Initial commit
This commit is contained in:
517
src/features/store-admin/OrderDetailDrawer.tsx
Normal file
517
src/features/store-admin/OrderDetailDrawer.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
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` : '—';
|
||||
};
|
||||
Reference in New Issue
Block a user