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>
235 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|