Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -0,0 +1,265 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Card, Center, Icon, Sheet, Spinner, Steps } from '@barev/ui';
|
||||
import { confirmDialog, dateKeyToDate, errorText, haptic, navigate, nextDays, paramsExtras, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { PageHead, toneClass, useParamsSummary } from '../lib.tsx';
|
||||
|
||||
export function OrderDetail({ id }: { id: string }) {
|
||||
const { t, amd, dt, locale } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const summary = useParamsSummary();
|
||||
const order = useQuery({ ...trpc.client.orders.get.queryOptions({ id }), refetchInterval: 15_000 });
|
||||
const preview = useQuery({ ...trpc.client.orders.cancelPreview.queryOptions({ id }), enabled: !!order.data?.active });
|
||||
const [reschedule, setReschedule] = useState(false);
|
||||
|
||||
const cancel = useMutation(
|
||||
trpc.client.orders.cancel.mutationOptions({
|
||||
onSuccess: (r) => {
|
||||
haptic.ok();
|
||||
toast(r.fee > 0 ? t('client.detail.cancelledWithFee', { fee: amd(r.fee) }) : t('client.detail.cancelled'));
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
|
||||
if (!order.data)
|
||||
return (
|
||||
<Center>
|
||||
<Spinner />
|
||||
</Center>
|
||||
);
|
||||
const o = order.data;
|
||||
|
||||
// шкала прогресса: пройденные статусы по истории, текущий, остальные
|
||||
const passed = new Set(o.history.map((h) => h.to));
|
||||
const currentIdx = o.steps.indexOf(o.status);
|
||||
const steps =
|
||||
o.status === 'CANCELLED'
|
||||
? [...o.steps.filter((s) => passed.has(s)).map((s) => ({ label: t(`status.${o.flow}.${s}`), state: 'done' as const })), { label: t(`status.${o.flow}.CANCELLED`), state: 'current' as const }]
|
||||
: o.steps.map((s, i) => {
|
||||
const at = o.history.find((h) => h.to === s && !h.event)?.at ?? o.history.find((h) => h.to === s)?.at;
|
||||
return {
|
||||
label: t(`status.${o.flow}.${s}`),
|
||||
state: i < currentIdx ? ('done' as const) : i === currentIdx ? ('current' as const) : ('todo' as const),
|
||||
meta: at && i <= currentIdx ? dt(at, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : undefined,
|
||||
};
|
||||
});
|
||||
const arrived = o.history.some((h) => h.event === 'arrived');
|
||||
|
||||
async function onCancel() {
|
||||
const p = preview.data?.policy;
|
||||
if (!p) return;
|
||||
const text = p.kind === 'free' ? t('client.detail.confirmCancelFree') : p.kind === 'late' ? t('client.detail.confirmCancelLate', { fee: amd(p.fee), pct: p.feePct }) : '';
|
||||
if (await confirmDialog(text)) cancel.mutate({ id });
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app app--plain">
|
||||
<PageHead title={`#${o.number}`} />
|
||||
|
||||
<Card className={toneClass(o.category)} style={{ padding: 20 }}>
|
||||
<div className="bc-stack" style={{ gap: 8 }}>
|
||||
<div className="bc-between" style={{ alignItems: 'flex-start' }}>
|
||||
<h2 className="bc-h2">{t(`services.${o.serviceCode}.name`)}</h2>
|
||||
<span className="bc-badge">{t(`status.${o.flow}.${o.status}`)}</span>
|
||||
</div>
|
||||
<div>{summary(o.serviceCode, o.params)}</div>
|
||||
{paramsExtras(t, o.params).length > 0 && <div className="bc-small">+ {paramsExtras(t, o.params).map((e) => e.label).join(', ')}</div>}
|
||||
<div className="bc-row" style={{ fontWeight: 600 }}>
|
||||
<Icon name="clock" size={18} />
|
||||
{dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
{o.addressText && (
|
||||
<div className="bc-row">
|
||||
<Icon name="pin" size={18} />
|
||||
{o.districtCode ? `${t(`districts.${o.districtCode}`)}, ` : ''}
|
||||
{o.addressText}
|
||||
</div>
|
||||
)}
|
||||
{o.providerName && (
|
||||
<div className="bc-row">
|
||||
<Icon name="user" size={18} />
|
||||
{t(`client.detail.providerLabel.${o.flow}`)}: {o.providerName}
|
||||
{arrived && o.status === 'EN_ROUTE' && <span className="bc-badge">{t('client.detail.arrived')}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="bc-h3" style={{ marginBottom: 12 }}>
|
||||
{t('client.detail.progress')}
|
||||
</h2>
|
||||
<Steps items={steps} />
|
||||
</Card>
|
||||
|
||||
{o.photos.length > 0 && <PhotoReport photos={o.photos} />}
|
||||
|
||||
<Card className="price-lines">
|
||||
<div className="bc-between">
|
||||
<span>{t('client.order.price')}</span>
|
||||
<span>{amd(o.basePrice)}</span>
|
||||
</div>
|
||||
{o.discountAmount > 0 && (
|
||||
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
|
||||
<span>{t('client.order.discount', { pct: o.discountPct })}</span>
|
||||
<span>−{amd(o.discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bc-between total">
|
||||
<span>{t('client.order.total')}</span>
|
||||
<span>{amd(o.totalPrice)}</span>
|
||||
</div>
|
||||
{o.cancelFee > 0 && (
|
||||
<div className="bc-between">
|
||||
<span>{t('client.detail.cancelFee')}</span>
|
||||
<span>{amd(o.cancelFee)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bc-small bc-muted">
|
||||
{t(`payment.${o.paymentStatus}`)}
|
||||
{o.paymentMethod ? ` · ${o.paymentMethod.name[locale] || o.paymentMethod.name.ru}` : ''}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{o.note && (
|
||||
<Card tight>
|
||||
<div className="bc-small bc-muted">{t('client.order.note')}</div>
|
||||
<div>{o.note}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="bc-stack" style={{ gap: 8 }}>
|
||||
{o.active && preview.data?.canReschedule && (
|
||||
<Button variant="light" block onClick={() => setReschedule(true)}>
|
||||
{t('client.detail.reschedule')}
|
||||
</Button>
|
||||
)}
|
||||
{o.active && preview.data?.cancellable && preview.data.policy.kind !== 'operator_only' && (
|
||||
<Button variant="danger" block onClick={onCancel} disabled={cancel.isPending}>
|
||||
{t('client.detail.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{o.active && preview.data && (!preview.data.cancellable || preview.data.policy.kind === 'operator_only' || !preview.data.canReschedule) && (
|
||||
<p className="bc-small bc-muted" style={{ margin: 0, textAlign: 'center' }}>
|
||||
{t('client.detail.viaOperator', { hours: preview.data.freeHours })}
|
||||
</p>
|
||||
)}
|
||||
{!o.active && o.status !== 'CANCELLED' && (
|
||||
<Button variant="dark" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
|
||||
<Icon name="repeat" size={18} />
|
||||
{t('client.detail.repeat')}
|
||||
</Button>
|
||||
)}
|
||||
{o.status === 'CANCELLED' && (
|
||||
<Button variant="light" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
|
||||
{t('client.detail.orderAgain')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RescheduleSheet open={reschedule} onClose={() => setReschedule(false)} order={o} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PhotoReport({ photos }: { photos: { code: string; fileId: string }[] }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="bc-h3" style={{ marginBottom: 10 }}>
|
||||
{t('client.detail.photoReport')}
|
||||
</h2>
|
||||
<div className="photos">
|
||||
{photos.map((p) => (
|
||||
<Photo key={p.fileId} fileId={p.fileId} alt={t(`checklist.${p.code}`)} trpc={trpc} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Photo({ fileId, alt, trpc }: { fileId: string; alt: string; trpc: ReturnType<typeof useTRPC> }) {
|
||||
const url = useQuery({ ...trpc.client.fileUrl.queryOptions({ fileId }), staleTime: 4 * 60_000 });
|
||||
return url.data ? (
|
||||
<a href={url.data.url} target="_blank" rel="noreferrer">
|
||||
<img src={url.data.url} alt={alt} loading="lazy" />
|
||||
</a>
|
||||
) : (
|
||||
<div style={{ aspectRatio: 1, borderRadius: 16, background: 'var(--bc-bg)' }} />
|
||||
);
|
||||
}
|
||||
|
||||
/** ORD-6: перенос — выбор нового слота */
|
||||
function RescheduleSheet({ open, onClose, order }: { open: boolean; onClose: () => void; order: { id: string; serviceCode: string; params: Record<string, unknown> } }) {
|
||||
const { t, dt } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const days = useMemo(() => nextDays(14), []);
|
||||
const [day, setDay] = useState(days[1] ?? days[0]!);
|
||||
const [slot, setSlot] = useState<Date | null>(null);
|
||||
const slots = useQuery({
|
||||
...trpc.client.slots.queryOptions({ serviceCode: order.serviceCode, params: order.params as Record<string, string | number | boolean>, dateKey: day, excludeOrderId: order.id }),
|
||||
enabled: open,
|
||||
});
|
||||
const mutate = useMutation(
|
||||
trpc.client.orders.reschedule.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('client.detail.rescheduled'));
|
||||
void qc.invalidateQueries();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} label={t('client.detail.reschedule')}>
|
||||
<div className="bc-stack">
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h2">{t('client.detail.reschedule')}</h2>
|
||||
<button className="back" onClick={onClose} aria-label={t('common.close')}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="days">
|
||||
{days.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className="day"
|
||||
aria-pressed={d === day}
|
||||
onClick={() => {
|
||||
setDay(d);
|
||||
setSlot(null);
|
||||
}}
|
||||
>
|
||||
<span className="bc-small">{dt(dateKeyToDate(d), { weekday: 'short' })}</span>
|
||||
<span className="day__num">{dt(dateKeyToDate(d), { day: 'numeric' })}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{slots.isPending ? (
|
||||
<Spinner />
|
||||
) : slots.data?.length ? (
|
||||
<div className="slots">
|
||||
{slots.data.map((s) => (
|
||||
<button key={s.start.toISOString()} type="button" className="slot" aria-pressed={slot?.getTime() === s.start.getTime()} onClick={() => setSlot(s.start)}>
|
||||
{dt(s.start, { hour: '2-digit', minute: '2-digit' })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="bc-muted">{t('client.order.noSlots')}</p>
|
||||
)}
|
||||
<Button block disabled={!slot || mutate.isPending} onClick={() => slot && mutate.mutate({ id: order.id, scheduledAt: slot })}>
|
||||
{t('client.detail.rescheduleConfirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user