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

@@ -40,6 +40,47 @@ const MOVES = [
/** The end states. Nothing follows them, so nothing is offered. */
const SETTLED = ['delivered', 'cancelled'];
/**
* The moves, split from their rendering.
*
* The drawer puts the buttons in its fixed footer and the caption in the body,
* and those two need to share one mutation — a second instance would give the
* footer its own pending state and its own error, so a failure would be
* reported in a place the operator is not looking. Returning both from one hook
* keeps a single source of truth for what is happening.
*/
export function useDeliveryMoves(job: DeliveryRow) {
const client = useQueryClient();
const [problem, setProblem] = useState<string | null>(null);
const status = (job.orderstatus ?? '').trim().toLowerCase();
const isSettled = SETTLED.includes(status);
const move = useMutation({
mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
deliveriesApi.update({
deliveryid: job.deliveryid,
orderheaderid: job.orderheaderid ?? 0,
orderstatus: to,
[stamp]: stampNow(),
}),
onSuccess: () => client.invalidateQueries({ queryKey: queryKeys.insights.all }),
onError: (error) => setProblem(errorMessage(error)),
});
return {
status,
isSettled,
isPending: move.isPending,
problem,
moves: MOVES,
run: (to: string, stamp: string) => {
setProblem(null);
move.mutate({ to, stamp });
},
};
}
export function DeliveryProgress({ job }: { job: DeliveryRow }) {
const client = useQueryClient();
const [problem, setProblem] = useState<string | null>(null);

View File

@@ -1,35 +1,51 @@
import { useEffect, type ReactNode } from 'react';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { X } from 'lucide-react';
import './drawer.css';
/**
* The side drawer, matched to `OrderDetailDrawer`.
* The console's side drawer. Every drawer in the product is this one.
*
* That one is the site's drawer and every measurement here is taken from it:
* the flex-end wrapper at z-index 70, a full-bleed scrim BUTTON rather than a
* div (so it is reachable and announces itself), a 460px panel capped at 92vw,
* `-24px 0 56px -12px` of shadow, a STICKY header on a hairline, and a 30px
* rounded close button on `--color-surface-sunken`.
* ── Why the panel is a flex column ──────────────────────────────────────────
*
* Extracted rather than copied a third time. The drawers I added had drifted —
* different scrim opacity, different shadow, a bare `X`, a header that scrolled
* away — which is exactly how two screens stop looking like one product.
* It used to be a single scrolling box with a sticky header. That cannot hold a
* footer: an action bar inside a scrolling container scrolls away with the
* content, which is exactly wrong for the button you opened the drawer to
* press. Header, scrolling body and action bar are now three rows of a column,
* so only the middle one moves.
*
* ── What belongs here and what does not ─────────────────────────────────────
*
* The shell owns the chrome — scrim, panel, header, close, footer — and nothing
* else. Content comes from `drawerKit`. A drawer that styles its own card or
* badge is the beginning of the drift this component exists to end.
*/
export interface DrawerProps {
title: string;
/** One line under the title. */
/** One line under the title — a brand, an SKU, a store name. */
subtitle?: string;
/** Sits beside the title — a status chip, usually. */
titleAside?: ReactNode;
/** Renders the title in the mono face. For codes: order ids, SKUs. */
isTitleMono?: boolean;
/** The status pill and any metadata beside it, on the row under the title. */
meta?: ReactNode;
/**
* Panel width in px. 460 is the site default; only widen for content that
* genuinely cannot fold, such as a grid of product cards.
* Panel width in px. The design system's range is 480–560; 520 is the
* default and only content that genuinely cannot fold should exceed it.
*/
width?: number;
/** Renders the body unpadded, for panels that manage their own layout. */
isBare?: boolean;
/**
* The fixed action bar. Omit it entirely for a read-only drawer — an empty
* bar is a band of chrome that costs height and offers nothing.
*/
footer?: ReactNode;
/** Stretch footer actions to fill the width, for a single primary action. */
isFooterFilled?: boolean;
/**
* Push the first action to the left and the rest right — a destructive
* action kept away from the one people mean to press.
*/
isFooterSpread?: boolean;
onClose: () => void;
children: ReactNode;
}
@@ -37,12 +53,23 @@ export interface DrawerProps {
export function Drawer({
title,
subtitle,
titleAside,
width = 460,
isTitleMono,
meta,
width = 520,
isBare,
footer,
isFooterFilled,
isFooterSpread,
onClose,
children,
}: DrawerProps) {
/*
Escape closes.
A dialog dismissible only by finding the scrim with a mouse is not dismissible
at all for anyone on a keyboard, and this one sits over the list the operator
is working through.
*/
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
@@ -52,83 +79,36 @@ export function Drawer({
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label={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,
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} style={{ minWidth: 0 }}>
<Text type="label" size="lg" weight="semibold">
{title}
</Text>
{subtitle ? (
<Text type="body" size="sm" color="secondary">
{subtitle}
</Text>
) : null}
{titleAside}
</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>
<div className="drawer-root" role="dialog" aria-modal="true" aria-label={title}>
<button type="button" className="drawer-scrim" aria-label="Close" onClick={onClose} />
{isBare ? children : <VStack gap={3} padding={2}>{children}</VStack>}
<div className="drawer-panel dialog-panel" style={{ width }}>
<header className="drawer-head">
<div className="drawer-head-text">
<h2 className="drawer-title" {...(isTitleMono ? { 'data-mono': 'true' } : {})}>
{title}
</h2>
{subtitle ? <span className="drawer-sub">{subtitle}</span> : null}
{meta ? <div className="drawer-meta">{meta}</div> : null}
</div>
<button type="button" className="drawer-close" aria-label="Close" onClick={onClose}>
<X size={16} />
</button>
</header>
<div className="drawer-body" {...(isBare ? { 'data-bare': 'true' } : {})}>
{children}
</div>
{footer ? (
<footer
className="drawer-foot"
{...(isFooterFilled ? { 'data-fill': 'true' } : {})}
{...(isFooterSpread ? { 'data-spread': 'true' } : {})}
>
{footer}
</footer>
) : null}
</div>
</div>
);

View File

@@ -1,9 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, Check, ExternalLink, Leaf } from 'lucide-react';
import { nutritionApi } from '@/api/nutrition';
import type { Product } from '@/api/types';
import { DrawerCard, Note, Row, Section } from './drawerKit';
import { BAND_COLOR, BAND_LABEL, facts, present } from './healthScore';
import './pages/deliveries.css';
@@ -57,24 +56,20 @@ export function HealthScorePanel({ product }: { product: Product }) {
*/
if (!brand || !imageId) {
return (
<VStack gap={1}>
<Label />
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
<Section title="Health score">
<Note>
This product is not linked to the global catalogue, so there is no health score for it.
Products imported from the catalogue carry one.
</Text>
</VStack>
</Note>
</Section>
);
}
if (query.isLoading) {
return (
<VStack gap={1}>
<Label />
<Text type="body" size="sm" color="secondary">
Checking the health score…
</Text>
</VStack>
<Section title="Health score">
<Note>Checking the health score…</Note>
</Section>
);
}
@@ -84,33 +79,28 @@ export function HealthScorePanel({ product }: { product: Product }) {
// "known but unscored", and said differently, for the same reason as above.
if (shown.isEmpty) {
return (
<VStack gap={1}>
<Label />
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
No health score available for this product yet.
</Text>
</VStack>
<Section title="Health score">
<Note>No health score available for this product yet.</Note>
</Section>
);
}
if (shown.isPending) {
return (
<VStack gap={1}>
<Label />
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
<Section title="Health score">
<Note>
This product is in the catalogue but has not been scored yet. It will appear here, and in
the app, once it has.
</Text>
</VStack>
</Note>
</Section>
);
}
const colour = shown.band ? BAND_COLOR[shown.band] : 'var(--color-ink-3)';
return (
<VStack gap={1}>
<Label />
<Section title="Health score">
<div className="health-body">
<div className="health-head">
<span
className="health-score"
@@ -179,22 +169,17 @@ export function HealthScorePanel({ product }: { product: Product }) {
) : null}
{facts(query.data ?? null).length > 0 ? (
<div className="health-facts">
{facts(query.data ?? null).map((row) => (
<div key={row.label}>
<span>{row.label}</span>
<strong>{row.value}</strong>
</div>
))}
<p>per 100 g</p>
</div>
<>
<DrawerCard>
{facts(query.data ?? null).map((row) => (
<Row key={row.label} label={row.label} value={row.value} />
))}
</DrawerCard>
<span className="health-per">per 100 g</span>
</>
) : null}
{shown.caveat ? (
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
{shown.caveat}
</Text>
) : null}
{shown.caveat ? <span className="health-caveat">{shown.caveat}</span> : null}
{shown.source ? (
<a className="health-source" href={shown.source.url} target="_blank" rel="noreferrer">
@@ -202,14 +187,7 @@ export function HealthScorePanel({ product }: { product: Product }) {
<ExternalLink size={11} />
</a>
) : null}
</VStack>
);
}
function Label() {
return (
<Text type="label" size="xsm" color="secondary">
Health score
</Text>
</div>
</Section>
);
}

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);

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
@@ -6,11 +6,12 @@ import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, Check, Copy, X } from 'lucide-react';
import { AlertTriangle, Check, Copy } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people';
import type { PosRole, PosUser, StaffInfo, StaffShift, TenantLocation } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { Drawer } from './Drawer';
import { branchLabel } from './format';
/* ── Shell ───────────────────────────────────────────────────────────────── */
@@ -34,102 +35,17 @@ function Sheet({
children: React.ReactNode;
footer: React.ReactNode;
}) {
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label={title}
style={{
position: 'fixed',
inset: 0,
zIndex: 70,
display: 'flex',
justifyContent: 'flex-end',
}}
<Drawer
title={title}
width={440}
onClose={onClose}
footer={footer}
isFooterSpread
{...(subtitle ? { subtitle } : {})}
>
<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: 440,
maxWidth: '92vw',
height: '100%',
display: 'flex',
flexDirection: 'column',
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={{ padding: 20, borderBottom: '1px solid var(--color-line)', flex: 'none' }}
>
<VStack gap={0}>
<Text type="label" size="lg" weight="semibold">
{title}
</Text>
{subtitle ? (
<Text type="body" size="xsm" color="secondary">
{subtitle}
</Text>
) : null}
</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>
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>{children}</div>
<HStack
justify="between"
align="center"
gap={1}
style={{ padding: 16, borderTop: '1px solid var(--color-line)', flex: 'none' }}
>
{footer}
</HStack>
</div>
</div>
{children}
</Drawer>
);
}

View File

@@ -1,17 +1,23 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
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 { EyeOff } from 'lucide-react';
import { BookOpen, EyeOff, Package, Ruler, Tag } from 'lucide-react';
import { productsApi } from '@/api/products';
import type { Product } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { useCatalogueProduct } from '@/queries/hooks';
import { useBranchScope } from './BranchScope';
import { Drawer } from './Drawer';
import {
Badge,
Bullets,
DrawerButton,
DrawerCard,
Metric,
Metrics,
Note,
Row,
Section,
} from './drawerKit';
import { branchLabel, count, money } from './format';
import { HealthScorePanel } from './HealthScorePanel';
import { ProductSizes } from './ProductSizes';
@@ -59,6 +65,7 @@ export function ProductDrawer({
const { tenantid, current, scoped } = useBranchScope();
const client = useQueryClient();
const [confirmHide, setConfirmHide] = useState(false);
const [shot, setShot] = useState(0);
const source = useCatalogueProduct(product.productbrand, product.productsku);
const images = imagesOf(product);
@@ -76,61 +83,82 @@ export function ProductDrawer({
});
const price = effectivePrice(product);
const cost = product.productcost ?? 0;
/* Margin is derived, not stored. Shown only when both halves are real — a
margin against a missing cost is a number that looks like a fact. */
const margin = price > 0 && cost > 0 ? price - cost : null;
return (
<Drawer
title={product.productname ?? 'Product'}
subtitle={[product.productbrand, product.productsku].filter(Boolean).join(' · ') || undefined}
titleAside={
<HStack gap={1} align="center" wrap="wrap" style={{ paddingTop: 2 }}>
<Chip label={STATE_LABEL[state]} color={STATE_COLOR[state]} />
</HStack>
}
onClose={onClose}
meta={<Badge label={STATE_LABEL[state]} colour={STATE_COLOR[state]} />}
footer={
canManage && isPublished(product) && !confirmHide ? (
<DrawerButton
label="Hide from the store catalogue"
variant="secondary"
icon={<EyeOff size={14} />}
onClick={() => setConfirmHide(true)}
/>
) : undefined
}
>
{/* ── Money ─────────────────────────────────────────────────────── */}
<div className="form-grid">
<Figure
label="Selling price"
value={effectivePrice(product) > 0 ? money(effectivePrice(product)) : 'not set'}
isStrong
/>
<Figure label="Cost" value={product.productcost ? money(product.productcost) : '—'} />
<Figure label="Tax" value={`${product.taxpercent ?? 0}%`} />
</div>
{/* ── Money ─────────────────────────────────────────────────────────
Margin joins the three the spec lists, because "what do I make on
this" is the question the other three get asked in service of. */}
<Metrics cols={margin === null ? 3 : 4}>
<Metric label="Selling price" value={price > 0 ? money(price) : 'not set'} />
<Metric label="Cost" value={cost > 0 ? money(cost) : '—'} isSmall />
<Metric label="Tax" value={`${product.taxpercent ?? 0}%`} isSmall />
{margin === null ? null : (
<Metric
label="Margin"
value={money(margin)}
note={`${((margin / price) * 100).toFixed(1)}%`}
isSmall
/>
)}
</Metrics>
{reason ? (
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{reason}
</Text>
) : null}
{reason ? <Note>{reason}</Note> : null}
{/* ── Pack shots ────────────────────────────────────────────────── */}
{/* ── Pack shot ─────────────────────────────────────────────────────
One large image with thumbnails under it, rather than a row of four
equals: the first image is the pack a shopper meets, the rest are
angles of the same thing. */}
{images.length > 0 ? (
<VStack gap={1}>
<Label>Pack</Label>
<HStack gap={1} wrap="wrap">
{images.slice(0, 4).map((url) => (
<img
key={url}
src={url}
alt=""
style={{
width: 76,
height: 76,
objectFit: 'contain',
mixBlendMode: 'multiply',
borderRadius: 12,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
}}
/>
))}
</HStack>
</VStack>
<Section title="Pack">
<div className="drawer-figure">
<img
src={images[shot] ?? images[0]}
alt={product.productname ?? 'Product'}
style={{ mixBlendMode: 'multiply' }}
/>
</div>
{images.length > 1 ? (
<div className="drawer-thumbs">
{images.map((url, index) => (
<button
key={url}
type="button"
className="drawer-thumb"
data-active={index === shot}
aria-label={`Photo ${index + 1}`}
onClick={() => setShot(index)}
>
<img src={url} alt="" style={{ mixBlendMode: 'multiply' }} />
</button>
))}
</div>
) : null}
</Section>
) : null}
{/* ── Sizes ─────────────────────────────────────────────────────── */}
{/* Above "In your shops" because it changes what a SHOPPER meets, which
{/* ── Sizes ─────────────────────────────────────────────────────────
Above "In your shops" because it changes what a SHOPPER meets, which
is the bigger fact about a product than where it is stocked. */}
{tenantid && branch ? (
<ProductSizes
@@ -141,103 +169,87 @@ export function ProductDrawer({
/>
) : null}
{/* ── Health score ──────────────────────────────────────────────── */}
{/* Under Sizes and above the shop detail: it is what a SHOPPER reads,
{/* ── Health score ──────────────────────────────────────────────────
Under Sizes and above the shop detail: it is what a SHOPPER reads,
and it belongs with the other shopper-facing facts. */}
<HealthScorePanel product={product} />
{/* ── In your shops ─────────────────────────────────────────────── */}
<VStack gap={1}>
<Label>In your shops</Label>
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
<Line
label={branch ? `Stock at ${branchLabel(branch.locationname)}` : 'Stock'}
value={count(stockOf(product))}
/>
<Line label="Released to shops" value={isPublished(product) ? 'yes' : 'not yet'} />
<Line
label="Category"
value={product.categoryname || product.Subcategoryname || 'uncategorised'}
/>
<Line
label="Unit"
value={[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'}
/>
</VStack>
</Card>
</VStack>
{product.productdesc ? (
<VStack gap={1}>
<Label>Description</Label>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
{product.productdesc}
</Text>
</VStack>
<Section title="Description">
{/* Prose, not a labelled row — the section heading already says what
this is, and a row reading "Description: …" says it twice. */}
<p className="drawer-prose">{product.productdesc}</p>
</Section>
) : null}
{/* ── The fields the import left behind ─────────────────────────── */}
{/* ── In your shops ─────────────────────────────────────────────────── */}
<Section title="In your shops">
<DrawerCard>
<Row
label={branch ? `Stock at ${branchLabel(branch.locationname)}` : 'Stock'}
value={count(stockOf(product))}
icon={<Package size={14} />}
/>
<Row
label="Released to shops"
value={isPublished(product) ? 'Yes' : 'Not yet'}
{...(isPublished(product) ? { tone: 'success' as const } : {})}
/>
<Row
label="Category"
value={product.categoryname || product.Subcategoryname || 'Uncategorised'}
icon={<Tag size={14} />}
/>
<Row
label="Unit"
value={[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'}
icon={<Ruler size={14} />}
/>
</DrawerCard>
</Section>
{/* ── The fields the import left behind ─────────────────────────────── */}
{source.isLoading ? (
<Text type="body" size="xsm" color="secondary">
Looking up the full catalogue entry…
</Text>
<Note>Looking up the full catalogue entry…</Note>
) : source.data ? (
<CatalogueExtras data={source.data} />
) : (
<VStack gap={1}>
<Label>Catalogue entry</Label>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
<Section title="Catalogue entry">
<Note>
Not available — this item has been revised in the global catalogue since you imported
it. Your copy is unaffected; only the extra reference details are gone.
</Text>
</VStack>
</Note>
</Section>
)}
{canManage && isPublished(product) ? (
<VStack gap={1}>
<div style={{ height: 1, background: 'var(--color-line)' }} />
{confirmHide ? (
<VStack gap={1}>
{/*
The honest description. `publishedat` is read by exactly one
query in the whole backend and filtered by none: the till gates
on `price > 0 && status != 'outofstock'`, and the customer-app
feed does not select the column at all. So this hides the
product HERE and nowhere else.
*/}
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
This hides <strong>{product.productname}</strong> from your shops&rsquo; product
list. It does <strong>not</strong> take it off the till or out of the customer app
— those go by price and stock, not by this setting. To stop selling it, set its
stock to zero.
</Text>
<HStack gap={1} justify="end" wrap="wrap">
<Button
label="Cancel"
variant="ghost"
size="sm"
onClick={() => setConfirmHide(false)}
/>
<Button
label={unpublish.isPending ? 'Hiding…' : 'Hide from the list'}
variant="secondary"
size="sm"
onClick={() => unpublish.mutate()}
isDisabled={unpublish.isPending || !tenantid}
/>
</HStack>
</VStack>
) : (
<Button
label="Hide from the store catalogue"
variant="ghost"
size="sm"
icon={<EyeOff size={13} />}
onClick={() => setConfirmHide(true)}
{/* ── Hiding it ─────────────────────────────────────────────────────
The confirmation stays in the body rather than the footer: it is the
explanation of what the button does, and it has to be read before the
decision rather than sit underneath it. */}
{canManage && isPublished(product) && confirmHide ? (
<Section title="Hide from the store catalogue">
<Note tone="brand">
{/*
The honest description. `publishedat` is read by exactly one
query in the whole backend and filtered by none: the till gates on
`price > 0 && status != 'outofstock'`, and the customer-app feed
does not select the column at all. So this hides the product HERE
and nowhere else.
*/}
This hides <strong>{product.productname}</strong> from your shops&rsquo; product list. It
does <strong>not</strong> take it off the till or out of the customer app — those go by
price and stock, not by this setting. To stop selling it, set its stock to zero.
</Note>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<DrawerButton label="Cancel" variant="ghost" onClick={() => setConfirmHide(false)} />
<DrawerButton
label={unpublish.isPending ? 'Hiding…' : 'Hide from the list'}
variant="danger"
onClick={() => unpublish.mutate()}
isDisabled={unpublish.isPending || !tenantid}
/>
)}
</VStack>
</div>
</Section>
) : null}
</Drawer>
);
@@ -246,9 +258,9 @@ export function ProductDrawer({
/**
* The catalogue's own reference data.
*
* Laid out as figure tiles and a card, the same as everything above it — a
* two-column key/value list was the thing that made this drawer read as a
* different product from the order drawer beside it.
* Built from the same Section / Card / Row pieces as everything above it — a
* two-column key/value list of its own was the thing that made this drawer read
* as a different product from the order drawer beside it.
*/
function CatalogueExtras({
data,
@@ -261,121 +273,45 @@ function CatalogueExtras({
if (data.providers?.length) lines.push(['Sold by', data.providers.join(', ')]);
return (
<VStack gap={1}>
<Label>From the global catalogue</Label>
<Section title="From the global catalogue">
{data.size || data.price_range ? (
<div className="form-grid">
{data.size ? <Figure label="Pack size" value={data.size} /> : null}
{data.price_range ? <Figure label="Typical retail" value={data.price_range} /> : null}
</div>
<Metrics cols={2}>
{data.price_range ? <Metric label="Typical retail" value={data.price_range} /> : null}
{data.size ? <Metric label="Pack size" value={data.size} isSmall /> : null}
</Metrics>
) : null}
{lines.length > 0 ? (
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
{lines.map(([label, value]) => (
<Line key={label} label={label} value={value} />
))}
</VStack>
</Card>
) : null}
{data.highlights?.length ? (
<VStack gap={0}>
{data.highlights.map((line) => (
<Text key={line} type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
• {line}
</Text>
<DrawerCard>
{lines.map(([label, value]) => (
<Row
key={label}
label={label}
value={value}
{...(label === 'Catalogue category' ? { icon: <BookOpen size={14} /> } : {})}
/>
))}
</VStack>
</DrawerCard>
) : null}
{data.nutrients?.length ? (
<VStack gap={1}>
<Label>Nutrition</Label>
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={2}>
{data.nutrients.map((line) => (
<Text key={line} type="body" size="sm" color="secondary">
{line}
</Text>
))}
</VStack>
</Card>
</VStack>
{/* Highlights and nutrition side by side — two short lists that each read
as a column, rather than one long stack that buries the second. */}
{data.highlights?.length || data.nutrients?.length ? (
<div className="drawer-bullets">
{data.highlights?.length ? (
<div className="drawer-section">
<h4 className="drawer-section-title">Highlights</h4>
<Bullets items={data.highlights} />
</div>
) : null}
{data.nutrients?.length ? (
<div className="drawer-section">
<h4 className="drawer-section-title">Nutrition</h4>
<Bullets items={data.nutrients} />
</div>
) : null}
</div>
) : null}
</VStack>
);
}
/* ── Bits, taken from OrderDetailDrawer so the two drawers read alike ────── */
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>
);
}
function Line({ label, value }: { label: string; value: string }) {
return (
<HStack justify="between" align="start" gap={2}>
<Text type="body" size="sm" color="secondary" style={{ flex: 'none' }}>
{label}
</Text>
<Text type="body" size="sm" style={{ textAlign: 'right' }}>
{value}
</Text>
</HStack>
);
}
const Label = ({ children }: { children: React.ReactNode }) => (
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
{children}
</Text>
);
/** The status chip, same shape as the tables use. */
function Chip({ label, color }: { label: string; color: string }) {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: 999,
padding: '2px 9px',
fontSize: 11.5,
fontWeight: 600,
whiteSpace: 'nowrap',
color,
background: `color-mix(in oklab, ${color} 12%, transparent)`,
border: `1px solid color-mix(in oklab, ${color} 26%, transparent)`,
}}
>
{label}
</span>
</Section>
);
}

View File

@@ -1,6 +1,5 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
@@ -13,6 +12,7 @@ import type { TenantLocation } from '@/api/types';
import { usePartners, useRiderShifts } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { Drawer } from './Drawer';
import { DrawerButton } from './drawerKit';
import './pages/deliveries.css';
/**
@@ -118,6 +118,19 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
subtitle={isNew ? 'They deliver for this shop' : row.contactno}
width={480}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<DrawerButton
label={save.isPending ? 'Saving…' : isNew ? 'Add rider' : 'Save'}
variant="primary"
icon={<Bike size={15} />}
isDisabled={!canSave}
onClick={() => save.mutate()}
/>
</>
}
>
<VStack gap={3}>
{/* The region is stated, not offered. If the branch has none, saying so
@@ -229,18 +242,6 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
</Text>
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} />
<Button
label={save.isPending ? 'Saving…' : isNew ? 'Add rider' : 'Save'}
variant="primary"
size="sm"
icon={<Bike size={14} />}
isDisabled={!canSave}
onClick={() => save.mutate()}
/>
</HStack>
{/* Said before the button, not after. This is the behaviour most likely
to be read as a failed save. */}
{isNew ? (

View File

@@ -11,6 +11,7 @@ import { optimiserApi, type SequencedStop } from '@/api/optimiser';
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { Drawer } from './Drawer';
import { DrawerButton } from './drawerKit';
import { buildDeliveries, riderName } from './assignDelivery';
import { money } from './format';
import { orderValue } from './orderStatus';
@@ -166,6 +167,27 @@ export function RoutePlanDrawer({
subtitle={`${riderName(rider)} · ${orders.length} order${orders.length === 1 ? '' : 's'}`}
width={520}
onClose={onClose}
footer={
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<span style={{ display: 'flex', gap: 8, marginLeft: 'auto' }}>
<DrawerButton
label="Re-plan"
variant="secondary"
icon={<Route size={15} />}
isDisabled={sequence.isPending}
onClick={() => sequence.mutate()}
/>
<DrawerButton
label={commit.isPending ? 'Assigning…' : 'Assign this round'}
variant="primary"
icon={<Bike size={15} />}
isDisabled={Boolean(blocked) || commit.isPending}
onClick={() => commit.mutate()}
/>
</span>
</>
}
>
<VStack gap={3}>
{sequence.isPending ? (
@@ -261,27 +283,6 @@ export function RoutePlanDrawer({
</Text>
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} />
<HStack gap={1} align="center">
<Button
label="Re-plan"
variant="secondary"
size="sm"
icon={<Route size={14} />}
isDisabled={sequence.isPending}
onClick={() => sequence.mutate()}
/>
<Button
label={commit.isPending ? 'Assigning…' : 'Assign this round'}
variant="primary"
size="sm"
icon={<Bike size={14} />}
isDisabled={Boolean(blocked) || commit.isPending}
onClick={() => commit.mutate()}
/>
</HStack>
</HStack>
{blocked && !sequence.isPending ? (
<Text type="body" size="xsm" color="secondary">

View File

@@ -0,0 +1,575 @@
/* ══ The drawer design system ══════════════════════════════════════════════
One shell, one header, one card, one row, one badge, one button — used by
every side drawer in the console so they read as one product rather than as
several. Everything below is built on the tokens already in `index.css`:
#662582 is the Nearle purple, #0f172a the navy, and the ink scale supplies
the muted greys. No drawer defines a colour of its own.
Spacing is an 8px system throughout: 8 / 12 / 16 / 20 / 24. */
/* ── Shell ──────────────────────────────────────────────────────────────── */
.drawer-root {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
justify-content: flex-end;
}
/* A button, not a div: the scrim is the primary way a mouse user dismisses
this, so it has to be reachable and to announce itself. Light enough that
the list behind stays readable — the operator is comparing the drawer
against the row it came from, and blacking that out removes the comparison. */
.drawer-scrim {
position: absolute;
inset: 0;
border: 0;
padding: 0;
background: rgb(15 23 42 / 0.32);
cursor: pointer;
animation: drawer-fade 180ms ease;
}
.drawer-panel {
position: relative;
display: flex;
flex-direction: column;
/* Column layout, not `overflow: auto` on the panel — that is what lets the
header and the action bar stay put while only the middle scrolls. */
height: 100%;
max-width: 92vw;
background: var(--color-surface);
box-shadow: -24px 0 56px -12px rgb(15 23 42 / 0.18);
animation: drawer-in 220ms cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes drawer-in {
from { transform: translateX(16px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes drawer-fade {
from { opacity: 0; }
to { opacity: 1; }
}
/* Motion is decoration here; the panel must still arrive. */
@media (prefers-reduced-motion: reduce) {
.drawer-panel, .drawer-scrim { animation: none; }
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.drawer-head {
flex: none;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--color-line);
background: var(--color-surface);
}
.drawer-head-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.drawer-title {
margin: 0;
font: 600 21px/1.25 var(--font-sans);
letter-spacing: -0.01em;
color: var(--color-ink-1);
overflow-wrap: anywhere;
}
/* An order id is a code, and a code reads better tabular. */
.drawer-title[data-mono='true'] { font-family: var(--font-mono); font-size: 20px; }
.drawer-sub {
font: 400 13.5px/1.4 var(--font-sans);
color: var(--color-ink-3);
overflow-wrap: anywhere;
}
/* The row under the title: badge, then any metadata. */
.drawer-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 2px;
}
.drawer-meta-text { font: 400 13px/1.4 var(--font-sans); color: var(--color-ink-3); }
.drawer-close {
width: 32px;
height: 32px;
border-radius: 10px;
border: 0;
flex: none;
display: grid;
place-items: center;
background: var(--color-surface-sunken);
color: var(--color-ink-3);
cursor: pointer;
transition: background 160ms ease, color 160ms ease;
}
.drawer-close:hover { background: var(--color-slate-200); color: var(--color-ink-1); }
.drawer-close:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
/* ── Body ───────────────────────────────────────────────────────────────── */
.drawer-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
/* Panels that lay themselves out — a gallery, a form with its own rhythm. */
.drawer-body[data-bare='true'] { padding: 0; gap: 0; }
/* Children keep their own height.
The body is a column flex container, so by default every child is allowed to
SHRINK once the content is taller than the panel — which is always. Anything
carrying `overflow: hidden` has a min-content height of zero and therefore
collapses completely: the metric grid rendered as a 1px hairline with its
figures clipped out of existence, and the cards were being squeezed too. The
body scrolls; its children do not compress. */
.drawer-body > * { flex: 0 0 auto; }
/* ── Footer action bar ──────────────────────────────────────────────────── */
/* Fixed, so the thing you opened the drawer to DO stays reachable however long
the content runs. Rendered only when a drawer actually has actions — an
empty bar is a band of chrome that costs height and offers nothing. */
.drawer-foot {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 24px;
border-top: 1px solid var(--color-line);
background: var(--color-surface);
}
.drawer-foot > * { flex: 0 1 auto; }
/* A lone action stretches: a single button hugging the right edge of a 520px
panel looks like it was left there by accident. */
.drawer-foot[data-fill='true'] > * { flex: 1 1 auto; }
/* Destructive on the left, the intended action on the right, with the width of
the panel between them. */
.drawer-foot[data-spread='true'] { justify-content: space-between; }
/* ── Section ────────────────────────────────────────────────────────────── */
.drawer-section { display: flex; flex-direction: column; gap: 10px; }
.drawer-section-title {
font: 600 11.5px/1 var(--font-sans);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-ink-3);
}
/* ── Card ───────────────────────────────────────────────────────────────── */
/* One radius, one border, everywhere. Rows inside are separated by hairlines
rather than by giving each its own card — a stack of cards for four labelled
values is the clutter this redesign exists to remove. */
.drawer-card {
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-surface);
overflow: hidden;
}
.drawer-card[data-tone='subtle'] { background: var(--color-surface-subtle); }
.drawer-card[data-tone='brand'] {
background: var(--color-brand-tint);
border-color: color-mix(in oklab, var(--color-brand) 18%, transparent);
}
/* ── Row: label left, value right ───────────────────────────────────────── */
.drawer-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: baseline;
padding: 11px 14px;
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
}
.drawer-row:last-child { border-bottom: 0; }
.drawer-row-label {
font: 400 13.5px/1.45 var(--font-sans);
color: var(--color-ink-3);
display: inline-flex;
align-items: center;
gap: 8px;
}
.drawer-row-label svg { color: var(--color-brand); flex: none; }
/* Right-aligned, tabular, so a column of values lines up on the decimal. */
.drawer-row-value {
font: 500 14px/1.45 var(--font-sans);
color: var(--color-ink-1);
text-align: right;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.drawer-row-value[data-tone='success'] { color: #0f8a5f; font-weight: 600; }
.drawer-row-value[data-tone='muted'] { color: var(--color-ink-4); }
/* A row that wraps its value under the label — for prose, not for figures. */
.drawer-row[data-stacked='true'] { grid-template-columns: 1fr; gap: 4px; }
.drawer-row[data-stacked='true'] .drawer-row-value { text-align: left; font-weight: 400; }
/* ── Metric grid ────────────────────────────────────────────────────────── */
/* Three equal columns. Equal, not auto: the whole point is that the figures sit
on a rhythm, and `auto` columns move them every time a value changes width. */
.drawer-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
background: var(--color-line);
border: 1px solid var(--color-line);
border-radius: 12px;
overflow: hidden;
}
.drawer-metrics[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.drawer-metrics[data-cols='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.drawer-metric {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
background: var(--color-surface);
min-width: 0;
}
.drawer-metric-label {
font: 600 11px/1 var(--font-sans);
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--color-ink-4);
}
.drawer-metric-value {
font: 600 17px/1.2 var(--font-sans);
color: var(--color-ink-1);
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.drawer-metric-value[data-size='sm'] { font-size: 14.5px; }
.drawer-metric-note { font: 400 12px/1.3 var(--font-sans); color: var(--color-ink-4); }
/* ── Status badge ───────────────────────────────────────────────────────── */
/* One badge, tinted from a single colour, so every status in the console is the
same shape and only the hue carries meaning. */
.drawer-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 999px;
font: 600 12px/1.5 var(--font-sans);
text-transform: capitalize;
white-space: nowrap;
}
/* ── Timeline ───────────────────────────────────────────────────────────── */
.drawer-timeline { display: flex; flex-direction: column; }
.drawer-step { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; }
.drawer-step-rail { display: flex; flex-direction: column; align-items: center; }
.drawer-step-dot {
width: 18px;
height: 18px;
border-radius: 999px;
display: grid;
place-items: center;
flex: none;
background: var(--color-surface-sunken);
border: 1px solid var(--color-line);
color: var(--color-ink-4);
}
.drawer-step-dot[data-done='true'] {
background: var(--color-brand);
border-color: var(--color-brand);
color: #fff;
}
/* The step the order is actually sitting on — a ring rather than a fill, so it
reads as "here now" and not as "finished". */
.drawer-step-dot[data-current='true'] {
background: var(--color-surface);
border: 2px solid var(--color-brand);
box-shadow: 0 0 0 4px var(--color-brand-tint);
}
.drawer-step-dot[data-tone='danger'] {
background: color-mix(in oklab, var(--color-error) 14%, transparent);
border-color: transparent;
color: var(--color-error);
}
/* Purple only as far as the journey has actually got. */
.drawer-step-line { width: 2px; flex: 1 1 auto; min-height: 20px; background: var(--color-line); }
.drawer-step-line[data-done='true'] { background: var(--color-brand); }
.drawer-step-body { padding-bottom: 14px; display: flex; flex-direction: column; gap: 2px; }
.drawer-step:last-child .drawer-step-body { padding-bottom: 0; }
.drawer-step-label { font: 500 14px/1.35 var(--font-sans); color: var(--color-ink-4); }
.drawer-step-label[data-done='true'] { color: var(--color-ink-1); }
.drawer-step-label[data-current='true'] { color: var(--color-brand); font-weight: 600; }
.drawer-step-at { font: 400 12.5px/1.35 var(--font-sans); color: var(--color-ink-3); font-variant-numeric: tabular-nums; }
/* ── Route ──────────────────────────────────────────────────────────────── */
.drawer-party { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 12px; padding: 12px 14px; }
.drawer-party-icon {
width: 30px;
height: 30px;
border-radius: 9px;
display: grid;
place-items: center;
flex: none;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.drawer-party-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.drawer-party-kind {
font: 600 10.5px/1 var(--font-sans);
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--color-ink-4);
}
.drawer-party-name { font: 600 14.5px/1.35 var(--font-sans); color: var(--color-ink-1); overflow-wrap: anywhere; }
.drawer-party-detail { font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-3); overflow-wrap: anywhere; }
/* The phone reads as something you could act on, without pretending to be a
button — these drawers are opened on a desktop where tel: does nothing. */
.drawer-party-phone {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 4px;
font: 500 12.5px/1 var(--font-sans);
color: var(--color-brand);
font-variant-numeric: tabular-nums;
text-decoration: none;
}
.drawer-party-phone:hover { text-decoration: underline; }
/* The arrow between pickup and drop. */
.drawer-party-link {
display: grid;
place-items: center;
height: 18px;
color: var(--color-ink-4);
border-top: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
background: var(--color-surface-subtle);
}
/* ── Informational note ─────────────────────────────────────────────────── */
/* Deliberately quiet. It explains an absence — no health score, no line items —
and an explanation that shouts competes with the data it is apologising for. */
.drawer-note {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--color-line);
background: var(--color-surface-subtle);
font: 400 13px/1.5 var(--font-sans);
color: var(--color-ink-3);
}
.drawer-note svg { color: var(--color-ink-4); margin-top: 1px; }
.drawer-note[data-tone='brand'] {
background: var(--color-brand-tint);
border-color: color-mix(in oklab, var(--color-brand) 16%, transparent);
}
.drawer-note[data-tone='brand'] svg { color: var(--color-brand); }
/* ── Buttons ────────────────────────────────────────────────────────────── */
/* One height, one radius, one type ramp for every button in every drawer. */
.drawer-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 42px;
padding: 0 18px;
border-radius: 11px;
border: 1px solid transparent;
font: 600 14px/1 var(--font-sans);
cursor: pointer;
white-space: nowrap;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
}
.drawer-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
.drawer-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.drawer-btn[data-variant='primary'] { background: var(--color-brand); color: #fff; }
.drawer-btn[data-variant='primary']:hover:not(:disabled) { background: var(--color-brand-strong); }
.drawer-btn[data-variant='secondary'] {
background: var(--color-surface);
border-color: color-mix(in oklab, var(--color-brand) 35%, transparent);
color: var(--color-brand);
}
.drawer-btn[data-variant='secondary']:hover:not(:disabled) { background: var(--color-brand-tint); }
/* Outline, never filled: a destructive action should be reachable without
being the brightest thing in the drawer. */
.drawer-btn[data-variant='danger'] {
background: var(--color-surface);
border-color: color-mix(in oklab, var(--color-error) 40%, transparent);
color: var(--color-error);
}
.drawer-btn[data-variant='danger']:hover:not(:disabled) {
background: color-mix(in oklab, var(--color-error) 8%, transparent);
}
.drawer-btn[data-variant='ghost'] { background: transparent; color: var(--color-ink-2); }
.drawer-btn[data-variant='ghost']:hover:not(:disabled) { background: var(--color-surface-sunken); }
/* A text-weight action that lives inside a section, not in the footer —
"Group with another size". */
.drawer-link-action {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: none;
font: 600 13px/1 var(--font-sans);
color: var(--color-brand);
cursor: pointer;
}
.drawer-link-action:hover { text-decoration: underline; }
.drawer-link-action:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; }
/* ── Image area ─────────────────────────────────────────────────────────── */
.drawer-figure {
display: grid;
place-items: center;
padding: 16px;
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-surface-subtle);
/* The backstop. A catalogue photo can be 2000px wide, and an auto grid track
will happily size to it and push the image straight out of the panel —
which is exactly what happened. Nothing leaves this box. */
overflow: hidden;
min-width: 0;
}
/* Capped rather than stretched: the original ratio is preserved, and a tall
bottle does not push the rest of the drawer off screen. Both axes are capped
— capping only the height lets a wide image grow sideways instead. */
.drawer-figure img {
max-height: 200px;
max-width: 100%;
width: auto;
height: auto;
object-fit: contain;
}
/* The catalogue's hero, where the pack text has to be legible before you zoom.
The cap is in px, not 100%: a percentage max-height resolves against the grid
area, which is treated as indefinite here, so the browser drops it entirely —
a 646px image sat inside a 260px box, clipped rather than fitted. 228 is the
260 box less its 16px padding on each side. */
.drawer-figure[data-tall='true'] { height: 260px; }
.drawer-figure[data-tall='true'] img { max-height: 228px; }
.drawer-thumbs { display: flex; gap: 8px; flex-wrap: wrap; }
.drawer-thumb {
width: 54px;
height: 54px;
padding: 3px;
border-radius: 10px;
border: 1px solid var(--color-line);
background: var(--color-surface);
cursor: pointer;
display: grid;
place-items: center;
overflow: hidden;
}
.drawer-thumb img { width: 100%; height: 100%; object-fit: contain; }
.drawer-thumb[data-active='true'] {
border-color: var(--color-brand);
box-shadow: 0 0 0 2px var(--color-brand-tint);
}
.drawer-thumb:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
/* ── Highlight bullets ──────────────────────────────────────────────────── */
.drawer-bullets { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
@media (max-width: 520px) { .drawer-bullets { grid-template-columns: 1fr; } }
.drawer-bullet-list { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; list-style: none; }
.drawer-bullet {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: baseline;
font: 400 13.5px/1.45 var(--font-sans);
color: var(--color-ink-2);
}
.drawer-bullet::before {
content: '';
width: 5px;
height: 5px;
border-radius: 999px;
background: var(--color-brand);
transform: translateY(-2px);
}
/* ── Prose ──────────────────────────────────────────────────────────────── */
/* 15px and 1.6 — a description is the one thing in a drawer that gets read
rather than scanned. */
.drawer-prose {
font: 400 14.5px/1.6 var(--font-sans);
color: var(--color-ink-2);
overflow-wrap: anywhere;
}
.drawer-prose p { margin: 0 0 10px; }
.drawer-prose p:last-child { margin-bottom: 0; }
/* ── Narrower screens ───────────────────────────────────────────────────── */
/* The panel is already capped at 92vw; below that the padding is what has to
give, or a 520px layout inside a 360px panel just clips. */
@media (max-width: 600px) {
.drawer-head { padding: 16px 16px 14px; }
.drawer-body { padding: 16px; gap: 20px; }
.drawer-foot { padding: 12px 16px; }
.drawer-title { font-size: 19px; }
.drawer-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* ── Health score, inside the drawer system ─────────────────────────────── */
/* The panel's own pieces — score chip, points, tags — already sit on the brand
palette and are kept. What is added here is the wrapper that makes it read as
one compact card rather than as a loose stack, which is what the redesign
asks of it: informative, and not competing with the product it describes. */
.health-body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--color-line);
border-radius: 12px;
background: var(--color-surface-subtle);
}
.health-per {
font: 400 12px/1.3 var(--font-sans);
color: var(--color-ink-4);
text-align: right;
}
.health-caveat { font: 400 12.5px/1.55 var(--font-sans); color: var(--color-ink-3); }

View File

@@ -0,0 +1,224 @@
import type { ReactNode } from 'react';
import { Info } from 'lucide-react';
import './drawer.css';
/**
* The pieces every drawer is built from.
*
* A drawer should be assembled out of these and should not style anything
* itself. That is the whole point: the console had eleven drawers that had each
* drifted — different header, different card, different badge — and the way to
* stop that happening again is for there to be nothing local to drift.
*
* Everything reads from `drawer.css`, which reads from the brand tokens in
* `index.css`. No component here names a colour.
*/
/* ── Section ─────────────────────────────────────────────────────────────── */
export function Section({
title,
action,
children,
}: {
title?: string;
/** A text action beside the title — "Group with another size". */
action?: ReactNode;
children: ReactNode;
}) {
return (
<section className="drawer-section">
{title || action ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
{title ? <h3 className="drawer-section-title">{title}</h3> : <span />}
{action}
</div>
) : null}
{children}
</section>
);
}
/* ── Card and rows ───────────────────────────────────────────────────────── */
export function DrawerCard({
tone,
children,
}: {
tone?: 'subtle' | 'brand';
children: ReactNode;
}) {
return (
<div className="drawer-card" {...(tone ? { 'data-tone': tone } : {})}>
{children}
</div>
);
}
/**
* A labelled value: label left, value right, hairline below.
*
* `isStacked` puts the value under the label instead — for prose, where a
* right-aligned paragraph would be unreadable.
*/
export function Row({
label,
value,
icon,
tone,
isStacked,
}: {
label: ReactNode;
value: ReactNode;
icon?: ReactNode;
tone?: 'success' | 'muted';
isStacked?: boolean;
}) {
return (
<div className="drawer-row" {...(isStacked ? { 'data-stacked': 'true' } : {})}>
<span className="drawer-row-label">
{icon}
{label}
</span>
<span className="drawer-row-value" {...(tone ? { 'data-tone': tone } : {})}>
{value}
</span>
</div>
);
}
/* ── Metric grid ─────────────────────────────────────────────────────────── */
export function Metrics({ cols, children }: { cols?: 2 | 3 | 4; children: ReactNode }) {
return (
<div className="drawer-metrics" {...(cols && cols !== 3 ? { 'data-cols': String(cols) } : {})}>
{children}
</div>
);
}
export function Metric({
label,
value,
note,
isSmall,
}: {
label: string;
value: ReactNode;
note?: ReactNode;
isSmall?: boolean;
}) {
return (
<div className="drawer-metric">
<span className="drawer-metric-label">{label}</span>
<span className="drawer-metric-value" {...(isSmall ? { 'data-size': 'sm' } : {})}>
{value}
</span>
{note ? <span className="drawer-metric-note">{note}</span> : null}
</div>
);
}
/* ── Status badge ────────────────────────────────────────────────────────── */
/**
* One badge shape for every status in the console.
*
* The colour is passed in rather than derived here, because the status
* vocabularies already live in `orderStatus.ts` — orders and deliveries use
* different words, and duplicating that mapping is how the two drift apart.
*/
export function Badge({ label, colour }: { label: string; colour: string }) {
return (
<span
className="drawer-badge"
style={{ color: colour, background: `color-mix(in oklab, ${colour} 12%, transparent)` }}
>
{label}
</span>
);
}
/* ── Note ────────────────────────────────────────────────────────────────── */
export function Note({
children,
tone,
icon,
}: {
children: ReactNode;
tone?: 'brand';
icon?: ReactNode;
}) {
return (
<div className="drawer-note" {...(tone ? { 'data-tone': tone } : {})}>
{icon ?? <Info size={15} />}
<span>{children}</span>
</div>
);
}
/* ── Buttons ─────────────────────────────────────────────────────────────── */
export function DrawerButton({
label,
variant = 'secondary',
icon,
isDisabled,
onClick,
type = 'button',
}: {
label: string;
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
icon?: ReactNode;
isDisabled?: boolean;
onClick?: () => void;
type?: 'button' | 'submit';
}) {
return (
<button
type={type === 'submit' ? 'submit' : 'button'}
className="drawer-btn"
data-variant={variant}
disabled={isDisabled}
{...(onClick ? { onClick } : {})}
>
{icon}
{label}
</button>
);
}
/** A text-weight action inside a section rather than in the footer. */
export function LinkAction({
label,
icon,
onClick,
isDisabled,
}: {
label: string;
icon?: ReactNode;
onClick: () => void;
isDisabled?: boolean;
}) {
return (
<button type="button" className="drawer-link-action" onClick={onClick} disabled={isDisabled}>
{icon}
{label}
</button>
);
}
/* ── Bullets ─────────────────────────────────────────────────────────────── */
export function Bullets({ items }: { items: readonly string[] }) {
return (
<ul className="drawer-bullet-list">
{items.map((item) => (
<li key={item} className="drawer-bullet">
<span>{item}</span>
</li>
))}
</ul>
);
}

View File

@@ -1,8 +1,10 @@
import { useState } from 'react';
import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader';
import { useAuth } from '@/auth/AuthContext';
import { useBranchScope } from '../BranchScope';
import { UploadsPanel } from '@/features/uploads/UploadsPanel';
import { SheetUploadDrawer } from '../SheetUploadDrawer';
/**
* This merchant's uploads.
@@ -19,6 +21,7 @@ import { UploadsPanel } from '@/features/uploads/UploadsPanel';
export function UploadsPage() {
const { user } = useAuth();
const { selected } = useBranchScope();
const [isUploading, setUploading] = useState(false);
return (
<VStack gap={3}>
@@ -28,8 +31,19 @@ export function UploadsPage() {
/>
<UploadsPanel
tenantid={user?.tenantid ?? 0}
onUpload={() => setUploading(true)}
{...(selected === null ? {} : { locationid: selected })}
/>
{/* The same drawer Inventory opens. Uploading from the page that lists
uploads is the obvious place for it, and routing somebody to another
screen to press a second button is not a flow, it is a detour. */}
{isUploading ? (
<SheetUploadDrawer
tenantid={user?.tenantid}
locationid={selected ?? user?.locationid}
onClose={() => setUploading(false)}
/>
) : null}
</VStack>
);
}