Files
BarevClub/apps/miniapp-client/src/screens/OrderDetail.tsx
T

266 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}