Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
186 lines
7.2 KiB
TypeScript
186 lines
7.2 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useToast, useTRPC } from '@barev/web';
|
|
import { useState } from 'react';
|
|
import type { Me, Role } from '../App.tsx';
|
|
import { errText, useT } from '../i18n.tsx';
|
|
import { Avatar, Dialog, Field, I, Skeleton } from '../components/kit.tsx';
|
|
|
|
const ROLES: Role[] = ['SUPERADMIN', 'SUPPORT', 'CONCIERGE', 'SALES'];
|
|
|
|
export function Team({ me }: { me: Me }) {
|
|
const { t } = useT();
|
|
const trpc = useTRPC();
|
|
const qc = useQueryClient();
|
|
const toast = useToast();
|
|
const list = useQuery(trpc.bo.team.list.queryOptions());
|
|
const refresh = () => qc.invalidateQueries({ queryKey: trpc.bo.team.list.queryKey() });
|
|
const update = useMutation(trpc.bo.team.update.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
|
|
const [link, setLink] = useState<{ name: string; url: string } | null>(null);
|
|
const linkUrl = useMutation(trpc.bo.team.linkUrl.mutationOptions({ onError: (e) => toast(errText(t, e), 'error') }));
|
|
const [adding, setAdding] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
<div className="bo-head">
|
|
<div>
|
|
<h1 className="bo-title">{t('team.title')}</h1>
|
|
<p className="bo-sub">{t('team.lead')}</p>
|
|
</div>
|
|
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setAdding(true)}>
|
|
<I name="plus" size={16} />
|
|
{t('team.add')}
|
|
</button>
|
|
</div>
|
|
|
|
{!list.data ? (
|
|
<Skeleton h={240} />
|
|
) : (
|
|
<div className="bo-team">
|
|
{list.data.map((m) => {
|
|
const roles = m.roles as Role[];
|
|
const self = m.id === me.id;
|
|
return (
|
|
<div key={m.id} className={`bo-member${m.active ? '' : ' bo-member--off'}`}>
|
|
<Avatar name={m.name} id={m.id} size={44} />
|
|
<div style={{ minWidth: 0 }}>
|
|
<div style={{ fontWeight: 600 }}>
|
|
{m.name} {self && <span className="bo-faint bo-small">({t('team.you')})</span>}
|
|
</div>
|
|
<div className="bo-small" style={{ color: m.telegramLinked ? 'var(--bc-ok)' : 'var(--bc-ink-3)' }}>
|
|
{!m.active ? t('team.disabled') : m.telegramLinked ? t('team.tgLinked') : t('team.tgNotLinked')}
|
|
</div>
|
|
<div className="bo-roles" role="group" aria-label={t('team.roles')}>
|
|
{ROLES.map((r) => {
|
|
const on = roles.includes(r);
|
|
return (
|
|
<button
|
|
key={r}
|
|
type="button"
|
|
className="bo-role"
|
|
aria-pressed={on}
|
|
title={t(`roleHint.${r}`)}
|
|
disabled={update.isPending || (self && r === 'SUPERADMIN')}
|
|
onClick={() => {
|
|
const next = on ? roles.filter((x) => x !== r) : [...roles, r];
|
|
if (next.length) update.mutate({ id: m.id, roles: next }, { onSuccess: () => toast(t('team.saved')) });
|
|
}}
|
|
>
|
|
{on && <I name="check" size={14} stroke={2.4} />}
|
|
{t(`role.${r}`)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
|
{m.active && (
|
|
<button
|
|
type="button"
|
|
className="bo-btn bo-btn--ghost bo-btn--sm"
|
|
onClick={() => linkUrl.mutate({ id: m.id }, { onSuccess: (r) => setLink({ name: m.name, url: r.url }) })}
|
|
>
|
|
<I name="telegram" size={15} />
|
|
{t('team.newLink')}
|
|
</button>
|
|
)}
|
|
{!self && (
|
|
<button type="button" className={`bo-btn bo-btn--sm ${m.active ? 'bo-btn--danger' : 'bo-btn--ghost'}`} onClick={() => update.mutate({ id: m.id, active: !m.active })}>
|
|
{m.active ? t('team.disable') : t('team.enable')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<AddMember
|
|
open={adding}
|
|
onClose={() => setAdding(false)}
|
|
onCreated={(name, url) => {
|
|
setAdding(false);
|
|
refresh();
|
|
setLink({ name, url });
|
|
}}
|
|
/>
|
|
|
|
<Dialog
|
|
open={!!link}
|
|
onClose={() => setLink(null)}
|
|
title={link ? t('team.linkTitle', { name: link.name }) : ''}
|
|
footer={
|
|
<button type="button" className="bo-btn" onClick={() => setLink(null)}>
|
|
{t('ui.close')}
|
|
</button>
|
|
}
|
|
>
|
|
<p className="bo-hint">{t('team.linkHint')}</p>
|
|
{link && (
|
|
<div className="bo-linkbox">
|
|
<span style={{ flex: 1 }}>{link.url}</span>
|
|
<button
|
|
type="button"
|
|
className="bo-btn bo-btn--sm"
|
|
onClick={() => navigator.clipboard.writeText(link.url).then(() => toast(t('ui.copied')), () => undefined)}
|
|
>
|
|
{t('ui.copy')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AddMember({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: (name: string, url: string) => void }) {
|
|
const { t } = useT();
|
|
const trpc = useTRPC();
|
|
const toast = useToast();
|
|
const [name, setName] = useState('');
|
|
const [roles, setRoles] = useState<Role[]>(['SUPPORT']);
|
|
const create = useMutation(
|
|
trpc.bo.team.create.mutationOptions({
|
|
onSuccess: (r) => {
|
|
onCreated(r.name, r.linkUrl);
|
|
setName('');
|
|
setRoles(['SUPPORT']);
|
|
},
|
|
onError: (e) => toast(errText(t, e), 'error'),
|
|
}),
|
|
);
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onClose={onClose}
|
|
title={t('team.add')}
|
|
footer={
|
|
<>
|
|
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
|
|
{t('ui.close')}
|
|
</button>
|
|
<button type="button" className="bo-btn" disabled={!name.trim() || !roles.length || create.isPending} onClick={() => create.mutate({ name: name.trim(), roles })}>
|
|
{t('team.create')}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<Field label={t('team.name')}>
|
|
<input className="bo-input" value={name} onChange={(e) => setName(e.target.value)} maxLength={80} autoFocus />
|
|
</Field>
|
|
<Field label={t('team.roles')}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{ROLES.map((r) => (
|
|
<label key={r} className="bo-check">
|
|
<input type="checkbox" checked={roles.includes(r)} onChange={(e) => setRoles(e.target.checked ? [...roles, r] : roles.filter((x) => x !== r))} />
|
|
<span>
|
|
<b style={{ fontWeight: 600 }}>{t(`role.${r}`)}</b> <span className="bo-muted">— {t(`roleHint.${r}`)}</span>
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</Field>
|
|
</Dialog>
|
|
);
|
|
}
|