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

+282
View File
@@ -0,0 +1,282 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Badge, Button, Card, Field, Icon, Spinner } from '@barev/ui';
import { confirmDialog, errorText, paramsExtras, paramsSummary, useI18n, useToast, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
/** ADM-3, ADM-4: карточка заказа и назначение исполнителя */
export function OrderDrawer({ id, onClose }: { id: string; onClose: () => void }) {
const { t, amd, dt, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const order = useQuery({ ...trpc.admin.orders.get.queryOptions({ id }), refetchInterval: 10_000 });
const methods = useQuery(trpc.admin.dictionaries.paymentMethods.queryOptions());
const [comment, setComment] = useState('');
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const done = (msg: string) => () => {
toast(msg);
void qc.invalidateQueries();
};
const fail = (e: unknown) => toast(errorText(t, e), 'error');
const transition = useMutation(trpc.admin.orders.transition.mutationOptions({ onSuccess: done(t('admin.order.statusChanged')), onError: fail }));
const cancel = useMutation(trpc.admin.orders.cancel.mutationOptions({ onSuccess: done(t('admin.order.cancelled')), onError: fail }));
const unassign = useMutation(trpc.admin.orders.unassign.mutationOptions({ onSuccess: done(t('admin.order.unassigned')), onError: fail }));
const markPaid = useMutation(trpc.admin.orders.markPaid.mutationOptions({ onSuccess: done(t('admin.order.paid')), onError: fail }));
const o = order.data;
const pendingOffer = o?.offers.find((f) => f.status === 'OFFERED');
const canAssign = o && !o.providerId && !pendingOffer && o.status === o.flowSteps[0];
const isFinal = o && !o.canCancel;
return (
<div className="drawer-backdrop" onClick={onClose}>
<aside className="drawer" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
{!o ? (
<Spinner />
) : (
<>
<div className="bc-between">
<h2 className="bc-h2">
#{o.number} · {t(`services.${o.service.code}.name`)}
</h2>
<button className="bc-btn bc-btn--light bc-btn--sm" onClick={onClose} aria-label={t('common.close')}>
<Icon name="close" size={18} />
</button>
</div>
<div className="bc-row" style={{ flexWrap: 'wrap' }}>
<Badge style={{ background: 'var(--bc-dark)', color: '#fff' }}>{t(`status.${o.service.flow}.${o.status}`)}</Badge>
<Badge>{t(`payment.${o.paymentStatus}`)}</Badge>
{pendingOffer && (
<Badge style={{ background: 'var(--bc-perks)' }}>
{pendingOffer.company && !pendingOffer.provider
? t('admin.kanban.toCompany', { name: pendingOffer.company.name })
: t('admin.kanban.awaiting', { name: pendingOffer.provider?.name ?? '' })}
</Badge>
)}
</div>
<Card className="bc-stack">
<dl className="kv" style={{ margin: 0, display: 'grid', gridTemplateColumns: '160px 1fr', gap: '6px 12px' }}>
<dt className="bc-muted">{t('admin.order.when')}</dt>
<dd style={{ margin: 0 }}>
{dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} · {o.durationMinutes} {t('admin.order.min')}
</dd>
<dt className="bc-muted">{t('admin.order.params')}</dt>
<dd style={{ margin: 0 }}>
{paramsSummary(t, o.service.code, o.params as Record<string, unknown>)}
{paramsExtras(t, o.params as Record<string, unknown>).map((e) => (
<div key={e.code} className="bc-small">
+ {e.label}
</div>
))}
</dd>
<dt className="bc-muted">{t('admin.order.address')}</dt>
<dd style={{ margin: 0 }}>
{o.isOnline ? t('common.online') : `${o.district ? t(`districts.${o.district.code}`) + ', ' : ''}${o.addressText ?? ''}`}
</dd>
{o.note && (
<>
<dt className="bc-muted">{t('admin.order.note')}</dt>
<dd style={{ margin: 0 }}>{o.note}</dd>
</>
)}
<dt className="bc-muted">{t('admin.order.client')}</dt>
<dd style={{ margin: 0 }}>
{o.client.user.firstName} {o.client.user.lastName ?? ''} {o.client.user.username ? `@${o.client.user.username}` : ''} · {o.locale.toUpperCase()}
{o.client.agency ? ` · ${t('admin.order.agency')}: ${o.client.agency.name}` : ''}
</dd>
<dt className="bc-muted">{t('admin.order.provider')}</dt>
<dd style={{ margin: 0 }}>
{o.provider?.name ?? '—'}
{o.company ? ` · ${o.company.name}` : ''}
</dd>
<dt className="bc-muted">{t('admin.order.price')}</dt>
<dd style={{ margin: 0 }}>
{amd(o.basePrice)}
{o.discountAmount > 0 ? ` − ${amd(o.discountAmount)} (Barev+ ${o.discountPct}%)` : ''} = <b>{amd(o.totalPrice)}</b>
{o.cancelFee > 0 ? ` · ${t('admin.order.cancelFee')}: ${amd(o.cancelFee)}` : ''}
</dd>
<dt className="bc-muted">{t('admin.order.payment')}</dt>
<dd style={{ margin: 0 }}>
{o.paymentMethod ? (o.paymentMethod.name as Record<string, string>)[locale] || (o.paymentMethod.name as Record<string, string>).ru : '—'} · {t(`payment.${o.paymentStatus}`)}
{o.paidAt ? ` · ${dt(o.paidAt)}` : ''}
</dd>
</dl>
</Card>
{canAssign && <Candidates orderId={o.id} />}
<Card className="bc-stack">
<h3 className="bc-h3">{t('admin.order.actions')}</h3>
<div className="bc-row" style={{ flexWrap: 'wrap' }}>
{o.adminTransitions.map((s) => (
<Button key={s} size="sm" variant="light" disabled={transition.isPending} onClick={() => transition.mutate({ orderId: o.id, to: s, comment: comment || undefined })}>
→ {t(`status.${o.service.flow}.${s}`)}
</Button>
))}
{(o.providerId || pendingOffer) && !isFinal && (
<Button size="sm" variant="outline" disabled={unassign.isPending} onClick={async () => (await confirmDialog(t('admin.order.confirmUnassign'))) && unassign.mutate({ orderId: o.id })}>
{t('admin.order.unassign')}
</Button>
)}
{o.paymentStatus === 'UNPAID' && o.status !== 'CANCELLED' && (
<Button size="sm" variant="dark" disabled={markPaid.isPending} onClick={async () => (await confirmDialog(t('admin.order.confirmPaid', { amount: amd(o.totalPrice) }))) && markPaid.mutate({ orderId: o.id, paymentMethodId: o.paymentMethodId ?? methods.data?.[0]?.id })}>
{t('admin.order.markPaid')}
</Button>
)}
{o.canCancel && (
<Button size="sm" variant="danger" disabled={cancel.isPending} onClick={async () => (await confirmDialog(t('admin.order.confirmCancel'))) && cancel.mutate({ orderId: o.id, reason: comment || undefined })}>
{t('admin.order.cancel')}
</Button>
)}
</div>
<Field label={t('admin.order.comment')}>
<input className="bc-input" value={comment} onChange={(e) => setComment(e.target.value)} maxLength={500} />
</Field>
</Card>
{o.checklist.length > 0 && (
<Card className="bc-stack">
<h3 className="bc-h3">{t('admin.order.checklist')}</h3>
{o.checklist.map((c) => (
<div key={c.id} className="bc-row">
<Icon name={c.checkedAt ? 'check' : 'close'} size={16} style={{ color: c.checkedAt ? 'var(--bc-ok)' : 'var(--bc-ink-3)' }} />
<span>{t(`checklist.${c.code}`)}</span>
{c.fileId && <FileLink fileId={c.fileId} />}
</div>
))}
</Card>
)}
<Card>
<h3 className="bc-h3" style={{ marginBottom: 8 }}>
{t('admin.order.history')}
</h3>
{o.history.map((h) => (
<div key={h.id} className="history-item">
<span className="bc-muted">{dt(h.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</span>
<span>
{h.event ? t(`admin.events.${h.event}`) : `${h.fromStatus ? t(`status.${o.service.flow}.${h.fromStatus}`) + ' → ' : ''}${t(`status.${o.service.flow}.${h.toStatus}`)}`}
<span className="bc-muted"> · {t(`admin.actors.${h.actorType}`)}</span>
{h.comment ? <div className="bc-small">{h.comment}</div> : null}
</span>
</div>
))}
{o.offers.length > 0 && (
<>
<h4 style={{ margin: '12px 0 4px' }}>{t('admin.order.offers')}</h4>
{o.offers.map((f) => (
<div key={f.id} className="history-item">
<span className="bc-muted">{dt(f.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</span>
<span>
{f.provider?.name ?? f.company?.name} · {t(`admin.offerStatus.${f.status}`)}
</span>
</div>
))}
</>
)}
</Card>
</>
)}
</aside>
</div>
);
}
function Candidates({ orderId }: { orderId: string }) {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const cands = useQuery(trpc.admin.orders.candidates.queryOptions({ id: orderId }));
const [showAll, setShowAll] = useState(false);
const assign = useMutation(
trpc.admin.orders.assign.mutationOptions({
onSuccess: () => {
toast(t('admin.order.offered'));
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
if (!cands.data) return <Spinner />;
const [best, ...rest] = cands.data.ranked;
return (
<Card tone="perks" className="bc-stack">
<h3 className="bc-h3">{t('admin.order.assignTitle')}</h3>
{best ? (
<Button block disabled={assign.isPending} onClick={() => assign.mutate({ orderId, target: { kind: best.kind, id: best.id } })}>
{t('admin.order.assignTo', { name: best.name })}
{best.kind === 'company' ? ` (${t('admin.order.company')})` : ''}
</Button>
) : (
<p style={{ margin: 0 }}>{t('admin.order.noCandidates')}</p>
)}
{best && <Reasons reasons={best.reasons} />}
{rest.length > 0 && (
<button className="bc-btn bc-btn--ghost bc-btn--sm" onClick={() => setShowAll(!showAll)}>
{t(showAll ? 'admin.order.hideOthers' : 'admin.order.chooseManually', { count: rest.length })}
</button>
)}
{showAll &&
rest.map((c) => (
<div key={`${c.kind}-${c.id}`} className="candidate">
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600 }}>
{c.name}
{c.kind === 'company' ? ` · ${t('admin.order.company')}` : ''}
</div>
<div className="bc-small bc-muted">
{c.languages.map((l) => l.toUpperCase()).join(' ')} · {t('admin.order.load', { count: c.load })}
{c.rating ? ` · ★ ${c.rating}` : ''}
</div>
<Reasons reasons={c.reasons} />
</div>
<Button size="sm" variant="light" disabled={assign.isPending} onClick={() => assign.mutate({ orderId, target: { kind: c.kind, id: c.id } })}>
{t('admin.order.assign')}
</Button>
</div>
))}
{cands.data.busy.length > 0 && (
<div className="bc-small">
{t('admin.order.busy')}: {cands.data.busy.map((b) => b.name).join(', ')}
</div>
)}
</Card>
);
}
function Reasons({ reasons }: { reasons: string[] }) {
const { t } = useI18n();
if (!reasons.length) return null;
return (
<div className="bc-row" style={{ flexWrap: 'wrap', gap: 4 }}>
{reasons.map((r) => (
<Badge key={r} style={{ fontSize: 11 }}>
{t(`admin.reasons.${r}`)}
</Badge>
))}
</div>
);
}
function FileLink({ fileId }: { fileId: string }) {
const { t } = useI18n();
const trpc = useTRPC();
const [enabled, setEnabled] = useState(false);
const url = useQuery({ ...trpc.admin.orders.fileUrl.queryOptions({ fileId }), enabled });
useEffect(() => {
if (url.data) window.open(url.data.url, '_blank', 'noopener');
}, [url.data]);
return (
<button className="bc-btn bc-btn--ghost bc-btn--sm" onClick={() => setEnabled(true)}>
<Icon name="camera" size={16} /> {t('admin.order.photo')}
</button>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { LOCALES } from '@barev/i18n';
import { Chip } from '@barev/ui';
import { useI18n } from '@barev/web';
import { useEffect, useState, type ReactNode } from 'react';
/** Мультивыбор чипами (языки, районы, роли) */
export function MultiChips<T extends string>({ value, options, onChange, label }: { value: T[]; options: { value: T; label: ReactNode }[]; onChange: (v: T[]) => void; label?: string }) {
return (
<div className="bc-chips" role="group" aria-label={label}>
{options.map((o) => {
const on = value.includes(o.value);
return (
<Chip key={o.value} pressed={on} onClick={() => onChange(on ? value.filter((v) => v !== o.value) : [...value, o.value])} style={{ minHeight: 36, padding: '4px 12px' }}>
{o.label}
</Chip>
);
})}
</div>
);
}
export const LOCALE_OPTIONS = LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }));
export type ScheduleRow = { weekday: number; startMinute: number; endMinute: number };
const toHm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
const fromHm = (s: string) => {
const [h, m] = s.split(':').map(Number);
return (h ?? 0) * 60 + (m ?? 0);
};
/** Недельный график исполнителя */
export function ScheduleEditor({ value, onChange }: { value: ScheduleRow[]; onChange: (v: ScheduleRow[]) => void }) {
const { t } = useI18n();
return (
<div className="schedule">
{[0, 1, 2, 3, 4, 5, 6].map((wd) => {
const row = value.find((r) => r.weekday === wd);
const update = (patch: Partial<ScheduleRow>) => onChange(value.map((r) => (r.weekday === wd ? { ...r, ...patch } : r)));
return (
<div key={wd} style={{ display: 'contents' }}>
<span style={{ fontWeight: 600 }}>{t(`admin.weekdays.${wd}`)}</span>
<input className="bc-input" type="time" value={row ? toHm(row.startMinute) : ''} disabled={!row} onChange={(e) => update({ startMinute: fromHm(e.target.value) })} aria-label={t('admin.schedule.from')} />
<input className="bc-input" type="time" value={row ? toHm(row.endMinute) : ''} disabled={!row} onChange={(e) => update({ endMinute: fromHm(e.target.value) })} aria-label={t('admin.schedule.to')} />
<input
type="checkbox"
checked={!!row}
aria-label={t('admin.schedule.works')}
onChange={(e) => onChange(e.target.checked ? [...value, { weekday: wd, startMinute: 540, endMinute: 1200 }] : value.filter((r) => r.weekday !== wd))}
style={{ width: 22, height: 22 }}
/>
</div>
);
})}
</div>
);
}
/** Локальная копия значения, сбрасывается при смене исходного */
export function useDraft<T>(source: T | undefined) {
const [draft, setDraft] = useState<T | undefined>(source);
useEffect(() => setDraft(source), [source]);
return [draft, setDraft] as const;
}
export function Copyable({ text }: { text: string }) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
return (
<span className="bc-row" style={{ gap: 6 }}>
<code className="mono" style={{ wordBreak: 'break-all' }}>
{text}
</code>
<button
type="button"
className="bc-btn bc-btn--ghost bc-btn--sm"
onClick={() => {
void navigator.clipboard?.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? t('admin.copied') : t('admin.copy')}
</button>
</span>
);
}