Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { Button, Center, Icon, Sheet, Spinner } from '@barev/ui';
|
||
import { confirmDialog, dateKeyToDate, errorText, haptic, navigate, nextDays, paramsExtras, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Stars, Timeline, toneClass, useOrderTimeline, 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 timeline = useOrderTimeline();
|
||
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 arrived = o.history.some((h) => h.event === 'arrived');
|
||
const extras = paramsExtras(t, o.params);
|
||
|
||
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">
|
||
<section className={`od-hero ${toneClass(o.category)}`}>
|
||
<div className="od-hero__bar">
|
||
<button className="back" onClick={() => window.history.back()} aria-label={t('common.back')}>
|
||
<Icon name="back" />
|
||
</button>
|
||
<span className="od-hero__num">#{o.number}</span>
|
||
</div>
|
||
<span className="od-hero__status" data-status={o.status === 'CANCELLED' ? 'cancelled' : o.active ? 'active' : 'done'}>
|
||
{t(`status.${o.flow}.${o.status}`)}
|
||
{arrived && o.status === 'EN_ROUTE' ? ` · ${t('client.detail.arrived')}` : ''}
|
||
</span>
|
||
<h1 className="od-hero__title">{t(`services.${o.serviceCode}.name`)}</h1>
|
||
<div className="od-hero__sub">
|
||
{summary(o.serviceCode, o.params)}
|
||
{extras.length > 0 && ` + ${extras.map((e) => e.label).join(', ')}`}
|
||
</div>
|
||
</section>
|
||
|
||
<dl className="facts">
|
||
<div>
|
||
<dt>
|
||
<Icon name="clock" size={18} />
|
||
{t('client.detail.when')}
|
||
</dt>
|
||
<dd>{dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}</dd>
|
||
</div>
|
||
{o.addressText && (
|
||
<div>
|
||
<dt>
|
||
<Icon name="pin" size={18} />
|
||
{t('client.detail.where')}
|
||
</dt>
|
||
<dd>
|
||
{o.districtCode ? `${t(`districts.${o.districtCode}`)}, ` : ''}
|
||
{o.addressText}
|
||
</dd>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<dt>
|
||
<Icon name="user" size={18} />
|
||
{t(`client.detail.providerLabel.${o.flow}`)}
|
||
</dt>
|
||
<dd>{o.providerName ?? <span className="bc-muted">{t('client.detail.whoPending')}</span>}</dd>
|
||
</div>
|
||
</dl>
|
||
|
||
{o.done && <RateBlock id={o.id} rating={o.rating} />}
|
||
|
||
<section className="panel">
|
||
<h2 className="panel__title">{t('client.detail.progress')}</h2>
|
||
<Timeline items={timeline(o)} />
|
||
</section>
|
||
|
||
{o.photos.length > 0 && <PhotoReport photos={o.photos} />}
|
||
|
||
<section className="panel 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>
|
||
)}
|
||
{o.pointsUsed > 0 && (
|
||
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
|
||
<span>{t('client.points.paidLine')}</span>
|
||
<span>−{amd(o.pointsUsed)}</span>
|
||
</div>
|
||
)}
|
||
<div className="bc-between total">
|
||
<span>{t('client.order.total')}</span>
|
||
<span>{amd(o.totalPrice - o.pointsUsed)}</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>
|
||
</section>
|
||
|
||
{o.note && (
|
||
<section className="panel">
|
||
<div className="bc-small bc-muted">{t('client.order.note')}</div>
|
||
<div>{o.note}</div>
|
||
</section>
|
||
)}
|
||
|
||
<div className="bc-stack" style={{ gap: 8 }}>
|
||
{!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="dark" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
|
||
{t('client.detail.orderAgain')}
|
||
</Button>
|
||
)}
|
||
<Button variant="light" block onClick={() => navigate('/chat')}>
|
||
<Icon name="chat" size={18} />
|
||
{t('client.detail.ask')}
|
||
</Button>
|
||
{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>
|
||
)}
|
||
</div>
|
||
|
||
<RescheduleSheet open={reschedule} onClose={() => setReschedule(false)} order={o} />
|
||
</main>
|
||
);
|
||
}
|
||
|
||
/** Оценка после выполнения: звёзды и комментарий; уже оценённый заказ показывает оценку с кнопкой «Изменить» */
|
||
function RateBlock({ id, rating }: { id: string; rating: { score: number; comment: string | null } | null }) {
|
||
const { t } = useI18n();
|
||
const trpc = useTRPC();
|
||
const qc = useQueryClient();
|
||
const toast = useToast();
|
||
const { query } = useLocation();
|
||
const ref = useRef<HTMLElement>(null);
|
||
const [editing, setEditing] = useState(!rating);
|
||
const [score, setScore] = useState(rating?.score ?? 0);
|
||
const [comment, setComment] = useState(rating?.comment ?? '');
|
||
useEffect(() => {
|
||
if (query.get('rate') && ref.current) ref.current.scrollIntoView({ block: 'center' });
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
const rate = useMutation(
|
||
trpc.client.orders.rate.mutationOptions({
|
||
onSuccess: () => {
|
||
haptic.ok();
|
||
toast(t('client.rate.thanks'));
|
||
setEditing(false);
|
||
void qc.invalidateQueries({ queryKey: trpc.client.orders.pathKey() });
|
||
},
|
||
onError: (e) => toast(errorText(t, e), 'error'),
|
||
}),
|
||
);
|
||
|
||
if (!editing && rating)
|
||
return (
|
||
<section className="rate rate--done" ref={ref}>
|
||
<div className="bc-between">
|
||
<span className="rate__label">{t('client.rate.yours')}</span>
|
||
<button type="button" className="rate__change" onClick={() => setEditing(true)}>
|
||
{t('client.rate.change')}
|
||
</button>
|
||
</div>
|
||
<Stars value={rating.score} size={26} />
|
||
{rating.comment && <p className="rate__comment">{rating.comment}</p>}
|
||
</section>
|
||
);
|
||
|
||
return (
|
||
<section className="rate" ref={ref}>
|
||
<h2 className="rate__title">{t('client.rate.title')}</h2>
|
||
<p className="rate__text">{t('client.rate.text')}</p>
|
||
<Stars value={score} onChange={setScore} size={40} />
|
||
{score > 0 && (
|
||
<>
|
||
<textarea className="rate__input" rows={2} maxLength={1000} placeholder={t('client.rate.comment')} value={comment} onChange={(e) => setComment(e.target.value)} />
|
||
<button type="button" className="pill pill--pink pill--block" disabled={rate.isPending} onClick={() => rate.mutate({ id, score, comment: comment.trim() || undefined })}>
|
||
{t('client.rate.send')}
|
||
</button>
|
||
</>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function PhotoReport({ photos }: { photos: { code: string; fileId: string }[] }) {
|
||
const { t } = useI18n();
|
||
const trpc = useTRPC();
|
||
return (
|
||
<section className="panel">
|
||
<h2 className="panel__title">{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>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|