Files
BarevClub/apps/backoffice/src/pages/Orders.tsx
T
zabudko-ruandClaude Opus 5.5 dcba7c200c Relocations and requests in the new admin; star rating in the client bot
Orders section gets tabs: Orders / Relocations / Requests (concierge sees the
last two). Relocation list with statuses, plan progress and current step;
relocation card with per-client steps (status, client comment, rename, add,
remove), documents, quiz answers, team note, write/call. Requests: website
cleaning requests and app requests (membership, call back) with notes,
create order, turn on membership. Dashboard links point here instead of the
old admin.

Fix: leads with an unanswered 'already moved?' (NULL) were skipped by
'moved not true' filters — plan sync and linking to the app client.

Client bot: after an order is done, asks for 1-5 stars; low rating asks what
went wrong, the reply is saved as the rating comment and goes to the team chat.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 04:17:21 +04:00

235 lines
10 KiB
TypeScript

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';
import { OrdersTabs } from './Moves.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>
<OrdersTabs me={me} current="orders" />
{!!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>
);
}