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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user