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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+185
View File
@@ -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>
);
}