Backoffice stage 3: orders and providers, auto-assignment

- Auto-assignment: a new order is offered to the best free provider (same ranking as
  the dispatcher hint); a decline or timeout passes it to the next one; nobody is
  offered the same order twice; after N tries it waits for a dispatcher and the team
  is notified once. The worker retries waiting orders every minute. Settings
  assign.auto / assign.maxAutoOffers (toggle on the Orders page, checkbox in old admin).
- Orders: list by day with groups (need a provider, offered, assigned, in progress,
  done, cancelled), periods and search; order card with progress, provider or ranked
  candidates ("offer" / "pick automatically"), details, checklist and photos,
  history in plain words, client, money (superadmin), payment, status change,
  reschedule (slots or custom time), cancel with fee.
- New order for a client (from the client card or the Orders page): params form,
  address, free slots for 14 days, price with the client's discount.
- Providers: list with rating, orders, declines, low ratings, next order; card with
  quality, ratings, upcoming orders, schedule and districts, admission documents,
  block/unblock with reason, new bot link, money and payouts (superadmin).
- Payout gets paidAt/note/createdById; Provider gets blockedReason/blockedAt/adminNote
  and documents (ProviderDoc).
- Tests for auto-assignment.

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-21 02:25:41 +04:00
1 parent 2772837e29
commit e7de4f5893
33 files changed
+4521 -45

No files matched your search

