drawer and upload
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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’ 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’ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
575
src/features/store-admin/drawer.css
Normal file
575
src/features/store-admin/drawer.css
Normal 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); }
|
||||
224
src/features/store-admin/drawerKit.tsx
Normal file
224
src/features/store-admin/drawerKit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user