Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -0,0 +1,185 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user