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