+2 -2
View File
@@ -528,7 +528,7 @@ function OrderTag({ order }: { order: OrderBrief | null }) {
</div>
);
return (
<a className="bo-ordertag" href={`#/clients/${order.clientId}`} title={t('chat.orderClient', { name: order.clientName })}>
<a className="bo-ordertag" href={`#/orders/${order.id}`} title={t('chat.orderClient', { name: order.clientName })}>
<I name="orders" size={13} />
{text(order)}
</a>
@@ -545,7 +545,7 @@ function ProviderOrders({ current, next }: { current: OrderBrief | null; next: O
<span className="bo-grow" style={{ minWidth: 0 }}>
{current ? (
<>
<b>{t('chat.orderNow')}:</b> {text(current, true)}
<b>{t('chat.orderNow')}:</b> <a href={`#/orders/${current.id}`} style={{ color: 'inherit' }}>{text(current, true)}</a>
</>
) : (
<b>{t('chat.noOrderNow')}</b>
+11 -6
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useToast, useTRPC } from '@barev/web';
import { myChannels } from './Chats.tsx';
import { NewOrderDialog } from './NewOrder.tsx';
import { useEffect, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT, type BoKey } from '../i18n.tsx';
@@ -16,6 +17,7 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
const trpc = useTRPC();
const c = useQuery(trpc.bo.clients.get.queryOptions({ id }));
const [dlg, setDlg] = useState<{ mode: SubMode; sub?: SubTarget } | null>(null);
const [newOrder, setNewOrder] = useState(false);
const isSuper = me.roles.includes('SUPERADMIN');
const canManage = isSuper || me.roles.includes('SUPPORT');
const canChat = myChannels(me).length > 0;
@@ -189,10 +191,12 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('card.orders')}</h2>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled title={t('card.newOrderSoon')}>
<I name="plus" size={16} />
{t('card.newOrder')}
</button>
{canManage && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setNewOrder(true)}>
<I name="plus" size={16} />
{t('card.newOrder')}
</button>
)}
</div>
{d.orders.length === 0 ? (
<p className="bo-hint">{t('card.noOrders')}</p>
@@ -200,9 +204,9 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
<ul className="bo-list">
{d.orders.map((o) => (
<li key={o.id} style={{ flexWrap: 'wrap' }}>
<span className="bo-num bo-faint" style={{ width: 52 }}>
<a href={`#/orders/${o.id}`} className="bo-num bo-faint" style={{ width: 52 }}>
№{o.number}
</span>
</a>
<span className="bo-grow" style={{ minWidth: 140 }}>
<b style={{ fontWeight: 600 }}>{shared(`services.${o.service}.name`)}</b>
<span className="bo-muted bo-small" style={{ display: 'block' }}>
@@ -324,6 +328,7 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
</div>
{dlg && <SubDialog clientId={d.id} mode={dlg.mode} sub={dlg.sub} current={current} onClose={() => setDlg(null)} />}
{newOrder && <NewOrderDialog clientId={d.id} onClose={() => setNewOrder(false)} />}
</>
);
}
+2 -2
View File
@@ -55,12 +55,12 @@ export function Dashboard({ me }: { me: Me }) {
type Att = { unassigned: number; unreadChats: number; webRequests: number; leads: number; appRequests: number; lowRatings: number; expiring: number };
const ATT: { key: keyof Att; href: string; urgent?: boolean; fill: string; ink: string }[] = [
{ key: 'unassigned', href: '#/orders', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'unassigned', href: '#/orders?group=new', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'unreadChats', href: '#/chats', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'webRequests', href: `${OLD_ADMIN_URL}#/requests`, fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
{ key: 'leads', href: `${OLD_ADMIN_URL}#/leads`, fill: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' },
{ key: 'appRequests', href: `${OLD_ADMIN_URL}#/app-requests`, fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
{ key: 'lowRatings', href: '#/orders', fill: 'var(--bc-pink)', ink: 'var(--bc-plum)' },
{ key: 'lowRatings', href: '#/providers', fill: 'var(--bc-pink)', ink: 'var(--bc-plum)' },
{ key: 'expiring', href: '#/clients?expiring=7', fill: 'var(--bc-perks)', ink: 'var(--bc-perks-ink)' },
];
+391
View File
@@ -0,0 +1,391 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDateTime, type Locale } from '@barev/i18n';
import { navigate, useToast, useTRPC } from '@barev/web';
import { useEffect, useMemo, useState } from 'react';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, PlanBadge } from '../components/kit.tsx';
type ParamField =
| { key: string; type: 'select'; options: string[]; default?: string }
| { key: string; type: 'int'; min: number; max: number; default?: number }
| { key: string; type: 'text'; maxLength: number; required?: boolean }
| { key: string; type: 'bool'; default?: boolean }
| { key: string; type: 'extras'; items: { code: string; max: number }[] };
type Params = Record<string, unknown>;
/** Дата по Еревану в виде YYYY-MM-DD */
const dayKey = (d: Date) => new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 10);
/** Время по Еревану из даты и «чч:мм» */
const atYerevan = (key: string, hhmm: string) => new Date(`${key}T${hhmm}:00+04:00`);
function defaults(fields: ParamField[]): Params {
const out: Params = {};
for (const f of fields) {
if (f.type === 'select') out[f.key] = f.default ?? f.options[0];
else if (f.type === 'int') out[f.key] = f.default ?? f.min;
else if (f.type === 'bool') out[f.key] = f.default ?? false;
else if (f.type === 'extras') out[f.key] = {};
}
return out;
}
/**
* Время заказа: свободные слоты на выбранный день (с учётом графиков и уже назначенных заказов)
* или «другое время» — оператор договорился сам.
*/
export function SlotPicker({ service, params, excludeOrderId, initial, onChange }: { service: string; params: Params; excludeOrderId?: string; initial?: Date; onChange: (v: { when: Date; busy: boolean } | null) => void }) {
const { t, time, locale } = useT();
const trpc = useTRPC();
const [day, setDay] = useState(dayKey(initial && initial > new Date() ? initial : new Date()));
const [picked, setPicked] = useState<Date | null>(null);
const [custom, setCustom] = useState(false);
const [hhmm, setHhmm] = useState('10:00');
const slots = useQuery({ ...trpc.bo.orders.slots.queryOptions({ service, params, dateKey: day, excludeOrderId }), placeholderData: keepPreviousData });
useEffect(() => {
if (custom) onChange(hhmm ? { when: atYerevan(day, hhmm), busy: true } : null);
else onChange(picked ? { when: picked, busy: false } : null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [custom, hhmm, day, picked]);
const days = useMemo(() => {
const out: string[] = [];
for (let i = 0; i < 14; i++) out.push(dayKey(new Date(Date.now() + i * 86_400_000)));
return out;
}, []);
const free = (slots.data ?? []).filter((s) => s.capacity > 0);
return (
<div className="bo-col" style={{ gap: 12 }}>
<div className="bo-days" role="group" aria-label={t('no.day')}>
{days.map((k) => {
const d = atYerevan(k, '12:00');
return (
<button
key={k}
type="button"
className="bo-day"
aria-pressed={k === day}
onClick={() => {
setDay(k);
setPicked(null);
}}
>
<span>{formatDateTime(locale as Locale, d, { weekday: 'short' })}</span>
<b>{formatDateTime(locale as Locale, d, { day: 'numeric' })}</b>
</button>
);
})}
</div>
{!custom ? (
free.length === 0 ? (
<p className="bo-hint">{slots.isFetching ? t('ui.loading') : t('no.noSlots')}</p>
) : (
<div className="bo-slots">
{free.map((s) => (
<button key={s.start.toISOString()} type="button" className="bo-slot" aria-pressed={picked?.getTime() === s.start.getTime()} onClick={() => setPicked(s.start)}>
{time(s.start)}
<span>{s.capacity}</span>
</button>
))}
</div>
)
) : (
<Field label={t('no.customTime')} hint={t('no.customHint')}>
<input className="bo-input" type="time" value={hhmm} onChange={(e) => setHhmm(e.target.value)} step={900} />
</Field>
)}
<label className="bo-check">
<input type="checkbox" checked={custom} onChange={(e) => setCustom(e.target.checked)} />
{t('no.otherTime')}
</label>
</div>
);
}
/** Новый заказ за клиента: услуга → параметры → адрес → время. Цена — с его скидкой по подписке */
export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?: string; onClose: () => void }) {
const { t, shared, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [clientId, setClientId] = useState(fixedClient ?? '');
const services = useQuery(trpc.bo.orders.services.queryOptions());
const dict = useQuery(trpc.bo.orders.dictionaries.queryOptions());
const [svc, setSvc] = useState('');
const service = services.data?.find((s) => s.code === svc) ?? services.data?.[0];
const fields = (service?.paramsSchema.fields ?? []) as ParamField[];
const [params, setParams] = useState<Params>({});
useEffect(() => {
if (service) setParams(defaults(fields));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [service?.code]);
const [address, setAddress] = useState('');
const [districtId, setDistrictId] = useState('');
const [note, setNote] = useState('');
const [payment, setPayment] = useState('');
const [at, setAt] = useState<{ when: Date; busy: boolean } | null>(null);
const ready = !!service && Object.keys(params).length > 0;
const quote = useQuery({ ...trpc.bo.orders.quote.queryOptions({ clientId, service: service?.code ?? '', params }), enabled: ready && !!clientId, placeholderData: keepPreviousData });
const create = useMutation(
trpc.bo.orders.create.mutationOptions({
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: trpc.bo.orders.list.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.clients.get.queryKey() });
toast(t('no.created', { n: r.number }));
onClose();
navigate(`/orders/${r.id}`);
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const needsAddress = service && !service.isOnline;
const canSubmit = !!clientId && ready && !!at && (!needsAddress || (address.trim() && districtId)) && !create.isPending;
return (
<Dialog
open
wide
onClose={onClose}
title={t('no.title')}
footer={
<>
{quote.data && (
<span className="bo-grow bo-quote">
<b className="bo-num">{amd(quote.data.total)}</b>
<span className="bo-small bo-muted">
{' '}
· {t('ord.minutes', { n: quote.data.durationMinutes })}
{quote.data.discountPct > 0 && ` · −${quote.data.discountPct}%`}
</span>
</span>
)}
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button
type="button"
className="bo-btn bo-btn--accent"
disabled={!canSubmit}
onClick={() =>
at &&
service &&
create.mutate({
clientId,
service: service.code,
params,
scheduledAt: at.when,
allowBusySlot: at.busy,
addressText: needsAddress ? address : undefined,
districtId: needsAddress ? districtId : undefined,
note: note || undefined,
paymentMethodId: payment || undefined,
})
}
>
{t('no.create')}
</button>
</>
}
>
{!fixedClient && <ClientPicker value={clientId} onChange={setClientId} />}
{(services.data?.length ?? 0) > 1 && (
<Field label={t('no.service')}>
<select className="bo-select" value={service?.code ?? ''} onChange={(e) => setSvc(e.target.value)}>
{services.data!.map((s) => (
<option key={s.code} value={s.code}>
{shared(`services.${s.code}.name`)}
</option>
))}
</select>
</Field>
)}
{service && <ParamsForm service={service.code} fields={fields} value={params} onChange={setParams} />}
{needsAddress && (
<div className="bo-row2">
<Field label={t('no.address')}>
<input className="bo-input" value={address} maxLength={300} onChange={(e) => setAddress(e.target.value)} placeholder={t('no.addressPh')} />
</Field>
<Field label={t('no.district')}>
<select className="bo-select" value={districtId} onChange={(e) => setDistrictId(e.target.value)}>
<option value="">—</option>
{dict.data?.districts.map((d) => (
<option key={d.id} value={d.id}>
{shared(`districts.${d.code}`)}
</option>
))}
</select>
</Field>
</div>
)}
{ready && (
<div>
<span className="bo-label">{t('no.when')}</span>
<div style={{ marginTop: 6 }}>
<SlotPicker service={service!.code} params={params} onChange={setAt} />
</div>
</div>
)}
<div className="bo-row2">
<Field label={t('no.note')}>
<input className="bo-input" value={note} maxLength={1000} onChange={(e) => setNote(e.target.value)} placeholder={t('no.notePh')} />
</Field>
<Field label={t('no.payment')}>
<select className="bo-select" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="">—</option>
{dict.data?.paymentMethods.map((m) => (
<option key={m.id} value={m.id}>
{m.name.ru ?? m.code}
</option>
))}
</select>
</Field>
</div>
<p className="bo-hint">{t('no.hint')}</p>
</Dialog>
);
}
function ParamsForm({ service, fields, value, onChange }: { service: string; fields: ParamField[]; value: Params; onChange: (v: Params) => void }) {
const { t, shared } = useT();
const label = (k: string) => {
const key = `params.${service}.${k}.label`;
const v = shared(key);
return v === key ? k : v;
};
const opt = (k: string, o: string) => {
const key = `params.${service}.${k}.${o}`;
const v = shared(key);
return v === key ? o : v;
};
const set = (k: string, v: unknown) => onChange({ ...value, [k]: v });
const [showExtras, setShowExtras] = useState(false);
return (
<div className="bo-col" style={{ gap: 12 }}>
<div className="bo-row2">
{fields
.filter((f) => f.type === 'select' || f.type === 'int')
.map((f) =>
f.type === 'select' ? (
<Field key={f.key} label={label(f.key)}>
<select className="bo-select" value={String(value[f.key] ?? '')} onChange={(e) => set(f.key, e.target.value)}>
{f.options.map((o) => (
<option key={o} value={o}>
{opt(f.key, o)}
</option>
))}
</select>
</Field>
) : f.type === 'int' ? (
<Field key={f.key} label={label(f.key)}>
<Stepper value={Number(value[f.key] ?? f.min)} min={f.min} max={f.max} onChange={(n) => set(f.key, n)} />
</Field>
) : null,
)}
</div>
{fields.map((f) =>
f.type === 'text' ? (
<Field key={f.key} label={label(f.key)}>
<textarea className="bo-textarea" style={{ minHeight: 70 }} maxLength={f.maxLength} value={String(value[f.key] ?? '')} onChange={(e) => set(f.key, e.target.value)} />
</Field>
) : f.type === 'bool' ? (
<label key={f.key} className="bo-check">
<input type="checkbox" checked={!!value[f.key]} onChange={(e) => set(f.key, e.target.checked)} />
{label(f.key)}
</label>
) : f.type === 'extras' ? (
<div key={f.key}>
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0 }} onClick={() => setShowExtras((s) => !s)} aria-expanded={showExtras}>
{t('no.extras')} {Object.values((value[f.key] as Record<string, number>) ?? {}).filter((n) => n > 0).length || ''} {showExtras ? '▴' : '▾'}
</button>
{showExtras && (
<div className="bo-extras">
{f.items.map((it) => {
const ex = (value[f.key] as Record<string, number>) ?? {};
const n = ex[it.code] ?? 0;
return (
<div key={it.code} className="bo-extra" data-on={n > 0 || undefined}>
<span className="bo-grow">{shared(`extras.${it.code}.name`)}</span>
{it.max === 1 ? (
<input type="checkbox" checked={n > 0} onChange={(e) => set(f.key, { ...ex, [it.code]: e.target.checked ? 1 : 0 })} aria-label={shared(`extras.${it.code}.name`)} />
) : (
<Stepper value={n} min={0} max={it.max} onChange={(v) => set(f.key, { ...ex, [it.code]: v })} small />
)}
</div>
);
})}
</div>
)}
</div>
) : null,
)}
</div>
);
}
function Stepper({ value, min, max, onChange, small }: { value: number; min: number; max: number; onChange: (n: number) => void; small?: boolean }) {
return (
<span className={`bo-stepper${small ? ' bo-stepper--sm' : ''}`}>
<button type="button" onClick={() => onChange(Math.max(min, value - 1))} disabled={value <= min} aria-label="−">
−
</button>
<b className="bo-num">{value}</b>
<button type="button" onClick={() => onChange(Math.min(max, value + 1))} disabled={value >= max} aria-label="+">
+
</button>
</span>
);
}
function ClientPicker({ value, onChange }: { value: string; onChange: (id: string) => void }) {
const { t } = useT();
const trpc = useTRPC();
const [q, setQ] = useState('');
const [debounced, setDebounced] = useState('');
useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), 250);
return () => clearTimeout(id);
}, [q]);
const list = useQuery({ ...trpc.bo.clients.list.queryOptions({ search: debounced || undefined, sort: 'new', page: 0 }), enabled: debounced.length >= 2 && !value, placeholderData: keepPreviousData });
const picked = useQuery({ ...trpc.bo.clients.get.queryOptions({ id: value }), enabled: !!value });
if (value && picked.data)
return (
<div className="bo-person">
<Avatar name={picked.data.name} id={picked.data.id} size={36} />
<span className="bo-grow">
<b>{picked.data.name}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[picked.data.username && `@${picked.data.username}`, picked.data.phone].filter(Boolean).join(' · ')}
</span>
</span>
<button type="button" className="bo-iconbtn" onClick={() => onChange('')} aria-label={t('no.changeClient')}>
<I name="close" size={18} />
</button>
</div>
);
return (
<Field label={t('no.client')}>
<input className="bo-input" autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('no.clientPh')} />
{debounced.length >= 2 && (
<ul className="bo-picklist">
{(list.data?.items ?? []).slice(0, 8).map((c) => (
<li key={c.id}>
<button type="button" onClick={() => onChange(c.id)}>
<Avatar name={c.name} id={c.id} size={28} />
<span className="bo-grow">
{c.name}
<span className="bo-faint bo-small"> {[c.username && `@${c.username}`, c.phone].filter(Boolean).join(' · ')}</span>
</span>
<PlanBadge plan={c.sub?.plan ?? null} t={t} />
</button>
</li>
))}
{list.data && list.data.items.length === 0 && <li className="bo-hint" style={{ padding: 8 }}>{t('no.noClients')}</li>}
</ul>
)}
</Field>
);
}
+585
View File
@@ -0,0 +1,585 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, paramsExtras, paramsSummary, useToast, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, PlanBadge, Skeleton } from '../components/kit.tsx';
import { GROUP_DOT, statusLabel } from './Orders.tsx';
import { SlotPicker } from './NewOrder.tsx';
const MIN = 60_000;
export function OrderCard({ id, me }: { id: string; me: Me }) {
const { t, shared, sharedT, date, dateTime, time, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const o = useQuery({ ...trpc.bo.orders.get.queryOptions({ id }), refetchInterval: 15_000 });
const [dlg, setDlg] = useState<'reschedule' | 'cancel' | null>(null);
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.orders.get.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.orders.candidates.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.orders.list.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.orders.counts.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.dashboard.attention.queryKey() });
};
const onError = (e: unknown) => toast(errText(t, e), 'error');
const transition = useMutation(trpc.bo.orders.transition.mutationOptions({ onSuccess: () => (refresh(), toast(t('ord.statusChanged'))), onError }));
const markPaid = useMutation(trpc.bo.orders.markPaid.mutationOptions({ onSuccess: () => (refresh(), toast(t('ord.paidDone'))), onError }));
const chat = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError }));
const canChat = me.roles.some((r) => r === 'SUPERADMIN' || r === 'SUPPORT' || r === 'CONCIERGE');
const back = (
<a className="bo-back" href="#/orders">
<I name="back" size={16} />
{t('ord.back')}
</a>
);
if (o.isError)
return (
<>
{back}
<div className="bo-panel bo-empty">{errText(t, o.error)}</div>
</>
);
if (!o.data)
return (
<div className="bo-col">
<Skeleton h={90} />
<Skeleton h={260} />
</div>
);
const d = o.data;
const end = new Date(d.scheduledAt.getTime() + d.durationMinutes * MIN);
const extras = paramsExtras(sharedT, d.params);
const stepIdx = d.steps.indexOf(d.status as never);
return (
<>
{back}
<header className="bo-ohead">
<div style={{ minWidth: 0 }}>
<div className="bo-ohead__kicker">
<span className="bo-num">№{d.number}</span>
<span className="bo-status" style={{ ['--dot' as string]: GROUP_DOT[d.status === 'CANCELLED' ? 'cancelled' : d.group === 'new' && d.pending ? 'pending' : d.group] }}>
{statusLabel(shared, d.flow, d.status)}
</span>
</div>
<h1 className="bo-title" style={{ fontSize: 'clamp(26px, 3.4vw, 38px)' }}>
{shared(`services.${d.service}.name`)}
</h1>
<div className="bo-muted" style={{ marginTop: 6 }}>
{dateTime(d.scheduledAt)} – {time(end)}
{d.service === 'cleaning' && <> · {paramsSummary(sharedT, d.service, d.params)}</>}
</div>
</div>
</header>
{d.status !== 'CANCELLED' && (
<ol className="bo-steps" aria-label={t('ord.progress')}>
{d.steps.map((s, i) => (
<li key={s} data-state={i < stepIdx ? 'done' : i === stepIdx ? 'now' : 'next'}>
{statusLabel(shared, d.flow, s)}
</li>
))}
</ol>
)}
<div className="bo-client-grid">
<div className="bo-col">
<ProviderPanel order={d} onChanged={refresh} canChat={canChat} />
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.details')}</h2>
</div>
<dl className="bo-kv">
<dt>{t('ord.when')}</dt>
<dd>
{date(d.scheduledAt)}, {time(d.scheduledAt)} – {time(end)}
<span className="bo-faint"> · {t('ord.minutes', { n: d.durationMinutes })}</span>
</dd>
<dt>{t('ord.where')}</dt>
<dd>{d.isOnline ? shared('common.online') : [d.address, d.district ? shared(`districts.${d.district}`) : null].filter(Boolean).join(', ') || '—'}</dd>
{d.service === 'cleaning' && (
<>
<dt>{t('ord.params')}</dt>
<dd>{paramsSummary(sharedT, d.service, d.params)}</dd>
</>
)}
{d.service !== 'cleaning' &&
Object.entries(d.params)
.filter(([, v]) => typeof v === 'string' || typeof v === 'number')
.map(([k, v]) => (
<FragmentKV key={k} k={paramLabel(shared, d.service, k)} v={paramValue(shared, d.service, k, v)} />
))}
{extras.length > 0 && (
<>
<dt>{t('ord.extras')}</dt>
<dd>{extras.map((x) => x.label).join(', ')}</dd>
</>
)}
{d.note && (
<>
<dt>{t('ord.note')}</dt>
<dd style={{ whiteSpace: 'pre-line' }}>{d.note}</dd>
</>
)}
<dt>{t('ord.lang')}</dt>
<dd>{d.locale.toUpperCase()}</dd>
<dt>{t('ord.created')}</dt>
<dd>{dateTime(d.createdAt)}</dd>
</dl>
</section>
{d.checklist.length > 0 && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.checklist')}</h2>
<span className="bo-faint">
{d.checklist.filter((c) => c.done).length}/{d.checklist.length}
</span>
</div>
<ul className="bo-checks">
{d.checklist.map((c) => (
<li key={c.code} data-done={c.done || undefined}>
<span className="bo-checks__box">{c.done && <I name="check" size={14} stroke={3} />}</span>
{shared(`checklist.${c.code}`)}
{c.requiresPhoto && !c.fileId && <span className="bo-faint bo-small"> · {t('ord.needsPhoto')}</span>}
</li>
))}
</ul>
{d.photos.length > 0 && (
<div className="bo-photos">
{d.photos.map((f) => (
<a key={f} href={`/files/bo/order-file/${f}`} target="_blank" rel="noreferrer">
<img src={`/files/bo/order-file/${f}`} alt={t('ord.photo')} loading="lazy" />
</a>
))}
</div>
)}
</section>
)}
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.history')}</h2>
</div>
<ol className="bo-timeline">
{d.history.map((h) => (
<li key={h.id}>
<span className="bo-feed__time">{dateTime(h.at)}</span>
<span>
<b>{historyTitle(t, shared, d.flow, h)}</b>
<span className="bo-muted">
{' '}
· {h.actorName ?? shared(`admin.actors.${h.actor}`)}
{h.comment && ` · ${h.comment}`}
</span>
</span>
</li>
))}
</ol>
</section>
</div>
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.client')}</h2>
</div>
<a href={`#/clients/${d.client.id}`} className="bo-person">
<Avatar name={d.client.name} id={d.client.id} size={40} />
<span style={{ minWidth: 0 }}>
<b>{d.client.name}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[d.client.username && `@${d.client.username}`, d.client.phone].filter(Boolean).join(' · ') || '—'}
</span>
</span>
<PlanBadge plan={d.client.plan} t={t} />
</a>
{canChat && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" style={{ marginTop: 12 }} onClick={() => chat.mutate({ clientId: d.client.id, channel: 'SUPPORT' })}>
<I name="chats" size={16} />
{t('ord.writeClient')}
</button>
)}
</section>
{d.money && (
<section className="bo-panel bo-money-card">
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
{t('ord.money')}
</span>
<div className="bo-bignum" style={{ margin: '6px 0 10px' }}>
{amd(d.money.total)}
</div>
<dl className="bo-kv bo-kv--plain">
<dt>{t('ord.base')}</dt>
<dd className="bo-num">{amd(d.money.base)}</dd>
{d.money.discount > 0 && (
<>
<dt>{t('ord.discount', { pct: d.money.discountPct })}</dt>
<dd className="bo-num">−{amd(d.money.discount)}</dd>
</>
)}
{d.money.payout != null && (
<>
<dt>{t('ord.payout')}</dt>
<dd className="bo-num">{amd(d.money.payout)}</dd>
</>
)}
{d.money.cancelFee > 0 && (
<>
<dt>{t('ord.cancelFee')}</dt>
<dd className="bo-num">{amd(d.money.cancelFee)}</dd>
</>
)}
</dl>
</section>
)}
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.payment')}</h2>
<span className={`bo-chip${d.paymentStatus === 'PAID' ? ' bo-chip--ok' : ''}`}>{shared(`payment.${d.paymentStatus}`)}</span>
</div>
{d.paidAt && (
<p className="bo-hint">
{dateTime(d.paidAt)}
{d.paymentMethod && ` · ${d.paymentMethod.ru}`}
</p>
)}
{d.paymentStatus === 'UNPAID' && d.status !== 'CANCELLED' && (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => confirm(t('ord.confirmPaid')) && markPaid.mutate({ id })} disabled={markPaid.isPending}>
<I name="check" size={16} />
{t('ord.markPaid')}
</button>
)}
</section>
{(d.adminTransitions.length > 0 || d.canReschedule || d.canCancel) && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.actions')}</h2>
</div>
<div className="bo-col" style={{ gap: 8 }}>
{d.adminTransitions.map((to) => (
<button
key={to}
type="button"
className="bo-btn bo-btn--ghost bo-btn--sm"
disabled={transition.isPending}
onClick={() => confirm(t('ord.confirmStatus', { status: statusLabel(shared, d.flow, to) })) && transition.mutate({ id, to })}
>
{t('ord.setStatus', { status: statusLabel(shared, d.flow, to) })}
</button>
))}
{d.canReschedule && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg('reschedule')}>
{t('ord.reschedule')}
</button>
)}
{d.canCancel && (
<button type="button" className="bo-btn bo-btn--danger bo-btn--sm" onClick={() => setDlg('cancel')}>
{t('ord.cancel')}
</button>
)}
</div>
</section>
)}
{d.rating && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.rating')}</h2>
<span className="bo-stars">{'★'.repeat(d.rating.score)}{'☆'.repeat(5 - d.rating.score)}</span>
</div>
{d.rating.comment ? <p style={{ margin: 0 }}>{d.rating.comment}</p> : <p className="bo-hint">{t('ord.noComment')}</p>}
</section>
)}
{d.offers.length > 0 && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.offers')}</h2>
</div>
<ul className="bo-list">
{d.offers.map((x) => (
<li key={x.id}>
<span className="bo-grow">
<b>{x.name}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{dateTime(x.createdAt)} · {x.by ?? t('ord.bySystem')}
</span>
</span>
<span className="bo-chip">{shared(`admin.offerStatus.${x.status}`)}</span>
</li>
))}
</ul>
</section>
)}
{d.cancelReason && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.cancelReason')}</h2>
</div>
<p style={{ margin: 0 }}>{d.cancelReason}</p>
</section>
)}
</div>
</div>
{dlg === 'reschedule' && <RescheduleDialog order={d} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'cancel' && <CancelDialog id={id} showFee={!!d.money} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
}
function FragmentKV({ k, v }: { k: string; v: string }) {
return (
<>
<dt>{k}</dt>
<dd>{v}</dd>
</>
);
}
const paramLabel = (shared: (k: string) => string, svc: string, key: string) => {
const k = `params.${svc}.${key}.label`;
const v = shared(k);
return v === k ? key : v;
};
const paramValue = (shared: (k: string) => string, svc: string, key: string, value: unknown) => {
const k = `params.${svc}.${key}.${String(value)}`;
const v = shared(k);
return v === k ? String(value) : v;
};
type HistoryRow = { event: string | null; from: string | null; to: string; comment: string | null };
function historyTitle(t: ReturnType<typeof useT>['t'], shared: (k: string) => string, flow: string, h: HistoryRow) {
if (h.event) {
const own = `ord.ev.${h.event}`;
const ownTr = t(own);
if (ownTr !== own) return ownTr;
const sh = `admin.events.${h.event}`;
const shTr = shared(sh);
if (shTr !== sh) return shTr;
}
if (!h.from) return t('ord.ev.created');
return statusLabel(shared, flow, h.to);
}
// ───── исполнитель и подбор ─────
type OrderData = NonNullable<ReturnType<typeof useOrderData>>;
function useOrderData(id: string) {
const trpc = useTRPC();
return useQuery(trpc.bo.orders.get.queryOptions({ id })).data;
}
function ProviderPanel({ order: d, onChanged, canChat }: { order: OrderData; onChanged: () => void; canChat: boolean }) {
const { t, shared } = useT();
const trpc = useTRPC();
const toast = useToast();
const [showBusy, setShowBusy] = useState(false);
const onError = (e: unknown) => toast(errText(t, e), 'error');
const cands = useQuery({ ...trpc.bo.orders.candidates.queryOptions({ id: d.id }), enabled: d.canAssign && !d.pending });
const assign = useMutation(trpc.bo.orders.assign.mutationOptions({ onSuccess: () => (onChanged(), toast(t('ord.offered'))), onError }));
const auto = useMutation(
trpc.bo.orders.auto.mutationOptions({
onSuccess: (r) => {
onChanged();
toast(r.result === 'offered' ? t('ord.autoOffered', { name: r.target }) : t('ord.autoNone'), r.result === 'offered' ? undefined : 'error');
},
onError,
}),
);
const unassign = useMutation(trpc.bo.orders.unassign.mutationOptions({ onSuccess: () => (onChanged(), toast(t('ord.unassigned'))), onError }));
const chat = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError }));
const [left, setLeft] = useState('');
useEffect(() => {
const exp = d.pending?.expiresAt;
if (!exp) return setLeft('');
const tick = () => setLeft(t('ord.minLeft', { n: Math.max(0, Math.ceil((exp.getTime() - Date.now()) / MIN)) }));
tick();
const iv = setInterval(tick, 15_000);
return () => clearInterval(iv);
}, [d.pending?.expiresAt, t]);
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.provider')}</h2>
{d.canUnassign && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled={unassign.isPending} onClick={() => confirm(d.provider ? t('ord.confirmUnassign') : t('ord.confirmRevoke')) && unassign.mutate({ id: d.id })}>
{d.provider ? t('ord.unassign') : t('ord.revoke')}
</button>
)}
</div>
{d.provider ? (
<div className="bo-person">
<Avatar name={d.provider.name} id={d.provider.id} size={40} />
<a href={`#/providers/${d.provider.id}`} style={{ minWidth: 0, color: 'inherit', textDecoration: 'none' }}>
<b>{d.provider.name}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[d.company?.name, d.provider.phone, d.provider.username && `@${d.provider.username}`].filter(Boolean).join(' · ') || '—'}
</span>
</a>
{canChat && (
<button type="button" className="bo-iconbtn" title={t('ord.writeProvider')} aria-label={t('ord.writeProvider')} onClick={() => chat.mutate({ providerId: d.provider!.id })}>
<I name="chats" size={20} />
</button>
)}
</div>
) : d.pending ? (
<div className="bo-pendingbox">
{d.pending.auto ? <I name="bot" size={20} /> : <I name="send" size={20} />}
<span className="bo-grow">
<b>{d.pending.company ? t('ord.pendingCompany', { name: d.pending.name }) : t('ord.pendingProvider', { name: d.pending.name })}</b>
<span className="bo-small" style={{ display: 'block', opacity: 0.8 }}>
{[d.pending.auto ? t('ord.byAuto') : null, left].filter(Boolean).join(' · ')}
</span>
</span>
</div>
) : d.canAssign ? (
<>
<div className="bo-assignbar">
<p className="bo-hint bo-grow" style={{ margin: 0 }}>
{t('ord.pickHint')}
</p>
<button type="button" className="bo-btn bo-btn--sm" onClick={() => auto.mutate({ id: d.id })} disabled={auto.isPending}>
<I name="bot" size={16} />
{t('ord.autoPick')}
</button>
</div>
{!cands.data ? (
<Skeleton h={120} />
) : cands.data.free.length === 0 ? (
<p className="bo-empty" style={{ padding: '16px 0' }}>
{t('ord.noFree')}
</p>
) : (
<ul className="bo-cands">
{cands.data.free.map((c, i) => (
<li key={`${c.kind}:${c.id}`}>
<span className="bo-cands__rank">{i + 1}</span>
<span className="bo-grow" style={{ minWidth: 0 }}>
<b>{c.name}</b>
{c.kind === 'company' && <span className="bo-chip" style={{ marginLeft: 6 }}>{t('ord.company')}</span>}
{c.tried && <span className="bo-chip bo-chip--warn" style={{ marginLeft: 6 }}>{t('ord.tried')}</span>}
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[c.rating != null ? `★ ${c.rating}` : null, c.languages.join(', ').toUpperCase(), ...c.reasons.map((r) => shared(`admin.reasons.${r}`)), t('ord.load', { n: c.load })].filter(Boolean).join(' · ')}
</span>
</span>
<button type="button" className="bo-btn bo-btn--sm" disabled={assign.isPending} onClick={() => assign.mutate({ id: d.id, target: { kind: c.kind, id: c.id } })}>
{t('ord.offer')}
</button>
</li>
))}
</ul>
)}
{!!cands.data?.busy.length && (
<>
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-ink-2)', marginTop: 10 }} onClick={() => setShowBusy((s) => !s)}>
{t('ord.busy', { n: cands.data.busy.length })} {showBusy ? '▴' : '▾'}
</button>
{showBusy && <p className="bo-hint" style={{ marginTop: 6 }}>{cands.data.busy.map((c) => c.name).join(', ')}</p>}
</>
)}
</>
) : (
<p className="bo-hint">{t('ord.noProvider')}</p>
)}
</section>
);
}
// ───── диалоги ─────
function RescheduleDialog({ order: d, onClose, onDone }: { order: OrderData; onClose: () => void; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [at, setAt] = useState<{ when: Date; busy: boolean } | null>(null);
const [comment, setComment] = useState('');
const save = useMutation(
trpc.bo.orders.reschedule.mutationOptions({
onSuccess: (r) => {
onDone();
toast(r.keptProvider ? t('ord.rescheduled') : t('ord.rescheduledFree'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('ord.rescheduleTitle', { n: d.number })}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!at || save.isPending} onClick={() => at && save.mutate({ id: d.id, scheduledAt: at.when, allowBusySlot: at.busy, comment: comment || undefined })}>
{t('ord.reschedule')}
</button>
</>
}
>
<SlotPicker service={d.service} params={d.params} excludeOrderId={d.id} initial={d.scheduledAt} onChange={setAt} />
<Field label={t('ord.comment')}>
<input className="bo-input" value={comment} maxLength={500} onChange={(e) => setComment(e.target.value)} placeholder={t('ord.commentPh')} />
</Field>
{d.provider && <p className="bo-hint">{t('ord.rescheduleHint')}</p>}
</Dialog>
);
}
function CancelDialog({ id, showFee, onClose, onDone }: { id: string; showFee: boolean; onClose: () => void; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [reason, setReason] = useState('');
const [fee, setFee] = useState('');
const cancel = useMutation(
trpc.bo.orders.cancel.mutationOptions({
onSuccess: () => {
onDone();
toast(t('ord.cancelled'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('ord.cancelTitle')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn bo-btn--accent" disabled={!reason.trim() || cancel.isPending} onClick={() => cancel.mutate({ id, reason, fee: Number(fee) || 0 })}>
{t('ord.cancel')}
</button>
</>
}
>
<Field label={t('ord.cancelReason')}>
<textarea className="bo-textarea" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('ord.cancelReasonPh')} />
</Field>
{showFee && (
<Field label={t('ord.cancelFee')} hint={t('ord.cancelFeeHint')}>
<input className="bo-input" inputMode="numeric" value={fee} onChange={(e) => setFee(e.target.value.replace(/\D/g, ''))} placeholder="0" />
</Field>
)}
<p className="bo-hint">{t('ord.cancelHint')}</p>
</Dialog>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDateTime, type Locale } from '@barev/i18n';
import { navigate, useLocation, useToast, useTRPC } from '@barev/web';
import { useEffect, useMemo, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { I, Skeleton } from '../components/kit.tsx';
import { NewOrderDialog } from './NewOrder.tsx';
type Group = 'active' | 'new' | 'pending' | 'assigned' | 'in_progress' | 'done' | 'cancelled' | 'all';
type Period = 'all' | 'today' | 'tomorrow' | 'week' | 'past';
const GROUPS: Group[] = ['active', 'new', 'pending', 'assigned', 'in_progress', 'done', 'cancelled'];
const PERIODS: Period[] = ['all', 'today', 'tomorrow', 'week', 'past'];
const MIN = 60_000;
/** Цвет группы — как точка статуса */
export const GROUP_DOT: Record<string, string> = {
new: 'var(--bc-accent)',
pending: 'var(--bc-perks-ink)',
assigned: 'var(--bc-blue-ink)',
in_progress: 'var(--bc-ok)',
done: 'var(--bc-ink-3)',
cancelled: 'var(--bc-danger)',
};
export function Orders({ me }: { me: Me }) {
const { t, locale, shared, amd, time } = useT();
const trpc = useTRPC();
const { query } = useLocation();
const group = (query.get('group') as Group) || 'active';
const period = (query.get('period') as Period) || 'all';
const [search, setSearch] = useState(query.get('q') ?? '');
const [q, setQ] = useState(search);
const [creating, setCreating] = useState(false);
const isSuper = me.roles.includes('SUPERADMIN');
useEffect(() => {
const id = setTimeout(() => setQ(search.trim()), 300);
return () => clearTimeout(id);
}, [search]);
const set = (patch: { group?: Group; period?: Period }) => {
const p = new URLSearchParams();
const g = patch.group ?? group;
const per = patch.period ?? period;
if (g !== 'active') p.set('group', g);
if (per !== 'all') p.set('period', per);
const s = p.toString();
navigate(`/orders${s ? `?${s}` : ''}`, { replace: true });
};
const list = useQuery({ ...trpc.bo.orders.list.queryOptions({ group, period, search: q || undefined }), refetchInterval: 20_000, placeholderData: keepPreviousData });
const counts = useQuery({ ...trpc.bo.orders.counts.queryOptions({ period, search: q || undefined }), refetchInterval: 20_000 });
const c = counts.data;
const countOf = (g: Group) => (!c ? null : g === 'active' ? c.new + c.pending + c.assigned + c.in_progress : g === 'all' ? null : c[g]);
// по дням: «Сегодня, 21 окт.»
const days = useMemo(() => {
const out: { key: string; label: string; rows: NonNullable<typeof list.data> }[] = [];
const dayKey = (d: Date) => formatDateTime(locale as Locale, d, { year: 'numeric', month: '2-digit', day: '2-digit' });
const today = dayKey(new Date());
const tomorrow = dayKey(new Date(Date.now() + 86_400_000));
for (const r of list.data ?? []) {
const k = dayKey(r.scheduledAt);
let d = out.at(-1);
if (!d || d.key !== k) {
const nice = formatDateTime(locale as Locale, r.scheduledAt, { weekday: 'short', day: 'numeric', month: 'long' });
const label = k === today ? `${t('ord.today')}, ${nice}` : k === tomorrow ? `${t('ord.tomorrow')}, ${nice}` : nice;
d = { key: k, label: label.charAt(0).toUpperCase() + label.slice(1), rows: [] };
out.push(d);
}
d.rows.push(r);
}
return out;
}, [list.data, locale, t]);
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('ord.title')}</h1>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<AutoSwitch canEdit={isSuper} />
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
<I name="plus" size={16} />
{t('ord.new')}
</button>
</div>
</div>
{!!c?.needsDispatcher && group !== 'new' && (
<button type="button" className="bo-alert" onClick={() => set({ group: 'new' })}>
<I name="warn" size={18} />
<span className="bo-grow">{t('ord.needsDispatcher', { n: c.needsDispatcher })}</span>
<I name="arrowDown" size={16} />
</button>
)}
<div className="bo-toolbar">
<label className="bo-search">
<I name="search" size={18} />
<input className="bo-input" type="search" value={search} placeholder={t('ord.search')} aria-label={t('ord.search')} onChange={(e) => setSearch(e.target.value)} />
</label>
<div className="bo-pills" role="group" aria-label={t('ord.period')}>
{PERIODS.map((p) => (
<button key={p} type="button" className="bo-pill" aria-pressed={period === p} onClick={() => set({ period: p })}>
{t(`ord.period.${p}`)}
</button>
))}
</div>
</div>
<div className="bo-ogroups" role="tablist">
{GROUPS.map((g) => {
const n = countOf(g);
return (
<button key={g} type="button" role="tab" aria-selected={group === g} className="bo-ogroup" onClick={() => set({ group: g })}>
{g !== 'active' && <span className="bo-ogroup__dot" style={{ background: GROUP_DOT[g] }} />}
{t(`ord.group.${g}`)}
{n != null && <span className="bo-ogroup__n">{n}</span>}
</button>
);
})}
</div>
{list.isPending ? (
<div className="bo-col">
<Skeleton h={64} />
<Skeleton h={64} />
<Skeleton h={64} />
</div>
) : days.length === 0 ? (
<div className="bo-panel bo-empty">{t('ord.empty')}</div>
) : (
<div className="bo-col" style={{ gap: 18 }}>
{days.map((d) => (
<section key={d.key}>
<h2 className="bo-oday">
{d.label}
<span className="bo-faint">{d.rows.length}</span>
</h2>
<ul className="bo-orows">
{d.rows.map((o) => {
const end = new Date(o.scheduledAt.getTime() + o.durationMinutes * MIN);
return (
<li key={o.id}>
<a href={`#/orders/${o.id}`} className="bo-orow" data-group={o.group}>
<span className="bo-orow__time">
<b>{time(o.scheduledAt)}</b>
<span>{time(end)}</span>
</span>
<span className="bo-orow__main">
<span className="bo-orow__title">
<span className="bo-num bo-faint">№{o.number}</span> {shared(`services.${o.service}.name`)}
</span>
<span className="bo-orow__sub">
{[o.isOnline ? shared('common.online') : o.district ? shared(`districts.${o.district}`) : null, o.client.name].filter(Boolean).join(' · ')}
</span>
</span>
<span className="bo-orow__who">
{o.provider ? (
<span>{o.provider.name}</span>
) : o.pending ? (
<span className="bo-orow__pending">
{o.pending.auto ? <I name="bot" size={14} /> : null}
{t('ord.waitsAnswer', { name: o.pending.name })}
</span>
) : o.needsDispatcher ? (
<span className="bo-wait-chip bo-wait-chip--hot">{t('ord.needsHuman')}</span>
) : o.group === 'new' && o.scheduledAt.getTime() < Date.now() ? (
<span className="bo-wait-chip bo-wait-chip--hot">{t('ord.overdue')}</span>
) : o.group === 'new' ? (
<span className="bo-faint">{t('ord.searching')}</span>
) : (
<span className="bo-faint">—</span>
)}
</span>
<span className="bo-orow__status">
<span className="bo-status" style={{ ['--dot' as string]: GROUP_DOT[o.group] }}>
{statusLabel(shared, o.flow, o.status)}
</span>
{isSuper && o.total != null && <b className="bo-num">{amd(o.total)}</b>}
{o.group === 'done' && o.paymentStatus === 'UNPAID' && <span className="bo-chip bo-chip--warn">{shared('payment.UNPAID')}</span>}
</span>
</a>
</li>
);
})}
</ul>
</section>
))}
</div>
)}
{creating && <NewOrderDialog onClose={() => setCreating(false)} />}
</>
);
}
export function statusLabel(shared: (k: string) => string, flow: string, status: string) {
const k = `status.${flow}.${status}`;
const v = shared(k);
return v === k ? status : v;
}
/** Автораспределение: видно всем, переключает суперадмин */
function AutoSwitch({ canEdit }: { canEdit: boolean }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const s = useQuery(trpc.bo.orders.autoSetting.queryOptions());
const save = useMutation(
trpc.bo.orders.setAutoSetting.mutationOptions({
onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.orders.autoSetting.queryKey() }),
onError: (e) => toast(errText(t, e), 'error'),
}),
);
if (!s.data) return null;
const on = s.data.auto;
return (
<button
type="button"
className={`bo-autosw${on ? ' is-on' : ''}`}
role="switch"
aria-checked={on}
disabled={!canEdit || save.isPending}
title={t('ord.autoHint', { tries: s.data.maxTries, minutes: s.data.timeoutMinutes })}
onClick={() => save.mutate({ auto: !on })}
>
<span className="bo-autosw__knob" />
{t(on ? 'ord.autoOn' : 'ord.autoOff')}
</button>
);
}
+659
View File
@@ -0,0 +1,659 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useToast, useTRPC } from '@barev/web';
import { useRef, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, Skeleton } from '../components/kit.tsx';
import { statusLabel } from './Orders.tsx';
const ROLES = ['CLEANER', 'HANDYMAN', 'DRIVER', 'TEACHER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const;
const LANGS = ['ru', 'en', 'hy', 'zh', 'es'] as const;
type Role = (typeof ROLES)[number];
type Lang = (typeof LANGS)[number];
type Slot = { weekday: number; startMinute: number; endMinute: number };
const hhmm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
const minutes = (s: string) => {
const [h, m] = s.split(':').map(Number);
return (h ?? 0) * 60 + (m ?? 0);
};
export function ProviderCard({ id, me }: { id: string; me: Me }) {
const { t, shared, date, dateTime, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const p = useQuery(trpc.bo.providers.get.queryOptions({ id }));
const dict = useQuery(trpc.bo.providers.dictionaries.queryOptions());
const [dlg, setDlg] = useState<'edit' | 'block' | 'payout' | null>(null);
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.providers.get.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.providers.list.queryKey() });
};
const onError = (e: unknown) => toast(errText(t, e), 'error');
const setActive = useMutation(trpc.bo.providers.setActive.mutationOptions({ onSuccess: () => (refresh(), toast(t('prv.saved'))), onError }));
const reset = useMutation(trpc.bo.providers.resetInvite.mutationOptions({ onSuccess: refresh, onError }));
const removeDoc = useMutation(trpc.bo.providers.removeDoc.mutationOptions({ onSuccess: refresh, onError }));
const chat = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError }));
const canChat = me.roles.some((r) => r === 'SUPERADMIN' || r === 'SUPPORT');
const back = (
<a className="bo-back" href="#/providers">
<I name="back" size={16} />
{t('prv.back')}
</a>
);
if (p.isError)
return (
<>
{back}
<div className="bo-panel bo-empty">{errText(t, p.error)}</div>
</>
);
if (!p.data)
return (
<div className="bo-col">
<Skeleton h={90} />
<Skeleton h={300} />
</div>
);
const d = p.data;
const districtName = new Map((dict.data?.districts ?? []).map((x) => [x.id, shared(`districts.${x.code}`)]));
const q = d.quality;
const offers30 = q.accepted30 + q.declined30 + q.expired30;
return (
<>
{back}
<header className="bo-profile">
<Avatar name={d.name} id={d.id} size={64} />
<div style={{ flex: 1, minWidth: 220 }}>
<h1 className="bo-title" style={{ fontSize: 'clamp(26px, 3.4vw, 38px)' }}>
{d.name}
</h1>
<div className="bo-muted" style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: '4px 12px', alignItems: 'center' }}>
<span>{shared(`roles.${d.role}`)}</span>
{d.company && <span>{d.company.name}</span>}
{d.phone && <a href={`tel:${d.phone.replace(/\s/g, '')}`}>{d.phone}</a>}
{d.username && <span>@{d.username}</span>}
{d.active ? (
d.onShift ? <span className="bo-chip bo-chip--ok">{t('prv.onShift')}</span> : <span className="bo-chip">{t('prv.offShift')}</span>
) : (
<span className="bo-chip bo-chip--danger">{t('prv.blocked')}</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{canChat && d.linked && (
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => chat.mutate({ providerId: d.id })}>
<I name="chats" size={16} />
{t('prv.write')}
</button>
)}
<button type="button" className="bo-btn" onClick={() => setDlg('edit')}>
{t('prv.edit')}
</button>
</div>
</header>
{!d.active && (
<div className="bo-banner" style={{ background: 'var(--bc-pink)', color: 'var(--bc-plum)' }}>
<span>
<b>{t('prv.blockedSince', { date: d.blockedAt ? date(d.blockedAt) : '—' })}</b>
{d.blockedReason && ` ${d.blockedReason}`}
</span>
<button type="button" className="bo-btn bo-btn--sm" onClick={() => setActive.mutate({ id: d.id, active: true })} disabled={setActive.isPending}>
{t('prv.unblock')}
</button>
</div>
)}
{!d.linked && d.inviteUrl && (
<div className="bo-banner">
<span>{t('prv.inviteHint')}</span>
<span style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="bo-btn bo-btn--sm"
onClick={() => {
void navigator.clipboard?.writeText(d.inviteUrl!);
toast(t('ui.copied'));
}}
>
{t('prv.copyInvite')}
</button>
</span>
</div>
)}
<div className="bo-client-grid">
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.quality')}</h2>
<span className="bo-faint bo-small">{t('prv.days30')}</span>
</div>
<div className="bo-quality">
<div>
<b className="bo-bignum">{q.rating != null ? `★ ${q.rating}` : '—'}</b>
<span className="bo-small bo-muted">{t('prv.ratings', { n: q.ratingCount })}</span>
</div>
<div>
<b className="bo-bignum">{offers30 ? `${Math.round((q.accepted30 / offers30) * 100)}%` : '—'}</b>
<span className="bo-small bo-muted">{t('prv.acceptRate', { a: q.accepted30, n: offers30 })}</span>
</div>
<div>
<b className="bo-bignum" style={{ color: q.declined30 + q.expired30 >= 3 ? 'var(--bc-danger)' : undefined }}>
{q.declined30 + q.expired30}
</b>
<span className="bo-small bo-muted">{t('prv.refusals', { d: q.declined30, e: q.expired30 })}</span>
</div>
<div>
<b className="bo-bignum" style={{ color: q.low30 ? 'var(--bc-danger)' : undefined }}>
{q.low30}
</b>
<span className="bo-small bo-muted">{t('prv.lowRatings')}</span>
</div>
</div>
{d.ratings.length > 0 && (
<ul className="bo-list" style={{ marginTop: 14 }}>
{d.ratings.slice(0, 8).map((r) => (
<li key={r.id} style={{ alignItems: 'flex-start' }}>
<span className="bo-stars" style={{ minWidth: 76 }} data-low={r.score <= 3 || undefined}>
{'★'.repeat(r.score)}
{'☆'.repeat(5 - r.score)}
</span>
<span className="bo-grow" style={{ whiteSpace: 'normal' }}>
{r.comment ?? <span className="bo-faint">{t('ord.noComment')}</span>}
<span className="bo-small bo-faint" style={{ display: 'block' }}>
<a href={`#/orders/${r.orderId}`}>№{r.orderNumber}</a> · {r.client} · {date(r.at)}
</span>
</span>
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.upcoming')}</h2>
</div>
{d.upcoming.length === 0 ? (
<p className="bo-hint">{t('prv.noUpcoming')}</p>
) : (
<ul className="bo-list">
{d.upcoming.map((o) => (
<li key={o.id}>
<a href={`#/orders/${o.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>
№{o.number} · {shared(`services.${o.service}.name`)}
</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{dateTime(o.scheduledAt)}
{o.district && ` · ${shared(`districts.${o.district}`)}`}
</span>
</a>
<span className="bo-chip">{statusLabel(shared, o.flow, o.status)}</span>
</li>
))}
</ul>
)}
{d.recent.length > 0 && (
<p className="bo-hint" style={{ marginTop: 12 }}>
{t('prv.recent')}:{' '}
{d.recent.slice(0, 8).map((o, i) => (
<span key={o.id}>
{i > 0 && ', '}
<a href={`#/orders/${o.id}`}>№{o.number}</a>
{o.rating && ` ★${o.rating}`}
</span>
))}
</p>
)}
</section>
</div>
<div className="bo-col">
{d.money && (
<section className="bo-panel bo-money-card">
<div className="bo-panel__head" style={{ marginBottom: 6 }}>
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
{t('prv.balance')}
</span>
<span className="bo-small">{rateText(t, d.money.rate)}</span>
</div>
<div className="bo-bignum">{amd(d.money.balance)}</div>
<div className="bo-small" style={{ display: 'flex', gap: 16, flexWrap: 'wrap', margin: '8px 0 12px' }}>
<span>
{t('prv.thisMonth')} <b className="bo-num">{amd(d.money.thisMonth)}</b>
</span>
<span>
{t('prv.prevMonth')} <b className="bo-num">{amd(d.money.prevMonth)}</b>
</span>
<span>
{t('prv.paidTotal')} <b className="bo-num">{amd(d.money.paidTotal)}</b>
</span>
</div>
<button type="button" className="bo-btn bo-btn--sm" disabled={d.money.balance <= 0} onClick={() => setDlg('payout')}>
{t('prv.payout')}
</button>
{d.money.payouts.length > 0 && (
<ul className="bo-list" style={{ marginTop: 12 }}>
{d.money.payouts.slice(0, 6).map((x) => (
<li key={x.id}>
<span className="bo-grow">
<b className="bo-num">{amd(x.amount)}</b>
<span className="bo-small" style={{ display: 'block', opacity: 0.75 }}>
{[x.paidAt && date(x.paidAt), x.by, x.note].filter(Boolean).join(' · ')}
</span>
</span>
</li>
))}
</ul>
)}
</section>
)}
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.work')}</h2>
</div>
<dl className="bo-kv">
<dt>{t('prv.languages')}</dt>
<dd>{d.languages.join(', ').toUpperCase()}</dd>
<dt>{t('prv.districts')}</dt>
<dd>{d.districtIds.length ? d.districtIds.map((x) => districtName.get(x) ?? '').join(', ') : t('prv.anyDistrict')}</dd>
</dl>
<WeekView schedule={d.schedule} />
{d.adminNote && <p className="bo-side-panel__note" style={{ marginTop: 12 }}>{d.adminNote}</p>}
</section>
<DocsPanel providerId={d.id} docs={d.docs} onChanged={refresh} onRemove={(docId) => confirm(t('prv.removeDocConfirm')) && removeDoc.mutate({ id: docId })} />
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.access')}</h2>
</div>
<div className="bo-col" style={{ gap: 8 }}>
{d.active ? (
<button type="button" className="bo-btn bo-btn--danger bo-btn--sm" onClick={() => setDlg('block')}>
{t('prv.block')}
</button>
) : (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => setActive.mutate({ id: d.id, active: true })}>
{t('prv.unblock')}
</button>
)}
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => confirm(t('prv.resetConfirm')) && reset.mutate({ id: d.id })} disabled={reset.isPending}>
{t('prv.resetInvite')}
</button>
<p className="bo-hint">{t('prv.since', { date: date(d.createdAt) })}</p>
</div>
</section>
</div>
</div>
{dlg === 'edit' && <ProviderForm me={me} provider={d} onClose={() => setDlg(null)} />}
{dlg === 'block' && <BlockDialog id={d.id} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'payout' && d.money && <PayoutDialog id={d.id} balance={d.money.balance} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
}
function rateText(t: ReturnType<typeof useT>['t'], r: { kind: 'company' | 'preset' | 'own'; type?: string; value?: number; name?: string }) {
if (r.kind === 'preset') return t('prv.ratePreset', { name: r.name ?? '' });
const v = r.type === 'PERCENT' ? `${r.value}%` : `${r.value} ֏`;
return r.kind === 'company' ? t('prv.rateCompany', { v }) : t('prv.rateOwn', { v });
}
function WeekView({ schedule }: { schedule: Slot[] }) {
const { shared } = useT();
return (
<div className="bo-week">
{[0, 1, 2, 3, 4, 5, 6].map((wd) => {
const s = schedule.filter((x) => x.weekday === wd);
return (
<div key={wd} className="bo-week__day" data-off={!s.length || undefined}>
<b>{shared(`admin.weekdays.${wd}`)}</b>
<span>{s.length ? s.map((x) => `${hhmm(x.startMinute)}–${hhmm(x.endMinute)}`).join(', ') : '—'}</span>
</div>
);
})}
</div>
);
}
function DocsPanel({ providerId, docs, onChanged, onRemove }: { providerId: string; docs: { id: string; title: string; fileName: string; mime: string; size: number; createdAt: Date }[]; onChanged: () => void; onRemove: (id: string) => void }) {
const { t, date } = useT();
const toast = useToast();
const file = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState('');
const [busy, setBusy] = useState(false);
const upload = async (f: File) => {
if (f.size > 10 * 1024 * 1024) return toast(t('chat.fileTooBig'), 'error');
setBusy(true);
try {
const form = new FormData();
form.set('title', title || f.name);
form.set('file', f);
const res = await fetch(`/files/bo/provider-doc/${providerId}`, { method: 'POST', body: form, credentials: 'include' });
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? String(res.status));
setTitle('');
onChanged();
toast(t('prv.docAdded'));
} catch (e) {
toast(errText(t, e), 'error');
} finally {
setBusy(false);
}
};
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.docs')}</h2>
<span className="bo-faint bo-small">{docs.length}</span>
</div>
{docs.length === 0 ? (
<p className="bo-hint">{t('prv.noDocsHint')}</p>
) : (
<ul className="bo-list">
{docs.map((d) => (
<li key={d.id}>
<I name="file" size={20} />
<a className="bo-grow" href={`/files/bo/provider-doc-file/${d.id}`} target="_blank" rel="noreferrer" style={{ color: 'inherit', minWidth: 0 }}>
<b>{d.title}</b>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{d.fileName} · {date(d.createdAt)}
</span>
</a>
<button type="button" className="bo-iconbtn" onClick={() => onRemove(d.id)} aria-label={t('chat.tplDelete')}>
<I name="close" size={16} />
</button>
</li>
))}
</ul>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input className="bo-input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t('prv.docTitlePh')} maxLength={120} style={{ minHeight: 36, padding: '6px 12px' }} />
<button type="button" className="bo-btn bo-btn--sm" onClick={() => file.current?.click()} disabled={busy}>
<I name="clip" size={16} />
{t('prv.upload')}
</button>
<input
ref={file}
type="file"
hidden
accept="image/*,application/pdf"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (f) void upload(f);
}}
/>
</div>
</section>
);
}
function BlockDialog({ id, onClose, onDone }: { id: string; onClose: () => void; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [reason, setReason] = useState('');
const save = useMutation(
trpc.bo.providers.setActive.mutationOptions({
onSuccess: () => {
onDone();
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('prv.blockTitle')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn bo-btn--accent" disabled={!reason.trim() || save.isPending} onClick={() => save.mutate({ id, active: false, reason })}>
{t('prv.block')}
</button>
</>
}
>
<Field label={t('prv.blockReason')}>
<textarea className="bo-textarea" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('prv.blockReasonPh')} />
</Field>
<p className="bo-hint">{t('prv.blockHint')}</p>
</Dialog>
);
}
function PayoutDialog({ id, balance, onClose, onDone }: { id: string; balance: number; onClose: () => void; onDone: () => void }) {
const { t, amd } = useT();
const trpc = useTRPC();
const toast = useToast();
const [amount, setAmount] = useState(String(balance));
const [note, setNote] = useState('');
const save = useMutation(
trpc.bo.providers.payout.mutationOptions({
onSuccess: () => {
onDone();
toast(t('prv.payoutDone'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('prv.payoutTitle')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!Number(amount) || save.isPending} onClick={() => save.mutate({ id, amount: Number(amount), note: note || undefined })}>
{t('prv.payoutConfirm')}
</button>
</>
}
>
<Field label={t('prv.amount')} hint={t('prv.balanceHint', { sum: amd(balance) })}>
<input className="bo-input" inputMode="numeric" value={amount} onChange={(e) => setAmount(e.target.value.replace(/\D/g, ''))} />
</Field>
<Field label={t('prv.payoutNote')}>
<input className="bo-input" value={note} maxLength={300} onChange={(e) => setNote(e.target.value)} placeholder={t('prv.payoutNotePh')} />
</Field>
</Dialog>
);
}
// ───── форма исполнителя ─────
type ProviderData = {
id: string;
name: string;
phone: string | null;
role: string;
company: { id: string } | null;
languages: string[];
districtIds: string[];
schedule: Slot[];
adminNote: string | null;
money: { rateType: string; rateValue: number; payoutPresetId: string | null; presets: { id: string; name: string }[] } | null;
};
export function ProviderForm({ me, provider, onClose }: { me: Me; provider?: ProviderData; onClose: () => void }) {
const { t, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const dict = useQuery(trpc.bo.providers.dictionaries.queryOptions());
const isSuper = me.roles.includes('SUPERADMIN');
const [f, setF] = useState({
name: provider?.name ?? '',
phone: provider?.phone ?? '',
role: (provider?.role ?? 'CLEANER') as Role,
companyId: provider?.company?.id ?? '',
languages: (provider?.languages ?? ['ru']) as Lang[],
districtIds: provider?.districtIds ?? [],
adminNote: provider?.adminNote ?? '',
rateType: provider?.money?.rateType ?? 'PERCENT',
rateValue: String(provider?.money?.rateValue ?? 70),
payoutPresetId: provider?.money?.payoutPresetId ?? '',
});
// график: одно окно на день (так заведено в рабочем боте); дни без окна — выходные
const [week, setWeek] = useState<(null | { from: string; to: string })[]>(() =>
[0, 1, 2, 3, 4, 5, 6].map((wd) => {
const s = provider ? provider.schedule.find((x) => x.weekday === wd) : wd < 6 ? { startMinute: 540, endMinute: 1200 } : null;
return s ? { from: hhmm(s.startMinute), to: hhmm(s.endMinute) } : null;
}),
);
const done = (r?: { id: string; inviteUrl: string } | void) => {
qc.invalidateQueries({ queryKey: trpc.bo.providers.list.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.providers.get.queryKey() });
toast(t('prv.saved'));
onClose();
if (r && 'id' in r) navigate(`/providers/${r.id}`);
};
const onError = (e: unknown) => toast(errText(t, e), 'error');
const create = useMutation(trpc.bo.providers.create.mutationOptions({ onSuccess: done, onError }));
const update = useMutation(trpc.bo.providers.update.mutationOptions({ onSuccess: () => done(), onError }));
const companies = (dict.data?.companies ?? []).filter((c) => c.roles.includes(f.role));
const payload = {
name: f.name,
phone: f.phone || null,
role: f.role,
companyId: f.companyId || null,
languages: f.languages,
districtIds: f.districtIds,
adminNote: f.adminNote || null,
schedule: week.flatMap((w, wd) => (w ? [{ weekday: wd, startMinute: minutes(w.from), endMinute: minutes(w.to) }] : [])),
rate: isSuper ? { rateType: f.rateType as 'FIXED' | 'PERCENT', rateValue: Number(f.rateValue) || 0, payoutPresetId: f.payoutPresetId || null } : undefined,
};
const toggle = <T,>(list: T[], v: T) => (list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
return (
<Dialog
open
wide
onClose={onClose}
title={provider ? t('prv.editTitle') : t('prv.addTitle')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button
type="button"
className="bo-btn"
disabled={!f.name.trim() || !f.languages.length || create.isPending || update.isPending}
onClick={() => (provider ? update.mutate({ id: provider.id, ...payload }) : create.mutate(payload))}
>
{t('ui.save')}
</button>
</>
}
>
<div className="bo-row2">
<Field label={t('prv.name')}>
<input className="bo-input" value={f.name} maxLength={100} onChange={(e) => setF({ ...f, name: e.target.value })} />
</Field>
<Field label={t('chat.phone')}>
<input className="bo-input" value={f.phone} maxLength={30} onChange={(e) => setF({ ...f, phone: e.target.value })} placeholder="+374 …" />
</Field>
</div>
<div className="bo-row2">
<Field label={t('prv.role')}>
<select className="bo-select" value={f.role} onChange={(e) => setF({ ...f, role: e.target.value as Role, companyId: '' })}>
{ROLES.map((r) => (
<option key={r} value={r}>
{shared(`roles.${r}`)}
</option>
))}
</select>
</Field>
<Field label={t('prv.company')}>
<select className="bo-select" value={f.companyId} onChange={(e) => setF({ ...f, companyId: e.target.value })}>
<option value="">{t('prv.freelance')}</option>
{companies.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</Field>
</div>
<div>
<span className="bo-label">{t('prv.languages')}</span>
<div className="bo-pills" style={{ marginTop: 6 }}>
{LANGS.map((l) => (
<button key={l} type="button" className="bo-pill" aria-pressed={f.languages.includes(l)} onClick={() => setF({ ...f, languages: toggle(f.languages, l) })}>
{l.toUpperCase()}
</button>
))}
</div>
</div>
<div>
<span className="bo-label">{t('prv.districts')}</span>
<div className="bo-pills" style={{ marginTop: 6 }}>
{(dict.data?.districts ?? []).map((d) => (
<button key={d.id} type="button" className="bo-pill" aria-pressed={f.districtIds.includes(d.id)} onClick={() => setF({ ...f, districtIds: toggle(f.districtIds, d.id) })}>
{shared(`districts.${d.code}`)}
</button>
))}
</div>
<span className="bo-hint">{t('prv.districtsHint')}</span>
</div>
<div>
<span className="bo-label">{t('prv.schedule')}</span>
<div className="bo-sched">
{week.map((w, wd) => (
<div key={wd} className="bo-sched__row" data-off={!w || undefined}>
<label className="bo-check" style={{ minWidth: 64 }}>
<input type="checkbox" checked={!!w} onChange={(e) => setWeek(week.map((x, i) => (i === wd ? (e.target.checked ? { from: '09:00', to: '20:00' } : null) : x)))} />
{shared(`admin.weekdays.${wd}`)}
</label>
{w && (
<>
<input className="bo-input" type="time" value={w.from} step={900} onChange={(e) => setWeek(week.map((x, i) => (i === wd ? { ...w, from: e.target.value } : x)))} />
<span>–</span>
<input className="bo-input" type="time" value={w.to} step={900} onChange={(e) => setWeek(week.map((x, i) => (i === wd ? { ...w, to: e.target.value } : x)))} />
</>
)}
</div>
))}
</div>
</div>
{isSuper && !f.companyId && (
<div className="bo-row2">
<Field label={t('prv.rate')}>
<select className="bo-select" value={f.payoutPresetId || f.rateType} onChange={(e) => (e.target.value === 'PERCENT' || e.target.value === 'FIXED' ? setF({ ...f, payoutPresetId: '', rateType: e.target.value }) : setF({ ...f, payoutPresetId: e.target.value }))}>
<option value="PERCENT">{shared('admin.rate.PERCENT')}</option>
<option value="FIXED">{shared('admin.rate.FIXED')}</option>
{(provider?.money?.presets ?? []).map((p) => (
<option key={p.id} value={p.id}>
{t('prv.ratePreset', { name: p.name })}
</option>
))}
</select>
</Field>
{!f.payoutPresetId && (
<Field label={f.rateType === 'PERCENT' ? '%' : '֏'}>
<input className="bo-input" inputMode="numeric" value={f.rateValue} onChange={(e) => setF({ ...f, rateValue: e.target.value.replace(/\D/g, '') })} />
</Field>
)}
</div>
)}
<Field label={t('prv.note')}>
<textarea className="bo-textarea" style={{ minHeight: 70 }} value={f.adminNote} maxLength={4000} onChange={(e) => setF({ ...f, adminNote: e.target.value })} placeholder={t('card.notePh')} />
</Field>
</Dialog>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import type { Me } from '../App.tsx';
import { useT } from '../i18n.tsx';
import { Avatar, I, Skeleton } from '../components/kit.tsx';
import { ProviderForm } from './ProviderCard.tsx';
type Status = 'active' | 'blocked' | 'all';
const ROLES = ['CLEANER', 'HANDYMAN', 'DRIVER', 'TEACHER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const;
export function Providers({ me }: { me: Me }) {
const { t, shared, dateTime } = useT();
const trpc = useTRPC();
const [status, setStatus] = useState<Status>('active');
const [role, setRole] = useState<(typeof ROLES)[number] | undefined>();
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [creating, setCreating] = useState(false);
useEffect(() => {
const id = setTimeout(() => setQ(search.trim()), 300);
return () => clearTimeout(id);
}, [search]);
const list = useQuery({ ...trpc.bo.providers.list.queryOptions({ status, role, search: q || undefined }), placeholderData: keepPreviousData });
const roles = ROLES.filter((r) => role === r || (list.data ?? []).some((p) => p.role === r));
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('prv.title')}</h1>
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
<I name="plus" size={16} />
{t('prv.add')}
</button>
</div>
<div className="bo-toolbar">
<label className="bo-search">
<I name="search" size={18} />
<input className="bo-input" type="search" value={search} placeholder={t('prv.search')} aria-label={t('prv.search')} onChange={(e) => setSearch(e.target.value)} />
</label>
<div className="bo-pills" role="group">
{(['active', 'blocked', 'all'] as const).map((s) => (
<button key={s} type="button" className="bo-pill" aria-pressed={status === s} onClick={() => setStatus(s)}>
{t(`prv.status.${s}`)}
</button>
))}
</div>
{roles.length > 1 && (
<div className="bo-pills" role="group">
{roles.map((r) => (
<button key={r} type="button" className="bo-pill" aria-pressed={role === r} onClick={() => setRole(role === r ? undefined : r)}>
{shared(`roles.${r}`)}
</button>
))}
</div>
)}
</div>
{!list.data ? (
<div className="bo-col">
<Skeleton h={72} />
<Skeleton h={72} />
</div>
) : list.data.length === 0 ? (
<div className="bo-panel bo-empty">{t('prv.empty')}</div>
) : (
<div className="bo-panel" style={{ padding: 8 }}>
<table className="bo-table bo-ptable">
<thead>
<tr>
<th>{t('prv.col.name')}</th>
<th className="bo-r">{t('prv.col.rating')}</th>
<th className="bo-r">{t('prv.col.done')}</th>
<th className="bo-r">{t('prv.col.refused')}</th>
<th>{t('prv.col.next')}</th>
</tr>
</thead>
<tbody>
{list.data.map((p) => (
<tr key={p.id} onClick={() => (location.hash = `#/providers/${p.id}`)} data-off={!p.active || undefined}>
<td>
<a href={`#/providers/${p.id}`} className="bo-who" onClick={(e) => e.stopPropagation()}>
<span className="bo-pavatar" data-shift={p.onShift || undefined}>
<Avatar name={p.name} id={p.id} size={38} />
</span>
<span style={{ minWidth: 0 }}>
<span className="bo-who__name">{p.name}</span>
<span className="bo-who__meta">
{[shared(`roles.${p.role}`), p.company?.name, p.languages.join(' ').toUpperCase()].filter(Boolean).join(' · ')}
</span>
<span className="bo-tags" style={{ marginTop: 4 }}>
{!p.active && <span className="bo-chip bo-chip--danger">{t('prv.blocked')}</span>}
{p.active && p.onShift && <span className="bo-chip bo-chip--ok">{t('prv.onShift')}</span>}
{!p.linked && <span className="bo-chip bo-chip--warn">{t('prv.notLinked')}</span>}
{p.docs === 0 && p.active && <span className="bo-chip">{t('prv.noDocs')}</span>}
</span>
</span>
</a>
</td>
<td className="bo-r">
{p.rating != null ? (
<>
<b className="bo-num">★ {p.rating}</b>
<span className="bo-faint bo-small" style={{ display: 'block' }}>
{t('prv.ratings', { n: p.ratingCount })}
{p.low30 > 0 && <span style={{ color: 'var(--bc-danger)' }}> · {t('prv.low', { n: p.low30 })}</span>}
</span>
</>
) : (
<span className="bo-faint">—</span>
)}
</td>
<td className="bo-r bo-num">{p.done30}</td>
<td className="bo-r bo-num" style={{ color: p.refused30 >= 3 ? 'var(--bc-danger)' : undefined }}>
{p.refused30}
</td>
<td>
{p.next ? (
<a href={`#/orders/${p.next.id}`} onClick={(e) => e.stopPropagation()} className="bo-small">
№{p.next.number} · {dateTime(p.next.scheduledAt)}
</a>
) : (
<span className="bo-faint">—</span>
)}
{p.today > 0 && (
<span className="bo-faint bo-small" style={{ display: 'block' }}>
{t('prv.today', { n: p.today })}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<p className="bo-hint" style={{ padding: '10px 12px 4px' }}>
{t('prv.period30')}
</p>
</div>
)}
{creating && <ProviderForm me={me} onClose={() => setCreating(false)} />}
</>
);
}