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:
1 parent
bbb25d3d0d
commit
dcba7c200c
19 files changed
+1422
-22
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)' },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
Reference in new issue
Block a user