drawer and upload

This commit is contained in:
2026-09-07 13:02:35 +05:30
parent 8defbd418f
commit 05ac76f5f0
22 changed files with 3194 additions and 1568 deletions

View File

@@ -1,15 +1,13 @@
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 { ReactNode } from 'react';
import { ArrowDown, Bike, Check, MapPin, Phone, X } from 'lucide-react';
import type { DeliveryRow, OrderRow } from '@/api/types';
import { DeliveryProgress } from './DeliveryProgress';
import { useDeliveryMoves } from './DeliveryProgress';
import { Drawer } from './Drawer';
import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit';
import { branchLabel, moneyExact } from './format';
import { DELIVERY_STATUS, ORDER_STATUS, orderQuantity, orderValue, statusColor } from './orderStatus';
type Row = OrderRow | DeliveryRow;
type Row_ = OrderRow | DeliveryRow;
export type RowKind = 'order' | 'delivery';
@@ -44,11 +42,57 @@ export function OrderDetailDrawer({
kind,
onClose,
}: {
row: Row;
row: Row_;
kind: RowKind;
onClose: () => void;
}) {
const delivery = kind === 'delivery';
/*
Split before the hook, not inside it.
The moves hook drives both the footer buttons and the caption that
reports their errors, and it can only be called when there IS a delivery.
Calling it twice — once per renderer — would give each its own pending state
and its own error, so a failed write would be reported where nobody is
looking. One instance, passed to both.
*/
return kind === 'delivery' ? (
<DeliveryDrawer job={row as DeliveryRow} onClose={onClose} />
) : (
<Sheet row={row} isDelivery={false} onClose={onClose} />
);
}
function DeliveryDrawer({ job, onClose }: { job: DeliveryRow; onClose: () => void }) {
const moves = useDeliveryMoves(job);
return (
<Sheet
row={job}
isDelivery
onClose={onClose}
{...(moves.isSettled ? {} : { footer: <MoveActions moves={moves} /> })}
caption={<MoveCaption moves={moves} />}
/>
);
}
type Moves = ReturnType<typeof useDeliveryMoves>;
function Sheet({
row,
isDelivery,
onClose,
footer,
caption,
}: {
row: Row_;
/** Passed explicitly, never inferred from the presence of a footer — a
settled job has no actions and would otherwise read as an order. */
isDelivery: boolean;
onClose: () => void;
footer?: ReactNode;
caption?: ReactNode;
}) {
const delivery = isDelivery;
// 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
@@ -56,16 +100,6 @@ export function OrderDetailDrawer({
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);
@@ -74,234 +108,187 @@ export function OrderDetailDrawer({
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' }}
<Drawer
title={title}
isTitleMono
onClose={onClose}
meta={
<>
<Badge label={status || '—'} colour={colour} />
<span className="drawer-meta-text">{branchLabel(row.locationname)}</span>
</>
}
{...(footer ? { footer } : {})}
>
<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>
{/* ── Money ─────────────────────────────────────────────────────────
Three equal columns, the shape the spec asks for and the one that
answers "what is this worth, who pays what, how much of it". */}
<Metrics>
<Metric label="Value" value={value > 0 ? moneyExact(value) : '—'} />
{job ? (
<Metric
label="Rider charge"
value={job.deliverycharges ? moneyExact(job.deliverycharges) : '—'}
isSmall
/>
) : (
<Metric
label="Cash to collect"
value={order?.collectionamt ? moneyExact(order.collectionamt) : '—'}
isSmall
/>
)}
<Metric
label="Items"
value={job ? String(job.itemcount ?? '—') : String(orderQuantity(row) || '—')}
isSmall
/>
</Metrics>
<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) || '—')}
/>
<Section title="Order journey">
<Timeline row={row} isCancelled={cancelled} />
</Section>
<Section title="Route">
<DrawerCard>
<Party
kind="Pickup"
name={row.pickupcustomer || branchLabel(row.locationname)}
detail={
job
? job.pickupsuburb || job.pickuplocation || job.Pickupaddress || job.pickupaddress
: order?.pickupaddress || order?.pickupsuburb
}
phone={row.pickupcontactno}
/>
<div className="drawer-party-link" aria-hidden>
<ArrowDown size={13} />
</div>
<Party
kind="Drop"
name={row.deliverycustomer}
detail={row.deliveryaddress || row.deliverysuburb}
phone={row.deliverycontactno}
/>
</DrawerCard>
</Section>
{/* ── 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}
{/* ── Rider ─────────────────────────────────────────────────────────── */}
{job ? (
<Section title="Rider">
{job.ridername ? (
<DrawerCard>
<div className="drawer-party">
<span className="drawer-party-icon">
<Bike size={15} />
</span>
<div className="drawer-party-body">
<span className="drawer-party-name">{job.ridername}</span>
{job.ridercontact ? (
<a className="drawer-party-phone" href={`tel:${job.ridercontact}`}>
<Phone size={12} />
{job.ridercontact}
</a>
) : null}
</div>
</div>
{/* 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. */}
<Metrics cols={job.transitminutes ? 3 : 2}>
<Metric label="Planned" value={km(job.kms)} isSmall />
<Metric label="Actual" value={km(job.actualkms || job.riderkms)} isSmall />
{job.transitminutes ? (
<Metric label="Transit" value={`${job.transitminutes} min`} isSmall />
) : null}
</Metrics>
</DrawerCard>
) : (
<Note>No rider assigned yet.</Note>
)}
{/* The caption for the footer's actions. It stays in the body while
the buttons sit in the bar — it explains when to use them, which
is reading matter, not a control. */}
{caption}
</Section>
) : order?.rider ? (
<Section title="Rider">
<DrawerCard>
<Row
label="Rider"
value={order.rider}
icon={<Bike size={14} />}
/>
<Party
icon={<MapPin size={13} />}
heading="Drop"
name={row.deliverycustomer}
detail={row.deliveryaddress || row.deliverysuburb}
phone={row.deliverycontactno}
/>
</VStack>
{order.ridercontactno ? (
<Row label="Contact" value={order.ridercontactno} icon={<Phone size={14} />} />
) : null}
</DrawerCard>
</Section>
) : null}
{/* ── 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>
)}
{/* Recording progress by hand. Under the rider rather than beside
the status chip at the top: it is a correction, not the normal
way a job moves, and putting it in the header would invite it
to be used as one. */}
<DeliveryProgress job={job} />
</VStack>
) : order?.rider ? (
<VStack gap={1}>
<Label>Rider</Label>
<Text type="body" size="sm">
{order.rider}
{order.ridercontactno ? ` · ${order.ridercontactno}` : ''}
</Text>
</VStack>
) : null}
{row.ordernotes || job?.notes ? (
<Section title="Notes">
<DrawerCard tone="subtle">
<Row label="Note" value={row.ordernotes || job?.notes} isStacked />
</DrawerCard>
</Section>
) : 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}
<Note>
Line items are not in the list read — Fiesta returns them from the order-detail endpoint,
which this sheet does not call yet.
</Note>
</Drawer>
);
}
<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>
/* ── Recording progress by hand ──────────────────────────────────────────── */
/**
* The moves, in the drawer's fixed action bar.
*
* In the footer rather than beside the status chip: it is a correction, not the
* normal way a job moves, and putting it in the header would invite it to be
* used as one. It is still the drawer's primary action, which is why it is
* pinned rather than buried at the end of a scroll.
*/
function MoveActions({ moves: state }: { moves: Moves }) {
const { isPending, moves, status, run } = state;
return (
<>
{moves.map((step) => (
<DrawerButton
key={step.to}
label={step.label}
variant={step.to === 'cancelled' ? 'danger' : step.to === 'delivered' ? 'primary' : 'secondary'}
isDisabled={isPending || step.to === status}
onClick={() => run(step.to, step.stamp)}
/>
))}
</>
);
}
function MoveCaption({ moves }: { moves: Moves }) {
const { isSettled, problem, status } = moves;
if (isSettled) {
return <Note>This job is {status}. Nothing further to record.</Note>;
}
if (problem) {
return (
<div className="drawer-note" role="alert" style={{ color: 'var(--color-error)' }}>
<X size={15} />
<span>{problem}</span>
</div>
</div>
);
}
return (
<Note>
Use the buttons below only when the rider's app has not. Marking a job delivered here also
closes the order.
</Note>
);
}
@@ -315,7 +302,7 @@ export function OrderDetailDrawer({
* 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 }) {
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
@@ -326,196 +313,101 @@ function Timeline({ row, isCancelled }: { row: Row; isCancelled: boolean }) {
.find(Boolean),
}));
/* The step the job is sitting on: the first one without a stamp. Marked as
"current" rather than merely "not done", so the eye lands on where the
order actually is. A finished journey has none, which is correct. */
const current = isCancelled ? -1 : steps.findIndex((step) => !step.at);
return (
<VStack gap={0}>
<div className="drawer-timeline">
{steps.map((step, index) => {
const done = Boolean(step.at);
const isLast = index === steps.length - 1;
const isCurrent = index === current;
const isLast = index === steps.length - 1 && !isCancelled;
return (
<HStack key={step.label} gap={1.5} align="start">
<VStack gap={0} align="center" style={{ width: 20, flex: 'none' }}>
<div key={step.label} className="drawer-step">
<span className="drawer-step-rail">
<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)',
}}
className="drawer-step-dot"
data-done={done}
{...(isCurrent ? { 'data-current': 'true' } : {})}
>
{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)' }}
{!isLast ? <span className="drawer-step-line" data-done={done} /> : null}
</span>
<span className="drawer-step-body">
<span
className="drawer-step-label"
data-done={done}
{...(isCurrent ? { 'data-current': 'true' } : {})}
>
{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>
</span>
{step.at ? <span className="drawer-step-at">{when(step.at)}</span> : null}
</span>
</div>
);
})}
{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} />
<div className="drawer-step">
<span className="drawer-step-rail">
<span className="drawer-step-dot" data-tone="danger">
<X size={11} />
</span>
</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>
<span className="drawer-step-body">
<span className="drawer-step-label" style={{ color: 'var(--color-error)', fontWeight: 600 }}>
Cancelled
</span>
{row.canceltime ? <span className="drawer-step-at">{when(row.canceltime)}</span> : null}
</span>
</div>
) : null}
</VStack>
</div>
);
}
function Party({
icon,
heading,
kind,
name,
detail,
phone,
}: {
icon: React.ReactNode;
heading: string;
kind: 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}
<div className="drawer-party">
<span className="drawer-party-icon">
<MapPin size={15} />
</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}
<div className="drawer-party-body">
<span className="drawer-party-kind">{kind}</span>
<span className="drawer-party-name">{name || '—'}</span>
{detail ? <span className="drawer-party-detail">{detail}</span> : 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>
<a className="drawer-party-phone" href={`tel:${phone}`}>
<Phone size={12} />
{phone}
</a>
) : null}
</VStack>
</HStack>
</div>
</div>
);
}
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 when = (at: string): string =>
new Date(at).toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const km = (value: string | undefined): string => {
const n = Number(value);