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>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 04:17:21 +04:00
1 parent bbb25d3d0d
commit dcba7c200c
19 files changed
+1422 -22

No files matched your search

+3 -3
View File
@@ -334,14 +334,14 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
}
/** Открыть переписку с клиентом (или начать её) — в канале, куда он сейчас пишет */
function WriteButton({ clientId }: { clientId: string }) {
export function WriteButton({ clientId, small }: { clientId: string; small?: boolean }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const open = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError: (e) => toast(errText(t, e), 'error') }));
return (
<button type="button" className="bo-btn bo-btn--accent" onClick={() => open.mutate({ clientId })} disabled={open.isPending}>
<I name="chats" size={16} />
<button type="button" className={`bo-btn bo-btn--accent${small ? ' bo-btn--sm' : ''}`} onClick={() => open.mutate({ clientId })} disabled={open.isPending}>
<I name="chats" size={small ? 14 : 16} />
{t('chat.write')}
</button>
);
+4 -4
View File
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@barev/web';
import type { Me } from '../App.tsx';
import { useT, type BoKey } from '../i18n.tsx';
import { I, OLD_ADMIN_URL, PlanBadge, Skeleton, sourceKeyLabel } from '../components/kit.tsx';
import { I, PlanBadge, Skeleton, sourceKeyLabel } from '../components/kit.tsx';
type Pair = { orders: number; subs: number };
const total = (p: Pair) => p.orders + p.subs;
@@ -57,9 +57,9 @@ type Att = { unassigned: number; unreadChats: number; webRequests: number; leads
const ATT: { key: keyof Att; href: string; urgent?: boolean; fill: string; ink: string }[] = [
{ 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: 'webRequests', href: '#/requests', fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
{ key: 'leads', href: '#/moves', fill: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' },
{ key: 'appRequests', href: '#/requests', fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
{ 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)' },
];
+662
View File
@@ -0,0 +1,662 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatAmd, type Locale } from '@barev/i18n';
import { navigate, paramsSummary, useLocation, useToast, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { I, Skeleton } from '../components/kit.tsx';
import { WriteButton } from './ClientCard.tsx';
import { NewOrderDialog } from './NewOrder.tsx';
/* ───────────── вкладки раздела «Заказы» ───────────── */
/** Заказы · Переезды · Заявки — один раздел; поддержка видит всё, консьерж — переезды и заявки */
export function OrdersTabs({ me, current }: { me: Me; current: 'orders' | 'moves' | 'requests' }) {
const { t } = useT();
const trpc = useTRPC();
const canOrders = me.roles.includes('SUPERADMIN') || me.roles.includes('SUPPORT');
const moves = useQuery({ ...trpc.bo.moves.counts.queryOptions(), refetchInterval: 30_000 });
const reqs = useQuery({ ...trpc.bo.requests.counts.queryOptions(), refetchInterval: 30_000 });
const tabs = [
...(canOrders ? [{ id: 'orders' as const, href: '#/orders', n: 0 }] : []),
{ id: 'moves' as const, href: '#/moves', n: moves.data?.NEW ?? 0 },
{ id: 'requests' as const, href: '#/requests', n: (reqs.data?.site ?? 0) + (reqs.data?.app ?? 0) },
];
return (
<nav className="bo-subtabs" aria-label={t('nav.orders')}>
{tabs.map((x) => (
<a key={x.id} href={x.href} aria-current={current === x.id ? 'page' : undefined}>
{t(`ord.tab.${x.id}`)}
{x.n > 0 && <span className="bo-subtabs__n">{x.n}</span>}
</a>
))}
</nav>
);
}
/* ───────────── список переездов ───────────── */
const STATUSES = ['NEW', 'IN_WORK', 'DONE', 'CANCELLED'] as const;
type Status = (typeof STATUSES)[number];
export const MOVE_DOT: Record<Status, string> = { NEW: 'var(--bc-accent)', IN_WORK: 'var(--bc-blue-ink)', DONE: 'var(--bc-ok)', CANCELLED: 'var(--bc-ink-3)' };
function whenText(shared: (k: string) => string, date: (d: Date) => string, m: { moveWhen: string | null; moveDate: Date | null }) {
if (m.moveWhen === 'date' && m.moveDate) return date(m.moveDate);
return m.moveWhen ? shared(`landing.quiz.when_${m.moveWhen}`) : '—';
}
export function Moves({ me }: { me: Me }) {
const { t, shared, date, locale } = useT();
const trpc = useTRPC();
const { query } = useLocation();
const status = (query.get('status') as Status | 'all' | null) ?? 'NEW';
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
useEffect(() => {
const id = setTimeout(() => setQ(search.trim()), 300);
return () => clearTimeout(id);
}, [search]);
const counts = useQuery({ ...trpc.bo.moves.counts.queryOptions(), refetchInterval: 30_000 });
const list = useQuery({ ...trpc.bo.moves.list.queryOptions({ status: status === 'all' ? undefined : status, search: q || undefined }), refetchInterval: 30_000, placeholderData: keepPreviousData });
const names = useQuery(trpc.bo.moves.names.queryOptions());
const stepName = (s: { code: string; title: string | null }) => s.title || names.data?.[`STEP:${s.code}`]?.name[locale] || names.data?.[`STEP:${s.code}`]?.name.ru || s.code;
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('ord.tab.moves')}</h1>
</div>
<OrdersTabs me={me} current="moves" />
<div className="bo-toolbar">
<label className="bo-search">
<I name="search" size={18} />
<input className="bo-input" type="search" value={search} placeholder={t('mv.search')} aria-label={t('mv.search')} onChange={(e) => setSearch(e.target.value)} />
</label>
</div>
<div className="bo-ogroups" role="tablist">
{[...STATUSES, 'all' as const].map((s) => (
<button key={s} type="button" role="tab" aria-selected={status === s} className="bo-ogroup" onClick={() => navigate(s === 'NEW' ? '/moves' : `/moves?status=${s}`, { replace: true })}>
{s !== 'all' && <span className="bo-ogroup__dot" style={{ background: MOVE_DOT[s] }} />}
{s === 'all' ? t('mv.all') : shared(`admin.leads.status_${s}`)}
{s !== 'all' && counts.data && <span className="bo-ogroup__n">{counts.data[s]}</span>}
</button>
))}
</div>
{list.isPending ? (
<div className="bo-col">
<Skeleton h={72} />
<Skeleton h={72} />
</div>
) : !list.data?.length ? (
<div className="bo-panel bo-empty">{t('mv.empty')}</div>
) : (
<ul className="bo-mvrows">
{list.data.map((m) => (
<li key={m.id}>
<a href={`#/moves/${m.id}`} className="bo-mvrow">
<span className="bo-mvrow__main">
<span className="bo-mvrow__title">
<span className="bo-num bo-faint">№{m.number}</span> {m.name}
{m.inApp && <span className="bo-chip bo-chip--ok">{t('mv.inApp')}</span>}
</span>
<span className="bo-mvrow__sub">
{[m.contact, m.agency, m.locale.toUpperCase()].filter(Boolean).join(' · ')}
</span>
</span>
<span className="bo-mvrow__col">
<span className="bo-faint bo-small">{t('mv.when')}</span>
<span>{whenText(shared, date, m)}</span>
</span>
<span className="bo-mvrow__col">
<span className="bo-faint bo-small">{m.package ? shared(`landing.quiz.package_${m.package}`) : t('mv.unfinished')}</span>
{m.stepsTotal > 0 ? (
<>
<span className="bo-mvbar" aria-hidden="true">
<span style={{ width: `${(m.stepsDone / m.stepsTotal) * 100}%` }} />
</span>
<span className="bo-small">
{t('mv.steps', { done: m.stepsDone, total: m.stepsTotal })}
{m.current ? ` · ${t('mv.now', { step: stepName(m.current) })}` : ''}
</span>
</>
) : (
<span className="bo-faint">{t('mv.noPlan')}</span>
)}
</span>
<span className="bo-mvrow__status">
<span className="bo-status" style={{ ['--dot' as string]: MOVE_DOT[m.status] }}>
{shared(`admin.leads.status_${m.status}`)}
</span>
{m.docsTotal > 0 && <span className="bo-small bo-faint">{t('mv.docs', { done: m.docsDone, total: m.docsTotal })}</span>}
<span className="bo-small bo-faint">{date(m.createdAt)}</span>
</span>
</a>
</li>
))}
</ul>
)}
</>
);
}
/* ───────────── карточка переезда ───────────── */
const STEP_STATUSES = ['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED'] as const;
type StepStatus = (typeof STEP_STATUSES)[number];
const YES_NO = ['flat', 'socialCard', 'bankCard', 'registration', 'residence'] as const;
interface Options {
airport?: 'none' | 'standard' | 'premium';
stay?: 'none' | 'flat' | 'hotel';
stayBudgetFrom?: number;
stayBudgetTo?: number;
flat?: boolean;
socialCard?: boolean;
bankCard?: boolean;
registration?: boolean;
residence?: boolean;
}
export function MoveCard({ id, me }: { id: string; me: Me }) {
const { t, shared, date, dateTime, locale } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const lead = useQuery(trpc.bo.moves.get.queryOptions({ id }));
const names = useQuery(trpc.bo.moves.names.queryOptions());
const templates = useQuery(trpc.bo.moves.templates.queryOptions());
const refresh = () => {
void qc.invalidateQueries({ queryKey: trpc.bo.moves.pathKey() });
};
const opts = { onSuccess: () => (refresh(), toast(t('prv.saved'))), onError: (e: unknown) => toast(errText(t, e), 'error') };
const update = useMutation(trpc.bo.moves.update.mutationOptions(opts));
const addStep = useMutation(trpc.bo.moves.addStep.mutationOptions(opts));
const addDoc = useMutation(trpc.bo.moves.addDoc.mutationOptions(opts));
const updateDoc = useMutation(trpc.bo.moves.updateDoc.mutationOptions(opts));
const removeDoc = useMutation(trpc.bo.moves.removeDoc.mutationOptions(opts));
const [note, setNote] = useState<string | null>(null);
const canChat = me.roles.includes('SUPERADMIN') || me.roles.includes('SUPPORT') || me.roles.includes('CONCIERGE');
const back = (
<a className="bo-back" href="#/moves">
<I name="back" size={16} />
{t('mv.back')}
</a>
);
if (lead.isError)
return (
<>
{back}
<div className="bo-panel bo-empty">{errText(t, lead.error)}</div>
</>
);
if (!lead.data)
return (
<div className="bo-col">
<Skeleton h={90} />
<Skeleton h={300} />
</div>
);
const d = lead.data;
const nameOf = (kind: 'STEP' | 'DOC', it: { code: string; title: string | null }) => {
const n = names.data?.[`${kind}:${it.code}`]?.name;
return it.title || n?.[locale] || n?.ru || it.code;
};
const free = (kind: 'STEP' | 'DOC', taken: { code: string }[]) =>
(templates.data ?? [])
.filter((x) => x.kind === kind && x.active && !taken.some((y) => y.code === x.code))
.map((x) => ({ code: x.code, name: (x.name as Record<string, string>)[locale] || (x.name as Record<string, string>).ru || x.code }));
const o = (d.options ?? {}) as Options;
const wants: string[] = [];
if (d.package === 'CUSTOM') {
if (o.airport && o.airport !== 'none') wants.push(`${shared('landing.quiz.airport')}: ${shared(`landing.quiz.airport_${o.airport}`)}`);
if (o.stay && o.stay !== 'none') {
const budget = o.stayBudgetFrom || o.stayBudgetTo ? ` (${shared('admin.leads.budget', { from: o.stayBudgetFrom ? formatAmd(locale as Locale, o.stayBudgetFrom) : '…', to: o.stayBudgetTo ? formatAmd(locale as Locale, o.stayBudgetTo) : '…' })})` : '';
wants.push(`${shared('landing.quiz.stay')}: ${shared(`landing.quiz.stay_${o.stay}`)}${budget}`);
}
for (const k of YES_NO) if (o[k]) wants.push(shared(`landing.quiz.${k}`));
}
const tel = d.contactType === 'PHONE' ? d.contact.replace(/[^\d+]/g, '') : null;
const tg = d.contactType === 'TELEGRAM' ? d.contact.replace(/^@/, '') : null;
const steps = d.steps.filter((s) => s.status !== 'SKIPPED');
const done = steps.filter((s) => s.status === 'DONE').length;
const noteValue = note ?? d.adminNote ?? '';
return (
<>
{back}
<header className="bo-ohead">
<div>
<div className="bo-ohead__kicker">
№{d.number} · {t('mv.created')} {dateTime(d.createdAt)}
</div>
<h1 className="bo-title">{d.name}</h1>
</div>
<div className="bo-pills" role="group" aria-label={t('mv.status')}>
{STATUSES.map((s) => (
<button key={s} type="button" className="bo-pill" aria-pressed={d.status === s} onClick={() => d.status !== s && update.mutate({ id, status: s })}>
{shared(`admin.leads.status_${s}`)}
</button>
))}
</div>
</header>
<div className="bo-client-grid">
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h">{t('mv.plan')}</h2>
{steps.length > 0 && <span className="bo-faint">{t('mv.steps', { done, total: steps.length })}</span>}
</div>
<p className="bo-hint">{d.client ? t('mv.planHintApp') : t('mv.planHintNoApp')}</p>
{steps.length > 0 && (
<div className="bo-mvbar bo-mvbar--big" aria-hidden="true">
<span style={{ width: `${(done / steps.length) * 100}%` }} />
</div>
)}
<ol className="bo-mvsteps">
{d.steps.map((s) => (
<StepRow key={s.id} step={s} name={nameOf('STEP', s)} onSaved={refresh} />
))}
</ol>
<AddItem placeholder={t('mv.addStep')} options={free('STEP', d.steps)} onAdd={(v) => addStep.mutate({ id, ...v })} />
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h">{t('mv.docsTitle')}</h2>
<span className="bo-faint">
{d.docs.filter((x) => x.checked).length}/{d.docs.length}
</span>
</div>
<p className="bo-hint">{t('mv.docsHint')}</p>
<ul className="bo-mvdocs">
{d.docs.map((x) => (
<li key={x.id} data-checked={x.checked || undefined}>
<label>
<input type="checkbox" checked={x.checked} onChange={(e) => updateDoc.mutate({ id: x.id, checked: e.target.checked })} />
<span>{nameOf('DOC', x)}</span>
</label>
<button type="button" className="bo-iconbtn" title={t('mv.remove')} aria-label={t('mv.remove')} onClick={() => confirm(t('mv.removeConfirm', { name: nameOf('DOC', x) })) && removeDoc.mutate({ id: x.id })}>
<I name="close" size={14} />
</button>
</li>
))}
</ul>
<AddItem placeholder={t('mv.addDoc')} options={free('DOC', d.docs)} onAdd={(v) => addDoc.mutate({ id, ...v })} />
<p className="bo-hint" style={{ marginTop: 10 }}>
{t('mv.listHint')}
</p>
</section>
</div>
<div className="bo-col">
<section className="bo-panel">
<h2 className="bo-h">{t('mv.contact')}</h2>
<div className="bo-contact" style={{ marginTop: 8 }}>
<b>{d.contact}</b>
<div className="bo-faint bo-small">
{t('mv.lang')}: {d.locale.toUpperCase()}
{d.agency ? ` · ${t('mv.agency')}: ${d.agency}` : ''}
</div>
</div>
<div className="bo-row" style={{ flexWrap: 'wrap', gap: 8, marginTop: 12 }}>
{d.client && canChat && <WriteButton clientId={d.client.id} />}
{tg && (
<a className="bo-btn bo-btn--soft" href={`https://t.me/${tg}`} target="_blank" rel="noopener">
<I name="telegram" size={16} />
Telegram
</a>
)}
{tel && (
<a className="bo-btn bo-btn--soft" href={`tel:${tel}`}>
<I name="phone" size={16} />
{t('mv.call')}
</a>
)}
{d.client && (
<a className="bo-btn bo-btn--ghost" href={`#/clients/${d.client.id}`}>
{t('mv.openClient')}
</a>
)}
</div>
</section>
<section className="bo-panel">
<h2 className="bo-h">{t('mv.answers')}</h2>
<dl className="bo-kv" style={{ marginTop: 8 }}>
<dt>{t('mv.when')}</dt>
<dd>{whenText(shared, date, d)}</dd>
<dt>{t('mv.package')}</dt>
<dd>{d.package ? shared(`landing.quiz.package_${d.package}`) : <span className="bo-faint">{t('mv.unfinished')}</span>}</dd>
{wants.length > 0 && (
<>
<dt>{t('mv.wants')}</dt>
<dd>
<ul className="bo-mvwants">
{wants.map((w) => (
<li key={w}>{w}</li>
))}
</ul>
</dd>
</>
)}
</dl>
</section>
<section className="bo-panel">
<h2 className="bo-h">{t('mv.note')}</h2>
<textarea className="bo-textarea" style={{ marginTop: 8 }} rows={4} maxLength={1000} placeholder={t('mv.notePh')} value={noteValue} onChange={(e) => setNote(e.target.value)} />
{note !== null && note !== (d.adminNote ?? '') && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--accent" style={{ marginTop: 8 }} onClick={() => update.mutate({ id, adminNote: note }, { onSuccess: () => setNote(null) })}>
{t('mv.save')}
</button>
)}
</section>
</div>
</div>
</>
);
}
/** Шаг плана: статус, название, комментарий для клиента */
function StepRow({ step, name, onSaved }: { step: { id: string; status: StepStatus; note: string | null }; name: string; onSaved: () => void }) {
const { t, shared } = useT();
const trpc = useTRPC();
const toast = useToast();
const [note, setNote] = useState(step.note ?? '');
const [title, setTitle] = useState<string | null>(null);
useEffect(() => setNote(step.note ?? ''), [step.note]);
const opts = { onSuccess: () => (onSaved(), toast(t('prv.saved'))), onError: (e: unknown) => toast(errText(t, e), 'error') };
const save = useMutation(trpc.bo.moves.updateStep.mutationOptions(opts));
const remove = useMutation(trpc.bo.moves.removeStep.mutationOptions(opts));
return (
<li className="bo-mvstep" data-status={step.status}>
<span className="bo-mvstep__dot" aria-hidden="true">
{step.status === 'DONE' && <I name="check" size={12} stroke={3} />}
</span>
<div className="bo-mvstep__body">
<div className="bo-mvstep__top">
{title === null ? (
<button type="button" className="bo-mvstep__name" title={t('mv.rename')} onClick={() => setTitle(name)}>
{name}
</button>
) : (
<input
className="bo-input"
autoFocus
value={title}
maxLength={120}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => {
if (title.trim() && title.trim() !== name) save.mutate({ id: step.id, title: title.trim() });
setTitle(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') setTitle(null);
}}
/>
)}
<select className="bo-select" value={step.status} aria-label={name} onChange={(e) => save.mutate({ id: step.id, status: e.target.value as StepStatus })}>
{STEP_STATUSES.map((s) => (
<option key={s} value={s}>
{shared(`client.move.status.${s}`)}
</option>
))}
</select>
<button type="button" className="bo-iconbtn" title={t('mv.remove')} aria-label={t('mv.remove')} onClick={() => confirm(t('mv.removeConfirm', { name })) && remove.mutate({ id: step.id })}>
<I name="close" size={14} />
</button>
</div>
<div className="bo-mvstep__note">
<input className="bo-input" value={note} maxLength={500} placeholder={t('mv.stepNote')} onChange={(e) => setNote(e.target.value)} />
{note !== (step.note ?? '') && (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => save.mutate({ id: step.id, note })}>
{t('mv.save')}
</button>
)}
</div>
</div>
</li>
);
}
/** Добавить пункт: из общего списка или свой */
function AddItem({ placeholder, options, onAdd }: { placeholder: string; options: { code: string; name: string }[]; onAdd: (v: { code?: string; title?: string }) => void }) {
const { t } = useT();
const [value, setValue] = useState('');
const [title, setTitle] = useState('');
return (
<div className="bo-mvadd">
<select className="bo-select" value={value} onChange={(e) => setValue(e.target.value)} aria-label={placeholder}>
<option value="">{placeholder}</option>
{options.map((o) => (
<option key={o.code} value={o.code}>
{o.name}
</option>
))}
<option value="__custom">{t('mv.custom')}</option>
</select>
{value === '__custom' && <input className="bo-input" value={title} maxLength={120} placeholder={t('mv.customPh')} onChange={(e) => setTitle(e.target.value)} />}
{value && (
<button
type="button"
className="bo-btn bo-btn--sm bo-btn--accent"
disabled={value === '__custom' && !title.trim()}
onClick={() => {
onAdd(value === '__custom' ? { title: title.trim() } : { code: value });
setValue('');
setTitle('');
}}
>
<I name="plus" size={14} />
{t('mv.add')}
</button>
)}
</div>
);
}
/* ───────────── заявки ───────────── */
export function Requests({ me }: { me: Me }) {
const { t, shared, sharedT, dateTime, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const { query } = useLocation();
const open = query.get('closed') !== '1';
const site = useQuery({ ...trpc.bo.requests.site.queryOptions({ open }), refetchInterval: 30_000 });
const app = useQuery({ ...trpc.bo.requests.app.queryOptions({ open }), refetchInterval: 30_000 });
const [orderFor, setOrderFor] = useState<string | null>(null);
const opts = (msg = t('prv.saved')) => ({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.bo.requests.pathKey() });
toast(msg);
},
onError: (e: unknown) => toast(errText(t, e), 'error'),
});
const updateSite = useMutation(trpc.bo.requests.updateSite.mutationOptions(opts()));
const updateApp = useMutation(trpc.bo.requests.updateApp.mutationOptions(opts()));
const activate = useMutation(trpc.bo.requests.activate.mutationOptions(opts(t('rq.activated'))));
const planName = (p: string | null) => (p === 'ELITE' ? 'Barev Elite' : 'Barev+');
const person = (u: { firstName: string | null; lastName: string | null; username: string | null } | undefined) => [u?.firstName, u?.lastName].filter(Boolean).join(' ') || (u?.username ? `@${u.username}` : '—');
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('ord.tab.requests')}</h1>
<div className="bo-pills" role="group">
<button type="button" className="bo-pill" aria-pressed={open} onClick={() => navigate('/requests', { replace: true })}>
{t('rq.open')}
</button>
<button type="button" className="bo-pill" aria-pressed={!open} onClick={() => navigate('/requests?closed=1', { replace: true })}>
{t('rq.closed')}
</button>
</div>
</div>
<OrdersTabs me={me} current="requests" />
<div className="bo-client-grid bo-rqgrid">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h">{t('rq.site')}</h2>
<span className="bo-faint">{site.data?.length ?? ''}</span>
</div>
<p className="bo-hint">{t('rq.siteHint')}</p>
{site.isPending ? (
<Skeleton h={80} />
) : !site.data?.length ? (
<div className="bo-empty">{open ? t('rq.empty') : t('rq.emptyClosed')}</div>
) : (
<ul className="bo-rqlist">
{site.data.map((r) => (
<li key={r.id} className="bo-rq">
<div className="bo-rq__top">
<b>
<span className="bo-num bo-faint">№{r.number}</span> {shared(`services.${r.service.code}.name`)}
</b>
<span className="bo-chip">{shared(`admin.webRequests.status_${r.status}`)}</span>
</div>
<div className="bo-small">{paramsSummary(sharedT, r.service.code, r.params as Record<string, unknown>)}</div>
<div className="bo-small">
{t('rq.wish')}: <b>{dateTime(r.preferredAt)}</b> · {t('rq.price')}: <b className="bo-num">{amd(r.totalPrice)}</b>
</div>
<div className="bo-small bo-faint">
{shared(`districts.${r.district.code}`)}, {r.addressText}
</div>
<div className="bo-rq__who">
<span>
{r.name || person(r.client?.user)} · <a href={`tel:${r.phone.replace(/[^\d+]/g, '')}`}>{r.phone}</a>
{!r.client && <span className="bo-faint"> · {t('rq.noApp')}</span>}
</span>
</div>
{r.note && <div className="bo-small bo-rq__note">{r.note}</div>}
<NoteLine value={r.adminNote} onSave={(adminNote) => updateSite.mutate({ id: r.id, adminNote })} />
<div className="bo-rq__actions">
{r.order ? (
<a className="bo-btn bo-btn--sm bo-btn--soft" href={`#/orders/${r.order.id}`}>
{t('rq.order', { n: r.order.number })}
</a>
) : (
r.status !== 'CANCELLED' &&
r.client && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--accent" onClick={() => setOrderFor(r.client!.id)}>
<I name="plus" size={14} />
{t('rq.newOrder')}
</button>
)
)}
{r.client && (
<a className="bo-btn bo-btn--sm bo-btn--ghost" href={`#/clients/${r.client.id}`}>
{t('mv.openClient')}
</a>
)}
{r.status === 'CANCELLED' ? (
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => updateSite.mutate({ id: r.id, status: 'REOPEN' })}>
{t('rq.reopen')}
</button>
) : (
r.status !== 'ORDERED' && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => confirm(shared('admin.webRequests.cancelConfirm', { number: r.number })) && updateSite.mutate({ id: r.id, status: 'CANCELLED' })}>
{t('rq.cancel')}
</button>
)
)}
</div>
<div className="bo-faint bo-small">{dateTime(r.createdAt)}</div>
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h">{t('rq.app')}</h2>
<span className="bo-faint">{app.data?.length ?? ''}</span>
</div>
<p className="bo-hint">{t('rq.appHint')}</p>
{app.isPending ? (
<Skeleton h={80} />
) : !app.data?.length ? (
<div className="bo-empty">{open ? t('rq.empty') : t('rq.emptyClosed')}</div>
) : (
<ul className="bo-rqlist">
{app.data.map((r) => (
<li key={r.id} className="bo-rq">
<div className="bo-rq__top">
<b>
<span className="bo-num bo-faint">№{r.number}</span>{' '}
{r.kind === 'SUBSCRIPTION' ? shared('admin.requests.subscription', { plan: planName(r.plan) }) : t('rq.callback', { service: r.serviceCode ? shared(`services.${r.serviceCode}.name`) : '—' })}
</b>
<span className="bo-chip">{shared(`admin.requests.status_${r.status}`)}</span>
</div>
<div className="bo-rq__who">
<a href={`#/clients/${r.clientId}`}>{person(r.client.user)}</a>
{r.client.user.phone && (
<>
{' · '}
<a href={`tel:${r.client.user.phone}`}>{r.client.user.phone}</a>
</>
)}
<span className="bo-faint"> · {r.client.user.locale.toUpperCase()}</span>
</div>
{r.comment && <div className="bo-small bo-rq__note">{r.comment}</div>}
<NoteLine value={r.adminNote} onSave={(adminNote) => updateApp.mutate({ id: r.id, adminNote })} />
<div className="bo-rq__actions">
{r.status === 'NEW' && r.kind === 'SUBSCRIPTION' && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--accent" onClick={() => confirm(shared('admin.requests.activateConfirm', { plan: planName(r.plan) })) && activate.mutate({ id: r.id, days: 30 })}>
{t('rq.activate')}
</button>
)}
{r.status === 'NEW' && (
<WriteButton clientId={r.clientId} small />
)}
{r.status === 'NEW' ? (
<>
<button type="button" className="bo-btn bo-btn--sm bo-btn--soft" onClick={() => updateApp.mutate({ id: r.id, status: 'DONE' })}>
<I name="check" size={14} />
{t('rq.done')}
</button>
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => updateApp.mutate({ id: r.id, status: 'CANCELLED' })}>
{t('rq.cancel')}
</button>
</>
) : (
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => updateApp.mutate({ id: r.id, status: 'NEW' })}>
{t('rq.reopen')}
</button>
)}
</div>
<div className="bo-faint bo-small">{dateTime(r.createdAt)}</div>
</li>
))}
</ul>
)}
</section>
</div>
{orderFor && <NewOrderDialog clientId={orderFor} onClose={() => setOrderFor(null)} />}
</>
);
}
/** Заметка команды одной строкой: сохраняется по кнопке */
function NoteLine({ value, onSave }: { value: string | null; onSave: (v: string) => void }) {
const { t } = useT();
const [v, setV] = useState(value ?? '');
useEffect(() => setV(value ?? ''), [value]);
return (
<div className="bo-rq__notefield">
<input className="bo-input" value={v} maxLength={1000} placeholder={t('mv.notePh')} aria-label={t('mv.note')} onChange={(e) => setV(e.target.value)} />
{v !== (value ?? '') && (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => onSave(v)}>
{t('mv.save')}
</button>
)}
</div>
);
}
+3
View File
@@ -6,6 +6,7 @@ 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';
@@ -85,6 +86,8 @@ export function Orders({ me }: { me: Me }) {
</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} />