Files
daily_console_web/src/features/store-admin/OrderDetailDrawer.tsx
2026-08-24 20:35:18 +05:30

518 lines
18 KiB
TypeScript

import { useEffect } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Check, MapPin, Phone, X } from 'lucide-react';
import type { DeliveryRow, OrderRow } from '@/api/types';
import { branchLabel, moneyExact } from './format';
import { DELIVERY_STATUS, ORDER_STATUS, orderQuantity, orderValue, statusColor } from './orderStatus';
type Row = OrderRow | DeliveryRow;
export type RowKind = 'order' | 'delivery';
/**
* The five stages an order walks, taken from the old console verbatim.
*
* The middle one matters and is easy to drop: **Packed & Ready** sits between
* confirmation and pickup, and it is the stage where an order stalls when a
* shop is busy. A four-step timeline that jumps from Confirmed to Out for
* Delivery hides exactly the delay an operator is trying to find.
*/
const STEPS: { label: string; fields: string[] }[] = [
// A delivery job has no `orderdate` of its own — its clock starts at
// `deliverydate`. Falling back keeps the first step from reading as a missing
// stamp on every delivery ever opened.
{ label: 'Order placed', fields: ['orderdate', 'deliverydate'] },
{ label: 'Confirmed', fields: ['starttime', 'assigntime'] },
{ label: 'Packed & ready', fields: ['packtime', 'arrivaltime'] },
{ label: 'Out for delivery', fields: ['pickuptime'] },
{ label: 'Delivered', fields: ['deliverytime'] },
];
/**
* One order or delivery, in full.
*
* A right-hand sheet rather than a modal: the operator is comparing this row
* against the list behind it — "is this the one that stalled?" — and a modal
* that greys the list out removes the thing being compared against.
*/
export function OrderDetailDrawer({
row,
kind,
onClose,
}: {
row: Row;
kind: RowKind;
onClose: () => void;
}) {
const delivery = kind === 'delivery';
// Narrowed once, here. `kind` comes from the table the row was clicked in, so
// it is authoritative in a way that sniffing fields is not — a delivery row
// carries an `orderheaderid` too, which is what made the first attempt at
// this discriminator silently mis-classify every delivery.
const job = delivery ? (row as DeliveryRow) : undefined;
const order = delivery ? undefined : (row as OrderRow);
// Escape closes. A dialog that can only be dismissed by finding the scrim
// with a mouse is not dismissible for anyone on a keyboard, and this one sits
// over the list an operator is working through.
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose]);
const title = row.orderid || (job ? `DLV-${job.deliveryid}` : `#${order?.orderheaderid}`);
const status = row.orderstatus;
const colour = statusColor(delivery ? DELIVERY_STATUS : ORDER_STATUS, status);
const value = job ? (job.deliveryamt ?? 0) : orderValue(row);
const cancelled = (status ?? '').toLowerCase().includes('cancel');
return (
<div
role="dialog"
aria-modal="true"
aria-label={`Order ${title}`}
style={{ position: 'fixed', inset: 0, zIndex: 70, display: 'flex', justifyContent: 'flex-end' }}
>
<button
type="button"
aria-label="Close"
onClick={onClose}
style={{
position: 'absolute',
inset: 0,
border: 0,
background: 'rgb(15 23 42 / .32)',
cursor: 'pointer',
}}
/>
<div
className="dialog-panel"
style={{
position: 'relative',
width: 460,
maxWidth: '92vw',
height: '100%',
overflowY: 'auto',
background: 'var(--color-surface)',
borderLeft: '1px solid var(--color-line)',
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
}}
>
<HStack
justify="between"
align="start"
gap={1}
style={{
position: 'sticky',
top: 0,
zIndex: 1,
padding: 20,
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-line)',
}}
>
<VStack gap={0.5}>
<Text
type="label"
size="lg"
weight="semibold"
style={{ fontFamily: 'var(--font-mono)' }}
>
{title}
</Text>
<HStack gap={1} align="center">
<span
style={{
display: 'inline-flex',
borderRadius: 999,
padding: '2px 9px',
fontSize: 11.5,
fontWeight: 600,
textTransform: 'capitalize',
color: colour,
background: `color-mix(in oklab, ${colour} 12%, transparent)`,
}}
>
{status || '—'}
</span>
<Text type="body" size="xsm" color="secondary">
{branchLabel(row.locationname)}
</Text>
</HStack>
</VStack>
<button
type="button"
aria-label="Close"
onClick={onClose}
style={{
width: 30,
height: 30,
borderRadius: 10,
border: 0,
flex: 'none',
display: 'grid',
placeItems: 'center',
background: 'var(--color-surface-sunken)',
color: 'var(--color-ink-3)',
cursor: 'pointer',
}}
>
<X size={15} />
</button>
</HStack>
<VStack gap={3} padding={2}>
{/* ── Money ─────────────────────────────────────────────────── */}
<div className="form-grid">
<Figure label="Value" value={value > 0 ? moneyExact(value) : '—'} isStrong />
{job ? (
<Figure
label="Rider charge"
value={job.deliverycharges ? moneyExact(job.deliverycharges) : '—'}
/>
) : (
<Figure
label="Cash to collect"
value={order?.collectionamt ? moneyExact(order.collectionamt) : '—'}
/>
)}
<Figure
label="Items"
value={job ? String(job.itemcount ?? '—') : String(orderQuantity(row) || '—')}
/>
</div>
{/* ── Journey ───────────────────────────────────────────────── */}
<VStack gap={1}>
<Label>Journey</Label>
<Timeline row={row} isCancelled={cancelled} />
</VStack>
{/* ── Pickup and drop ───────────────────────────────────────── */}
<VStack gap={1}>
<Label>Route</Label>
<Party
icon={<MapPin size={13} />}
heading="Pickup"
name={row.pickupcustomer || branchLabel(row.locationname)}
detail={
job
? job.pickupsuburb || job.pickuplocation || job.Pickupaddress || job.pickupaddress
: order?.pickupaddress || order?.pickupsuburb
}
phone={row.pickupcontactno}
/>
<Party
icon={<MapPin size={13} />}
heading="Drop"
name={row.deliverycustomer}
detail={row.deliveryaddress || row.deliverysuburb}
phone={row.deliverycontactno}
/>
</VStack>
{/* ── Rider ─────────────────────────────────────────────────── */}
{job ? (
<VStack gap={1}>
<Label>Rider</Label>
{job.ridername ? (
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
<HStack gap={1} align="center">
<span
style={{
width: 30,
height: 30,
borderRadius: 999,
flex: 'none',
display: 'grid',
placeItems: 'center',
background: 'color-mix(in oklab, #8b5cf6 14%, transparent)',
color: '#8b5cf6',
}}
>
<Bike size={14} />
</span>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{job.ridername}
</Text>
{job.ridercontact ? (
<Text type="body" size="xsm" color="secondary">
{job.ridercontact}
</Text>
) : null}
</VStack>
</HStack>
{/* Planned against actual. The gap is the number worth
reading — a job quoted 3km and ridden 9 is either a bad
address or a rider taking a detour, and one figure hides
both. */}
<HStack gap={2}>
<Figure label="Planned" value={km(job.kms)} />
<Figure label="Actual" value={km(job.actualkms || job.riderkms)} />
{job.transitminutes ? (
<Figure label="Transit" value={`${job.transitminutes} min`} />
) : null}
</HStack>
</VStack>
</Card>
) : (
<Text type="body" size="sm" color="secondary">
No rider assigned yet.
</Text>
)}
</VStack>
) : order?.rider ? (
<VStack gap={1}>
<Label>Rider</Label>
<Text type="body" size="sm">
{order.rider}
{order.ridercontactno ? ` · ${order.ridercontactno}` : ''}
</Text>
</VStack>
) : null}
{/* ── Notes ─────────────────────────────────────────────────── */}
{row.ordernotes || job?.notes ? (
<VStack gap={1}>
<Label>Notes</Label>
<Text type="body" size="sm" color="secondary">
{row.ordernotes || job?.notes}
</Text>
</VStack>
) : null}
<Text type="body" size="xsm" color="secondary">
Line items are not in the list read — Fiesta returns them from the order-detail
endpoint, which this sheet does not call yet.
</Text>
</VStack>
</div>
</div>
);
}
/* ────────────────────────────────────────────────────────────────────────── */
/**
* The journey, as stamps rather than as a guess.
*
* A step is complete only when its timestamp is present. Nothing is inferred
* from the status: an order marked Delivered with no `pickuptime` really does
* have a missing stamp, and drawing that step as done would paper over the
* data-quality problem the operator opened this sheet to find.
*/
function Timeline({ row, isCancelled }: { row: Row; isCancelled: boolean }) {
const steps = STEPS.map((step) => ({
label: step.label,
// Read by name: the two row shapes do not carry the same stamps — a
// delivery has no `packtime`, an order has no `arrivaltime` — so a step
// whose field is absent simply reads undefined and renders as not-yet.
at: step.fields
.map((field) => (row as unknown as Record<string, string | undefined>)[field])
.find(Boolean),
}));
return (
<VStack gap={0}>
{steps.map((step, index) => {
const done = Boolean(step.at);
const isLast = index === steps.length - 1;
return (
<HStack key={step.label} gap={1.5} align="start">
<VStack gap={0} align="center" style={{ width: 20, flex: 'none' }}>
<span
style={{
width: 18,
height: 18,
borderRadius: 999,
display: 'grid',
placeItems: 'center',
flex: 'none',
background: done ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
color: done ? '#fff' : 'var(--color-ink-4)',
border: done ? 0 : '1px solid var(--color-line)',
}}
>
{done ? <Check size={11} /> : null}
</span>
{!isLast ? (
<span
style={{
width: 2,
height: 22,
background: done ? 'var(--color-brand)' : 'var(--color-line)',
}}
/>
) : null}
</VStack>
<VStack gap={0} style={{ paddingBottom: isLast ? 0 : 8 }}>
<Text
type="body"
size="sm"
style={{ color: done ? 'var(--color-ink-1)' : 'var(--color-ink-4)' }}
>
{step.label}
</Text>
{step.at ? (
<Text type="body" size="xsm" color="secondary">
{new Date(step.at).toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})}
</Text>
) : null}
</VStack>
</HStack>
);
})}
{isCancelled ? (
<HStack gap={1.5} align="center" style={{ paddingTop: 8 }}>
<span
style={{
width: 18,
height: 18,
borderRadius: 999,
flex: 'none',
display: 'grid',
placeItems: 'center',
background: 'color-mix(in oklab, var(--color-error, #d64545) 14%, transparent)',
color: 'var(--color-error, #d64545)',
}}
>
<X size={11} />
</span>
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
Cancelled
{row.canceltime
? ` · ${new Date(row.canceltime).toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})}`
: ''}
</Text>
</HStack>
) : null}
</VStack>
);
}
function Party({
icon,
heading,
name,
detail,
phone,
}: {
icon: React.ReactNode;
heading: string;
name: string | undefined;
detail: string | undefined;
phone: string | undefined;
}) {
return (
<HStack gap={1.5} align="start">
<span
style={{
width: 26,
height: 26,
borderRadius: 8,
flex: 'none',
display: 'grid',
placeItems: 'center',
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
}}
>
{icon}
</span>
<VStack gap={0} style={{ minWidth: 0 }}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
{heading}
</Text>
<Text type="body" size="sm" weight="semibold">
{name || '—'}
</Text>
{detail ? (
<Text type="body" size="xsm" color="secondary">
{detail}
</Text>
) : null}
{phone ? (
<HStack gap={0.5} align="center">
<Phone size={11} style={{ color: 'var(--color-ink-4)' }} />
<Text type="body" size="xsm" color="secondary">
{phone}
</Text>
</HStack>
) : null}
</VStack>
</HStack>
);
}
function Figure({
label,
value,
isStrong,
}: {
label: string;
value: string;
isStrong?: boolean;
}) {
return (
<VStack gap={0}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
{label}
</Text>
<Text
type="label"
size={isStrong ? 'lg' : 'sm'}
weight="semibold"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{value}
</Text>
</VStack>
);
}
const Label = ({ children }: { children: React.ReactNode }) => (
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
{children}
</Text>
);
const km = (value: string | undefined): string => {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? `${n.toFixed(1)} km` : '—';
};