518 lines
18 KiB
TypeScript
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` : '—';
|
|
};
|