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:
1 parent
2772837e29
commit
e7de4f5893
33 files changed
+4521
-45
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user