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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:23:32 +04:00
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>
);
}