drawer and upload

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

View File

@@ -1,17 +1,18 @@
import { useEffect, useState } from 'react';
import { Banner } from '@astryxdesign/core/Banner';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { Divider } from '@astryxdesign/core/Divider';
import { HStack } from '@astryxdesign/core/HStack';
import { Lightbox } from '@astryxdesign/core/Lightbox';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { Token } from '@astryxdesign/core/Token';
import { VStack } from '@astryxdesign/core/VStack';
import { Check, DownloadCloud, ImageOff, Info } from 'lucide-react';
import { Check, DownloadCloud, ImageOff } from 'lucide-react';
import type { CatalogueProduct } from '@/api/types';
import { Drawer } from '@/features/store-admin/Drawer';
import {
Badge,
Bullets,
DrawerButton,
DrawerCard,
Row,
Section,
} from '@/features/store-admin/drawerKit';
import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel';
/**
@@ -89,29 +90,35 @@ export function CatalogueDetailDrawer({
<Drawer
title={product.product_name}
subtitle={`${brand} · catalogue id ${product.id}`}
titleAside={isImported ? <Token label="In your list" size="sm" color="green" /> : undefined}
width={540}
onClose={onClose}
{...(isImported ? { meta: <Badge label="In your list" colour="#0f8a5f" /> } : {})}
{...(!isImported && !blockedReason && onImport
? {
isFooterFilled: true,
footer: (
<DrawerButton
label={isBusy ? 'Adding…' : actionLabel}
variant="primary"
icon={<DownloadCloud size={15} />}
isDisabled={Boolean(isBusy)}
onClick={onImport}
/>
),
}
: {})}
>
{/* The photograph, at the size a label can be read at. Click to zoom —
at card size the ingredients and the net weight are not legible. */}
<Card padding={0} elevation="none">
<Section>
<button
type="button"
className="drawer-figure"
data-tall="true"
aria-label="Open photo full size"
onClick={() => setIsZoomed(true)}
disabled={!hero || failed}
style={{
display: 'block',
width: '100%',
height: 280,
padding: 0,
border: '1px solid var(--color-line)',
borderRadius: 16,
background: 'var(--color-surface)',
cursor: hero && !failed ? 'zoom-in' : 'default',
overflow: 'hidden',
}}
style={{ width: '100%', cursor: hero && !failed ? 'zoom-in' : 'default' }}
>
{hero && !failed ? (
<img
@@ -119,91 +126,71 @@ export function CatalogueDetailDrawer({
alt={product.product_name}
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
mixBlendMode: 'multiply',
}}
style={{ mixBlendMode: 'multiply' }}
/>
) : (
<ImageOff size={40} style={{ color: 'var(--color-line)' }} />
)}
</button>
</Card>
{images.length > 1 ? (
<VStack gap={0.5}>
<HStack gap={0.5} align="center">
<span style={{ color: 'var(--color-ink-4)', display: 'flex' }}>
<Info size={12} />
</span>
<Text type="body" size="xsm" color="secondary">
{images.length > 1 ? (
<>
<div className="drawer-thumbs">
{images.map((src, index) => (
<button
type="button"
// Index, not the URL: a catalogue row can list the same photo
// twice, and a duplicate key makes React drop one thumbnail
// and mis-track the rest as you page through them.
key={`${index}-${src}`}
className="drawer-thumb"
data-active={index === heroAt}
aria-label={`Show photo ${index + 1}`}
onClick={() => (index === heroAt ? setIsZoomed(true) : setHeroAt(index))}
>
<img src={src} alt="" referrerPolicy="no-referrer" />
</button>
))}
</div>
<span style={{ font: '400 12.5px/1.4 var(--font-sans)', color: 'var(--color-ink-4)' }}>
{images.length} photos — only the first is imported
</Text>
</HStack>
<div style={{ display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 4 }}>
{images.map((src, index) => (
<button
type="button"
// Index, not the URL: a catalogue row can list the same photo
// twice, and a duplicate key makes React drop one thumbnail and
// mis-track the rest as you page through them.
key={`${index}-${src}`}
aria-label={`Show photo ${index + 1}`}
onClick={() => (index === heroAt ? setIsZoomed(true) : setHeroAt(index))}
style={{
width: 58,
height: 58,
flex: 'none',
padding: 2,
borderRadius: 10,
background: 'var(--color-surface)',
border:
index === heroAt
? '2px solid var(--color-brand)'
: '1px solid var(--color-line)',
opacity: index === heroAt ? 1 : 0.6,
cursor: 'pointer',
}}
>
<img
src={src}
alt=""
referrerPolicy="no-referrer"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</button>
))}
</div>
</VStack>
) : null}
</span>
</>
) : null}
</Section>
{/* A RANGE, not a price. What the shop charges is set after the import,
and conflating the two is how a catalogue figure ends up on a shelf. */}
<Card padding={0} elevation="low">
<HStack justify="between" align="center" gap={2} padding={2}>
<VStack gap={0}>
<Text type="label" size="xsm" color="secondary">
MARKET PRICE RANGE
</Text>
<Text type="large" weight="semibold" hasTabularNumbers>
<DrawerCard tone="brand">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '14px 16px',
}}
>
<span style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span className="drawer-metric-label">Market price range</span>
<span
style={{
font: '600 22px/1.2 var(--font-sans)',
color: 'var(--color-ink-1)',
fontVariantNumeric: 'tabular-nums',
}}
>
{product.price_range ?? '—'}
</Text>
</VStack>
{product.size ? <Token label={product.size} size="md" /> : null}
</HStack>
</Card>
</span>
</span>
{product.size ? <Badge label={product.size} colour="var(--color-brand)" /> : null}
</div>
</DrawerCard>
{product.description ? (
<VStack gap={0.5}>
<Text type="label" size="xsm" color="secondary">
DESCRIPTION
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{product.description}
</Text>
</VStack>
<Section title="Description">
<p className="drawer-prose">{product.description}</p>
</Section>
) : null}
{/* ── Health score ──────────────────────────────────────────────────
@@ -215,80 +202,50 @@ export function CatalogueDetailDrawer({
`brand` and `image_id` come straight off the catalogue row, so no
lookup is needed to find the key. */}
<HealthScorePanel product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }} />
<HealthScorePanel
product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }}
/>
{product.highlights?.length || product.nutrients?.length ? (
<div className="form-grid">
<div className="drawer-bullets">
{product.highlights?.length ? (
<VStack gap={0.5}>
<Text type="label" size="xsm" color="secondary">
HIGHLIGHTS
</Text>
<VStack gap={0}>
{product.highlights.map((line, index) => (
<Text
key={`${index}-${line}`}
type="body"
size="xsm"
color="secondary"
style={{ lineHeight: 1.6 }}
>
• {line}
</Text>
))}
</VStack>
</VStack>
<div className="drawer-section">
<h4 className="drawer-section-title">Highlights</h4>
<Bullets items={product.highlights} />
</div>
) : null}
{product.nutrients?.length ? (
<VStack gap={0.5}>
<Text type="label" size="xsm" color="secondary">
NUTRITION
</Text>
<VStack gap={0}>
{product.nutrients.map((line, index) => (
<Text
key={`${index}-${line}`}
type="body"
size="xsm"
color="secondary"
style={{ lineHeight: 1.6 }}
>
• {line}
</Text>
))}
</VStack>
</VStack>
<div className="drawer-section">
<h4 className="drawer-section-title">Nutrition</h4>
<Bullets items={product.nutrients} />
</div>
) : null}
</div>
) : null}
{facts.length > 0 ? (
<>
<Divider />
<VStack gap={1}>
<Section title="Catalogue record">
<DrawerCard>
{facts.map((fact) => (
<HStack key={fact.label} justify="between" align="start" gap={2}>
<Text type="body" size="xsm" color="secondary" style={{ flex: 'none' }}>
{fact.label}
</Text>
<Text
type="body"
size="xsm"
style={{
textAlign: 'right',
...(fact.isMono ? { fontFamily: 'var(--font-mono)' } : {}),
}}
>
{fact.value}
</Text>
</HStack>
<Row
key={fact.label}
label={fact.label}
value={
fact.isMono ? (
<span style={{ fontFamily: 'var(--font-mono)' }}>{fact.value}</span>
) : (
fact.value
)
}
/>
))}
</VStack>
</>
</DrawerCard>
</Section>
) : null}
{/* The action, last, because everything above is what the decision is
made on. */}
made on. The button itself lives in the fixed bar; what stays here is
the choice it depends on and the warning about what it does not do. */}
{isImported ? (
<Banner
status="success"
@@ -299,42 +256,36 @@ export function CatalogueDetailDrawer({
) : blockedReason ? (
<Banner status="warning" title="Cannot import yet" description={blockedReason} />
) : onImport ? (
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
{categoryOptions ? (
<Selector
label="Import into"
size="sm"
options={categoryOptions}
value={categoryid}
onChange={(value) => onCategoryChange?.(value)}
/* No "leave uncategorised" placeholder, and the advice that
used to sit here — "uncategorised beats wrongly categorised"
— is gone because it is false. A product filed under
category 0 is returned by NO query the customer app makes:
its endpoint rejects categoryid 0 outright. Seven products
across three tenants sat invisible that way, six outlets
showing shoppers an empty shop, while the console listed
their stock as normal. A wrongly filed product is at least
findable and fixable; an unfiled one was neither. */
placeholder="Choose a category"
description="Your own category, not the catalogue's. A product with no category cannot appear in the customer app at all."
/>
) : null}
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
Adds this product with no price. It reaches no shop and cannot be sold until you
price and publish it.
</Text>
<Button
label={isBusy ? 'Adding…' : actionLabel}
variant="primary"
icon={<DownloadCloud size={14} />}
width="full"
isDisabled={Boolean(isBusy)}
onClick={onImport}
/>
</VStack>
</Card>
<Section title="Import into my products">
<DrawerCard>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 14 }}>
{categoryOptions ? (
<Selector
label="Category"
size="sm"
options={categoryOptions}
value={categoryid}
onChange={(value) => onCategoryChange?.(value)}
/* No "leave uncategorised" placeholder, and the advice that
used to sit here — "uncategorised beats wrongly categorised"
— is gone because it is false. A product filed under
category 0 is returned by NO query the customer app makes:
its endpoint rejects categoryid 0 outright. Seven products
across three tenants sat invisible that way, six outlets
showing shoppers an empty shop, while the console listed
their stock as normal. A wrongly filed product is at least
findable and fixable; an unfiled one was neither. */
placeholder="Choose a category"
description="Your own category, not the catalogue's. A product with no category cannot appear in the customer app at all."
/>
) : null}
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
Adds this product with no price. It reaches no shop and cannot be sold until you
price and publish it.
</span>
</div>
</DrawerCard>
</Section>
) : null}
{/* Gallery mode, driven by the same index the thumbnails set — so

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { VStack } from '@astryxdesign/core/VStack';
import { PackageSearch, Upload } from 'lucide-react';
@@ -29,7 +29,23 @@ import { SheetImportPanel } from '../import/SheetImportPanel';
* same catalogue.
*/
export function GlobalCataloguePage() {
const [mode, setMode] = useState<'catalogue' | 'sheet'>('catalogue');
/*
The tab is in the URL so it can be linked to.
Uploads has an "Upload spreadsheet" button and this is where that action
lives for a platform operator — it writes the GLOBAL catalogue, which is not
the same action as a merchant uploading their own list. Held in local state it
could only be reached by landing on Browse and pressing a second control,
which is a detour rather than a flow.
*/
const [params, setParams] = useSearchParams();
const mode = params.get('tab') === 'sheet' ? 'sheet' : 'catalogue';
const setMode = (next: 'catalogue' | 'sheet') => {
const merged = new URLSearchParams(params);
if (next === 'catalogue') merged.delete('tab');
else merged.set('tab', next);
setParams(merged, { replace: true });
};
return (
<VStack gap={3}>

View File

@@ -1,3 +1,4 @@
import { useNavigate } from 'react-router-dom';
import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader';
import { UploadsPanel } from '@/features/uploads/UploadsPanel';
@@ -11,13 +12,18 @@ import { UploadsPanel } from '@/features/uploads/UploadsPanel';
* merchant name on each row when it is not given one.
*/
export function UploadsPage() {
const navigate = useNavigate();
return (
<VStack gap={3}>
<PageHeader
title="Uploads"
description="Spreadsheets sent to the catalogue service, across every merchant — what each one added, and whether it reached a shelf."
/>
<UploadsPanel />
{/* A platform operator's upload writes the GLOBAL catalogue, which is a
different action from a merchant sending their own list — so this goes
to the page that owns it rather than opening a merchant's drawer. */}
<UploadsPanel onUpload={() => navigate('/nearle/catalogue?tab=sheet')} />
</VStack>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,13 @@
import { useEffect } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Check, MapPin, Phone, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { ArrowDown, Bike, Check, MapPin, Phone, X } from 'lucide-react';
import type { DeliveryRow, OrderRow } from '@/api/types';
import { DeliveryProgress } from './DeliveryProgress';
import { useDeliveryMoves } from './DeliveryProgress';
import { Drawer } from './Drawer';
import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit';
import { branchLabel, moneyExact } from './format';
import { DELIVERY_STATUS, ORDER_STATUS, orderQuantity, orderValue, statusColor } from './orderStatus';
type Row = OrderRow | DeliveryRow;
type Row_ = OrderRow | DeliveryRow;
export type RowKind = 'order' | 'delivery';
@@ -44,11 +42,57 @@ export function OrderDetailDrawer({
kind,
onClose,
}: {
row: Row;
row: Row_;
kind: RowKind;
onClose: () => void;
}) {
const delivery = kind === 'delivery';
/*
Split before the hook, not inside it.
The moves hook drives both the footer buttons and the caption that
reports their errors, and it can only be called when there IS a delivery.
Calling it twice — once per renderer — would give each its own pending state
and its own error, so a failed write would be reported where nobody is
looking. One instance, passed to both.
*/
return kind === 'delivery' ? (
<DeliveryDrawer job={row as DeliveryRow} onClose={onClose} />
) : (
<Sheet row={row} isDelivery={false} onClose={onClose} />
);
}
function DeliveryDrawer({ job, onClose }: { job: DeliveryRow; onClose: () => void }) {
const moves = useDeliveryMoves(job);
return (
<Sheet
row={job}
isDelivery
onClose={onClose}
{...(moves.isSettled ? {} : { footer: <MoveActions moves={moves} /> })}
caption={<MoveCaption moves={moves} />}
/>
);
}
type Moves = ReturnType<typeof useDeliveryMoves>;
function Sheet({
row,
isDelivery,
onClose,
footer,
caption,
}: {
row: Row_;
/** Passed explicitly, never inferred from the presence of a footer — a
settled job has no actions and would otherwise read as an order. */
isDelivery: boolean;
onClose: () => void;
footer?: ReactNode;
caption?: ReactNode;
}) {
const delivery = isDelivery;
// Narrowed once, here. `kind` comes from the table the row was clicked in, so
// it is authoritative in a way that sniffing fields is not — a delivery row
// carries an `orderheaderid` too, which is what made the first attempt at
@@ -56,16 +100,6 @@ export function OrderDetailDrawer({
const job = delivery ? (row as DeliveryRow) : undefined;
const order = delivery ? undefined : (row as OrderRow);
// Escape closes. A dialog that can only be dismissed by finding the scrim
// with a mouse is not dismissible for anyone on a keyboard, and this one sits
// over the list an operator is working through.
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose]);
const title = row.orderid || (job ? `DLV-${job.deliveryid}` : `#${order?.orderheaderid}`);
const status = row.orderstatus;
const colour = statusColor(delivery ? DELIVERY_STATUS : ORDER_STATUS, status);
@@ -74,234 +108,187 @@ export function OrderDetailDrawer({
const cancelled = (status ?? '').toLowerCase().includes('cancel');
return (
<div
role="dialog"
aria-modal="true"
aria-label={`Order ${title}`}
style={{ position: 'fixed', inset: 0, zIndex: 70, display: 'flex', justifyContent: 'flex-end' }}
<Drawer
title={title}
isTitleMono
onClose={onClose}
meta={
<>
<Badge label={status || '—'} colour={colour} />
<span className="drawer-meta-text">{branchLabel(row.locationname)}</span>
</>
}
{...(footer ? { footer } : {})}
>
<button
type="button"
aria-label="Close"
onClick={onClose}
style={{
position: 'absolute',
inset: 0,
border: 0,
background: 'rgb(15 23 42 / .32)',
cursor: 'pointer',
}}
/>
<div
className="dialog-panel"
style={{
position: 'relative',
width: 460,
maxWidth: '92vw',
height: '100%',
overflowY: 'auto',
background: 'var(--color-surface)',
borderLeft: '1px solid var(--color-line)',
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
}}
>
<HStack
justify="between"
align="start"
gap={1}
style={{
position: 'sticky',
top: 0,
zIndex: 1,
padding: 20,
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-line)',
}}
>
<VStack gap={0.5}>
<Text
type="label"
size="lg"
weight="semibold"
style={{ fontFamily: 'var(--font-mono)' }}
>
{title}
</Text>
<HStack gap={1} align="center">
<span
style={{
display: 'inline-flex',
borderRadius: 999,
padding: '2px 9px',
fontSize: 11.5,
fontWeight: 600,
textTransform: 'capitalize',
color: colour,
background: `color-mix(in oklab, ${colour} 12%, transparent)`,
}}
>
{status || '—'}
</span>
<Text type="body" size="xsm" color="secondary">
{branchLabel(row.locationname)}
</Text>
</HStack>
</VStack>
<button
type="button"
aria-label="Close"
onClick={onClose}
style={{
width: 30,
height: 30,
borderRadius: 10,
border: 0,
flex: 'none',
display: 'grid',
placeItems: 'center',
background: 'var(--color-surface-sunken)',
color: 'var(--color-ink-3)',
cursor: 'pointer',
}}
>
<X size={15} />
</button>
</HStack>
{/* ── Money ─────────────────────────────────────────────────────────
Three equal columns, the shape the spec asks for and the one that
answers "what is this worth, who pays what, how much of it". */}
<Metrics>
<Metric label="Value" value={value > 0 ? moneyExact(value) : '—'} />
{job ? (
<Metric
label="Rider charge"
value={job.deliverycharges ? moneyExact(job.deliverycharges) : '—'}
isSmall
/>
) : (
<Metric
label="Cash to collect"
value={order?.collectionamt ? moneyExact(order.collectionamt) : '—'}
isSmall
/>
)}
<Metric
label="Items"
value={job ? String(job.itemcount ?? '—') : String(orderQuantity(row) || '—')}
isSmall
/>
</Metrics>
<VStack gap={3} padding={2}>
{/* ── Money ─────────────────────────────────────────────────── */}
<div className="form-grid">
<Figure label="Value" value={value > 0 ? moneyExact(value) : '—'} isStrong />
{job ? (
<Figure
label="Rider charge"
value={job.deliverycharges ? moneyExact(job.deliverycharges) : '—'}
/>
) : (
<Figure
label="Cash to collect"
value={order?.collectionamt ? moneyExact(order.collectionamt) : '—'}
/>
)}
<Figure
label="Items"
value={job ? String(job.itemcount ?? '—') : String(orderQuantity(row) || '—')}
/>
<Section title="Order journey">
<Timeline row={row} isCancelled={cancelled} />
</Section>
<Section title="Route">
<DrawerCard>
<Party
kind="Pickup"
name={row.pickupcustomer || branchLabel(row.locationname)}
detail={
job
? job.pickupsuburb || job.pickuplocation || job.Pickupaddress || job.pickupaddress
: order?.pickupaddress || order?.pickupsuburb
}
phone={row.pickupcontactno}
/>
<div className="drawer-party-link" aria-hidden>
<ArrowDown size={13} />
</div>
<Party
kind="Drop"
name={row.deliverycustomer}
detail={row.deliveryaddress || row.deliverysuburb}
phone={row.deliverycontactno}
/>
</DrawerCard>
</Section>
{/* ── Journey ───────────────────────────────────────────────── */}
<VStack gap={1}>
<Label>Journey</Label>
<Timeline row={row} isCancelled={cancelled} />
</VStack>
{/* ── Pickup and drop ───────────────────────────────────────── */}
<VStack gap={1}>
<Label>Route</Label>
<Party
icon={<MapPin size={13} />}
heading="Pickup"
name={row.pickupcustomer || branchLabel(row.locationname)}
detail={
job
? job.pickupsuburb || job.pickuplocation || job.Pickupaddress || job.pickupaddress
: order?.pickupaddress || order?.pickupsuburb
}
phone={row.pickupcontactno}
{/* ── Rider ─────────────────────────────────────────────────────────── */}
{job ? (
<Section title="Rider">
{job.ridername ? (
<DrawerCard>
<div className="drawer-party">
<span className="drawer-party-icon">
<Bike size={15} />
</span>
<div className="drawer-party-body">
<span className="drawer-party-name">{job.ridername}</span>
{job.ridercontact ? (
<a className="drawer-party-phone" href={`tel:${job.ridercontact}`}>
<Phone size={12} />
{job.ridercontact}
</a>
) : null}
</div>
</div>
{/* Planned against actual. The gap is the number worth reading —
a job quoted 3km and ridden 9 is either a bad address or a
rider taking a detour, and one figure hides both. */}
<Metrics cols={job.transitminutes ? 3 : 2}>
<Metric label="Planned" value={km(job.kms)} isSmall />
<Metric label="Actual" value={km(job.actualkms || job.riderkms)} isSmall />
{job.transitminutes ? (
<Metric label="Transit" value={`${job.transitminutes} min`} isSmall />
) : null}
</Metrics>
</DrawerCard>
) : (
<Note>No rider assigned yet.</Note>
)}
{/* The caption for the footer's actions. It stays in the body while
the buttons sit in the bar — it explains when to use them, which
is reading matter, not a control. */}
{caption}
</Section>
) : order?.rider ? (
<Section title="Rider">
<DrawerCard>
<Row
label="Rider"
value={order.rider}
icon={<Bike size={14} />}
/>
<Party
icon={<MapPin size={13} />}
heading="Drop"
name={row.deliverycustomer}
detail={row.deliveryaddress || row.deliverysuburb}
phone={row.deliverycontactno}
/>
</VStack>
{order.ridercontactno ? (
<Row label="Contact" value={order.ridercontactno} icon={<Phone size={14} />} />
) : null}
</DrawerCard>
</Section>
) : null}
{/* ── Rider ─────────────────────────────────────────────────── */}
{job ? (
<VStack gap={1}>
<Label>Rider</Label>
{job.ridername ? (
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
<HStack gap={1} align="center">
<span
style={{
width: 30,
height: 30,
borderRadius: 999,
flex: 'none',
display: 'grid',
placeItems: 'center',
background: 'color-mix(in oklab, #8b5cf6 14%, transparent)',
color: '#8b5cf6',
}}
>
<Bike size={14} />
</span>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{job.ridername}
</Text>
{job.ridercontact ? (
<Text type="body" size="xsm" color="secondary">
{job.ridercontact}
</Text>
) : null}
</VStack>
</HStack>
{/* Planned against actual. The gap is the number worth
reading — a job quoted 3km and ridden 9 is either a bad
address or a rider taking a detour, and one figure hides
both. */}
<HStack gap={2}>
<Figure label="Planned" value={km(job.kms)} />
<Figure label="Actual" value={km(job.actualkms || job.riderkms)} />
{job.transitminutes ? (
<Figure label="Transit" value={`${job.transitminutes} min`} />
) : null}
</HStack>
</VStack>
</Card>
) : (
<Text type="body" size="sm" color="secondary">
No rider assigned yet.
</Text>
)}
{/* Recording progress by hand. Under the rider rather than beside
the status chip at the top: it is a correction, not the normal
way a job moves, and putting it in the header would invite it
to be used as one. */}
<DeliveryProgress job={job} />
</VStack>
) : order?.rider ? (
<VStack gap={1}>
<Label>Rider</Label>
<Text type="body" size="sm">
{order.rider}
{order.ridercontactno ? ` · ${order.ridercontactno}` : ''}
</Text>
</VStack>
) : null}
{row.ordernotes || job?.notes ? (
<Section title="Notes">
<DrawerCard tone="subtle">
<Row label="Note" value={row.ordernotes || job?.notes} isStacked />
</DrawerCard>
</Section>
) : null}
{/* ── Notes ─────────────────────────────────────────────────── */}
{row.ordernotes || job?.notes ? (
<VStack gap={1}>
<Label>Notes</Label>
<Text type="body" size="sm" color="secondary">
{row.ordernotes || job?.notes}
</Text>
</VStack>
) : null}
<Note>
Line items are not in the list read — Fiesta returns them from the order-detail endpoint,
which this sheet does not call yet.
</Note>
</Drawer>
);
}
<Text type="body" size="xsm" color="secondary">
Line items are not in the list read — Fiesta returns them from the order-detail
endpoint, which this sheet does not call yet.
</Text>
</VStack>
/* ── Recording progress by hand ──────────────────────────────────────────── */
/**
* The moves, in the drawer's fixed action bar.
*
* In the footer rather than beside the status chip: it is a correction, not the
* normal way a job moves, and putting it in the header would invite it to be
* used as one. It is still the drawer's primary action, which is why it is
* pinned rather than buried at the end of a scroll.
*/
function MoveActions({ moves: state }: { moves: Moves }) {
const { isPending, moves, status, run } = state;
return (
<>
{moves.map((step) => (
<DrawerButton
key={step.to}
label={step.label}
variant={step.to === 'cancelled' ? 'danger' : step.to === 'delivered' ? 'primary' : 'secondary'}
isDisabled={isPending || step.to === status}
onClick={() => run(step.to, step.stamp)}
/>
))}
</>
);
}
function MoveCaption({ moves }: { moves: Moves }) {
const { isSettled, problem, status } = moves;
if (isSettled) {
return <Note>This job is {status}. Nothing further to record.</Note>;
}
if (problem) {
return (
<div className="drawer-note" role="alert" style={{ color: 'var(--color-error)' }}>
<X size={15} />
<span>{problem}</span>
</div>
</div>
);
}
return (
<Note>
Use the buttons below only when the rider's app has not. Marking a job delivered here also
closes the order.
</Note>
);
}
@@ -315,7 +302,7 @@ export function OrderDetailDrawer({
* have a missing stamp, and drawing that step as done would paper over the
* data-quality problem the operator opened this sheet to find.
*/
function Timeline({ row, isCancelled }: { row: Row; isCancelled: boolean }) {
function Timeline({ row, isCancelled }: { row: Row_; isCancelled: boolean }) {
const steps = STEPS.map((step) => ({
label: step.label,
// Read by name: the two row shapes do not carry the same stamps — a
@@ -326,196 +313,101 @@ function Timeline({ row, isCancelled }: { row: Row; isCancelled: boolean }) {
.find(Boolean),
}));
/* The step the job is sitting on: the first one without a stamp. Marked as
"current" rather than merely "not done", so the eye lands on where the
order actually is. A finished journey has none, which is correct. */
const current = isCancelled ? -1 : steps.findIndex((step) => !step.at);
return (
<VStack gap={0}>
<div className="drawer-timeline">
{steps.map((step, index) => {
const done = Boolean(step.at);
const isLast = index === steps.length - 1;
const isCurrent = index === current;
const isLast = index === steps.length - 1 && !isCancelled;
return (
<HStack key={step.label} gap={1.5} align="start">
<VStack gap={0} align="center" style={{ width: 20, flex: 'none' }}>
<div key={step.label} className="drawer-step">
<span className="drawer-step-rail">
<span
style={{
width: 18,
height: 18,
borderRadius: 999,
display: 'grid',
placeItems: 'center',
flex: 'none',
background: done ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
color: done ? '#fff' : 'var(--color-ink-4)',
border: done ? 0 : '1px solid var(--color-line)',
}}
className="drawer-step-dot"
data-done={done}
{...(isCurrent ? { 'data-current': 'true' } : {})}
>
{done ? <Check size={11} /> : null}
</span>
{!isLast ? (
<span
style={{
width: 2,
height: 22,
background: done ? 'var(--color-brand)' : 'var(--color-line)',
}}
/>
) : null}
</VStack>
<VStack gap={0} style={{ paddingBottom: isLast ? 0 : 8 }}>
<Text
type="body"
size="sm"
style={{ color: done ? 'var(--color-ink-1)' : 'var(--color-ink-4)' }}
{!isLast ? <span className="drawer-step-line" data-done={done} /> : null}
</span>
<span className="drawer-step-body">
<span
className="drawer-step-label"
data-done={done}
{...(isCurrent ? { 'data-current': 'true' } : {})}
>
{step.label}
</Text>
{step.at ? (
<Text type="body" size="xsm" color="secondary">
{new Date(step.at).toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})}
</Text>
) : null}
</VStack>
</HStack>
</span>
{step.at ? <span className="drawer-step-at">{when(step.at)}</span> : null}
</span>
</div>
);
})}
{isCancelled ? (
<HStack gap={1.5} align="center" style={{ paddingTop: 8 }}>
<span
style={{
width: 18,
height: 18,
borderRadius: 999,
flex: 'none',
display: 'grid',
placeItems: 'center',
background: 'color-mix(in oklab, var(--color-error, #d64545) 14%, transparent)',
color: 'var(--color-error, #d64545)',
}}
>
<X size={11} />
<div className="drawer-step">
<span className="drawer-step-rail">
<span className="drawer-step-dot" data-tone="danger">
<X size={11} />
</span>
</span>
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
Cancelled
{row.canceltime
? ` · ${new Date(row.canceltime).toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})}`
: ''}
</Text>
</HStack>
<span className="drawer-step-body">
<span className="drawer-step-label" style={{ color: 'var(--color-error)', fontWeight: 600 }}>
Cancelled
</span>
{row.canceltime ? <span className="drawer-step-at">{when(row.canceltime)}</span> : null}
</span>
</div>
) : null}
</VStack>
</div>
);
}
function Party({
icon,
heading,
kind,
name,
detail,
phone,
}: {
icon: React.ReactNode;
heading: string;
kind: string;
name: string | undefined;
detail: string | undefined;
phone: string | undefined;
}) {
return (
<HStack gap={1.5} align="start">
<span
style={{
width: 26,
height: 26,
borderRadius: 8,
flex: 'none',
display: 'grid',
placeItems: 'center',
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
}}
>
{icon}
<div className="drawer-party">
<span className="drawer-party-icon">
<MapPin size={15} />
</span>
<VStack gap={0} style={{ minWidth: 0 }}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
{heading}
</Text>
<Text type="body" size="sm" weight="semibold">
{name || '—'}
</Text>
{detail ? (
<Text type="body" size="xsm" color="secondary">
{detail}
</Text>
) : null}
<div className="drawer-party-body">
<span className="drawer-party-kind">{kind}</span>
<span className="drawer-party-name">{name || '—'}</span>
{detail ? <span className="drawer-party-detail">{detail}</span> : null}
{phone ? (
<HStack gap={0.5} align="center">
<Phone size={11} style={{ color: 'var(--color-ink-4)' }} />
<Text type="body" size="xsm" color="secondary">
{phone}
</Text>
</HStack>
<a className="drawer-party-phone" href={`tel:${phone}`}>
<Phone size={12} />
{phone}
</a>
) : null}
</VStack>
</HStack>
</div>
</div>
);
}
function Figure({
label,
value,
isStrong,
}: {
label: string;
value: string;
isStrong?: boolean;
}) {
return (
<VStack gap={0}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
{label}
</Text>
<Text
type="label"
size={isStrong ? 'lg' : 'sm'}
weight="semibold"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{value}
</Text>
</VStack>
);
}
const Label = ({ children }: { children: React.ReactNode }) => (
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
{children}
</Text>
);
const when = (at: string): string =>
new Date(at).toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const km = (value: string | undefined): string => {
const n = Number(value);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
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';
@@ -10,6 +9,7 @@ import { errorMessage } from '@/api/client';
import { stockApi } from '@/api/stock';
import type { Product } from '@/api/types';
import { Drawer } from '@/features/store-admin/Drawer';
import { DrawerButton } from '@/features/store-admin/drawerKit';
import { queryKeys } from '@/queries/keys';
/**
@@ -94,6 +94,30 @@ export function BulkRequestDialog({
subtitle={`${lines.length} product${lines.length === 1 ? '' : 's'}`}
width={520}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton
label="Cancel"
variant="ghost"
isDisabled={send.isPending}
onClick={onClose}
/>
<DrawerButton
label={
send.isPending
? 'Sending…'
: `Send ${lines.length} request${lines.length === 1 ? '' : 's'}`
}
variant="primary"
isDisabled={send.isPending || lines.length === 0}
onClick={() => {
setProblem(null);
send.mutate();
}}
/>
</>
}
>
<VStack gap={3}>
<Text type="body" size="sm" color="secondary">
@@ -162,19 +186,6 @@ export function BulkRequestDialog({
</Card>
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Button label="Cancel" variant="ghost" isDisabled={send.isPending} onClick={onClose} />
<Button
label={send.isPending ? 'Sending…' : `Send ${lines.length} request${lines.length === 1 ? '' : 's'}`}
variant="primary"
isLoading={send.isPending}
isDisabled={send.isPending || lines.length === 0}
onClick={() => {
setProblem(null);
send.mutate();
}}
/>
</HStack>
</VStack>
</Drawer>
);

View File

@@ -11,6 +11,7 @@ import { queryKeys } from '@/queries/keys';
import { money } from '@/features/store-admin/format';
import { effectivePrice, imagesOf } from '@/features/store-admin/productState';
import { Drawer } from '@/features/store-admin/Drawer';
import { DrawerButton } from '@/features/store-admin/drawerKit';
import { Thumb } from './ui';
const QUICK = [10, 25, 50, 100] as const;
@@ -69,6 +70,18 @@ export function RequestDialog({
subtitle={product.productname ?? undefined}
width={420}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<DrawerButton
label={ask.isPending ? 'Sending…' : 'Send request'}
variant="primary"
isDisabled={ask.isPending || qty < 1}
onClick={() => ask.mutate()}
/>
</>
}
>
<HStack gap={1.5} align="center">
<Thumb images={imagesOf(product)} alt={product.productname ?? ''} />
@@ -133,16 +146,6 @@ export function RequestDialog({
</Text>
) : null}
<HStack gap={1} justify="end" wrap="wrap">
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} />
<Button
label={ask.isPending ? 'Sending…' : 'Send request'}
variant="primary"
size="sm"
isDisabled={ask.isPending || qty < 1}
onClick={() => ask.mutate()}
/>
</HStack>
</Drawer>
);
}

View File

@@ -1,8 +1,10 @@
import { useState } from 'react';
import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader';
import { useAuth } from '@/auth/AuthContext';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { UploadsPanel } from '@/features/uploads/UploadsPanel';
import { SheetUploadDrawer } from '@/features/store-admin/SheetUploadDrawer';
/**
* This branch's uploads.
@@ -16,6 +18,7 @@ export function StoreUploadsPage() {
const { user } = useAuth();
const { selected } = useBranchScope();
const locationid = selected ?? user?.locationid ?? 0;
const [isUploading, setUploading] = useState(false);
return (
<VStack gap={3}>
@@ -23,7 +26,18 @@ export function StoreUploadsPage() {
title="Uploads"
description="Spreadsheets sent to the catalogue service from this branch — what each one added, and whether it reached the shelf."
/>
<UploadsPanel tenantid={user?.tenantid ?? 0} locationid={locationid} />
<UploadsPanel
tenantid={user?.tenantid ?? 0}
locationid={locationid}
onUpload={() => setUploading(true)}
/>
{isUploading ? (
<SheetUploadDrawer
tenantid={user?.tenantid}
locationid={locationid}
onClose={() => setUploading(false)}
/>
) : null}
</VStack>
);
}

View File

@@ -0,0 +1,445 @@
import type { ReactNode } from 'react';
import {
AlertTriangle,
Check,
CircleDot,
Circle,
Download,
FileSpreadsheet,
Info,
PackageCheck,
} from 'lucide-react';
import { isAwaitingReview, isSettled, type IngestBatch, type IngestProduct } from '@/api/ingest';
import { daysUntilExpiry, type UploadReceipt } from '@/api/uploads';
import { Drawer } from '@/features/store-admin/Drawer';
import {
Badge,
DrawerButton,
DrawerCard,
Metric,
Metrics,
Note,
Row,
Section,
} from '@/features/store-admin/drawerKit';
import {
fileSize,
journeyOf,
pipelineOf,
stateOf,
totalsOf,
STATE_COLOUR,
STATE_LABEL,
} from './uploadState';
import './uploads.css';
/**
* One upload, in full.
*
* A dedicated workspace rather than the generic detail drawer: the subject here
* is a FILE moving through a pipeline, which nothing else in the console is.
* So it gets a file header, a journey, a stage-by-stage view of the run, and a
* rejection panel — while keeping the console's shell, close button, sticky
* footer and card treatment, so it is still recognisably the same product.
*
* ── Everything here is read, nothing is inferred ────────────────────────────
*
* Two systems answer between them: Fiesta holds the receipt, the ingest service
* holds the run. Where neither records something — a stage time, a reason a row
* was rejected — this says so rather than filling the gap. The gaps are what
* somebody opens this drawer to find.
*/
export function UploadDetailDrawer({
receipt,
batch,
products,
showTenant,
extra,
onClose,
}: {
receipt: UploadReceipt;
batch: IngestBatch | undefined;
products: IngestProduct[];
/** Nearle Admin sees whose upload it is; a merchant already knows. */
showTenant: boolean;
/** The shelving control, when this upload can be finished from here. */
extra?: ReactNode;
onClose: () => void;
}) {
const state = stateOf(receipt, batch);
const totals = totalsOf(receipt, batch);
const journey = journeyOf(receipt, batch);
const pipeline = pipelineOf(batch);
const expiresIn = daysUntilExpiry(receipt);
const file = batch?.files?.[0];
const isRunning = Boolean(batch && !isSettled(batch) && !isAwaitingReview(batch));
const failedFiles = (batch?.files ?? []).filter((entry) => entry.status === 'failed');
const unrecognised = file?.result?.unrecognised_columns ?? [];
return (
<Drawer
title="Upload details"
width={540}
onClose={onClose}
isFooterFilled={products.length === 0}
footer={
<>
{totals.rejected > 0 && products.length > 0 ? (
<DrawerButton
label="What was rejected"
variant="ghost"
onClick={() => {
document
.getElementById('up-rejected')
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
/>
) : null}
<DrawerButton
label="Download report"
variant="secondary"
icon={<Download size={15} />}
/* Built from the manifest already in hand, not from an endpoint —
the service offers no report download, and a button that 404s is
worse than one that is honestly unavailable. */
isDisabled={products.length === 0}
onClick={() => downloadReport(receipt, products)}
/>
</>
}
>
{/* ── The file ──────────────────────────────────────────────────────── */}
<div className="up-head">
<span className="up-head-icon">
<FileSpreadsheet size={22} />
</span>
<div className="up-head-body">
<span className="up-head-name">{receipt.filename || 'Spreadsheet'}</span>
<span className="up-head-sub">
{totals.rows} row{totals.rows === 1 ? '' : 's'}
{totals.rejected > 0 ? ` · ${totals.rejected} rejected` : ''}
</span>
<div className="up-head-row">
<Badge label={STATE_LABEL[state]} colour={STATE_COLOUR[state]} />
</div>
<span className="up-head-meta">
{[
showTenant ? receipt.tenantname : null,
receipt.locationname,
receipt.uploadedname,
when(receipt.created),
]
.filter(Boolean)
.join(' · ')}
</span>
</div>
</div>
{/* ── Summary ───────────────────────────────────────────────────────── */}
<Metrics>
<Metric label="Total rows" value={totals.rows || '—'} />
<Metric label="Added to catalogue" value={totals.added} isSmall />
<Metric
label="Rejected"
value={
totals.rejected > 0 ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<AlertTriangle size={14} style={{ color: 'var(--color-error)' }} />
{totals.rejected}
</span>
) : (
0
)
}
isSmall
/>
</Metrics>
{/* ── Whatever needs saying out loud, before the detail ─────────────── */}
{expiresIn !== null ? (
<Note icon={<AlertTriangle size={15} style={{ color: 'var(--color-error)' }} />}>
{expiresIn === 0
? 'This drop has passed the seven-day review window and may already have been deleted. Re-upload it.'
: `Still waiting for review. The catalogue service deletes an unreviewed drop after seven days — ${expiresIn} left.`}
</Note>
) : null}
{batch && batch.runner === 'dagster' && !isSettled(batch) ? (
<Note icon={<AlertTriangle size={15} style={{ color: 'var(--color-error)' }} />}>
This batch was staged for the Dagster orchestrator, which is not running in production —
it will wait indefinitely rather than fail. Ask the catalogue team to resume it onto the
in-process worker.
</Note>
) : null}
{/* ── Processing, while it runs ─────────────────────────────────────── */}
{isRunning && pipeline.steps.length > 0 ? (
<Section title="Processing">
<div className="up-progress">
<div className="up-progress-track">
<div
className="up-progress-fill"
style={{ width: `${Math.round((pipeline.done / Math.max(pipeline.total, 1)) * 100)}%` }}
/>
</div>
<span className="up-progress-text">
{pipeline.done} / {pipeline.total} completed
</span>
</div>
<DrawerCard>
<div className="up-pipeline">
{pipeline.steps.map((step) => (
<div
key={step.index}
className="up-stage"
data-done={step.isDone}
{...(step.isCurrent ? { 'data-current': 'true' } : {})}
>
<span className="up-stage-icon">
{step.isDone ? (
<Check size={14} />
) : step.isCurrent ? (
<CircleDot size={14} />
) : (
<Circle size={13} />
)}
</span>
<span className="up-stage-name">{step.name}</span>
<span className="up-stage-count">{step.progress}</span>
</div>
))}
</div>
</DrawerCard>
</Section>
) : null}
{/* ── The journey ───────────────────────────────────────────────────── */}
<Section title="Upload journey">
<div className="up-journey">
{journey.map((step, index) => (
<div key={step.label} className="up-step">
<span className="up-step-rail">
<span
className="up-dot"
data-done={step.isDone}
{...(step.isCurrent ? { 'data-current': 'true' } : {})}
{...(step.isFinal ? { 'data-final': 'true' } : {})}
>
{step.isDone ? <Check size={12} /> : null}
</span>
{index < journey.length - 1 ? (
<span className="up-line" data-done={step.isDone} />
) : null}
</span>
<span className="up-step-body">
<span
className="up-step-label"
data-done={step.isDone}
{...(step.isCurrent ? { 'data-current': 'true' } : {})}
>
{step.label}
</span>
{step.at ? <span className="up-step-at">{when(step.at)}</span> : null}
</span>
</div>
))}
</div>
</Section>
{/* ── File details ──────────────────────────────────────────────────── */}
<Section title="File details">
<DrawerCard>
<Row label="File name" value={receipt.filename || '—'} />
<Row label="Size" value={fileSize(file?.size_bytes)} />
<Row label="Rows" value={totals.rows || '—'} />
<Row label="Added" value={totals.added} />
{totals.backfilled > 0 ? <Row label="Filled in" value={totals.backfilled} /> : null}
{totals.alreadyThere > 0 ? (
<Row label="Already there" value={totals.alreadyThere} />
) : null}
<Row
label="Rejected"
value={totals.rejected}
{...(totals.rejected > 0 ? {} : { tone: 'muted' as const })}
/>
<Row label="Sent as" value={receipt.sender || '—'} />
<Row label="Batch" value={<Mono>{receipt.batchid}</Mono>} />
{receipt.runid ? <Row label="Run" value={<Mono>{receipt.runid}</Mono>} /> : null}
</DrawerCard>
</Section>
{/* ── Rejections ────────────────────────────────────────────────────── */}
{totals.rejected > 0 ? (
<Section title="Rejected">
<div className="up-reject" id="up-rejected">
<div className="up-reject-head">
<AlertTriangle size={18} style={{ color: 'var(--color-error)' }} />
<span className="up-reject-count">
{totals.rejected} row{totals.rejected === 1 ? '' : 's'}
</span>
</div>
<div className="up-reject-body">
{/*
The service returns a COUNT and nothing else — no row numbers and
no reasons. Listing invented ones would be worse than useless
here, because somebody would go and look for row 4. What it does
give is the headers it could not read, which is the most common
cause, so that is offered instead.
*/}
The catalogue service reports how many rows it rejected, but not which ones or why.
{unrecognised.length > 0 ? (
<>
{' '}
It did not recognise these column headings, which is the usual cause:
<ul>
{unrecognised.map((column) => (
<li key={column}>
<code>{column}</code>
</li>
))}
</ul>
</>
) : (
' Check the sheet for blank product names, missing brands and malformed prices.'
)}
</div>
</div>
</Section>
) : null}
{/* Files the service refused, named rather than counted — "1 of 2 failed"
does not say which one to resend. */}
{failedFiles.map((entry) => (
<Note key={entry.index} icon={<AlertTriangle size={15} style={{ color: 'var(--color-error)' }} />}>
{entry.filename} could not be read — {entry.detail ?? 'no reason given'}
</Note>
))}
{/* ── What it did ───────────────────────────────────────────────────── */}
{batch?.brands?.length ? (
<Section title="Brands touched">
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{batch.brands.map((brand) => (
<Badge key={brand} label={brand} colour="var(--color-brand)" />
))}
</div>
</Section>
) : null}
{products.length > 0 ? (
<Section title={`${products.length} product${products.length === 1 ? '' : 's'} in the catalogue`}>
<Note>
“Already there” is a success, not a failure — re-sending a sheet writes nothing.
</Note>
<DrawerCard>
{products.slice(0, 100).map((product) => (
<Row
key={product.image_id}
label={
<span style={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0 }}>
<span style={{ color: 'var(--color-ink-1)', fontWeight: 500 }}>
{product.product_name}
</span>
<Mono>
{product.product_sku ?? product.image_id}
{product.sku_source === 'Internal' ? ' · SKU minted by the service' : ''}
</Mono>
</span>
}
value={DISPOSITION[product.disposition] ?? product.disposition}
tone={product.disposition === 'unchanged' ? 'muted' : undefined}
/>
))}
</DrawerCard>
{products.length > 100 ? (
<span className="up-head-meta">Showing the first 100 of {products.length}.</span>
) : null}
</Section>
) : batch && isSettled(batch) ? (
<Note>The run reported no products for this file.</Note>
) : null}
{/* ── On the shelf ──────────────────────────────────────────────────── */}
{receipt.shelvedat ? (
<Note icon={<PackageCheck size={15} style={{ color: 'var(--color-success)' }} />}>
{receipt.shelvedcount} priced and stocked at {receipt.locationname || 'this branch'} on{' '}
{when(receipt.shelvedat)}
{receipt.skippedcount > 0 ? `, ${receipt.skippedcount} left out` : ''}.
</Note>
) : null}
{/* Finishing the job — the step the ingest service cannot do. */}
{extra}
{state === 'done' || state === 'partial' ? (
<Note tone="brand" icon={<Info size={15} />}>
This file has been processed.{' '}
{totals.rejected > 0
? `${totals.rejected} row${totals.rejected === 1 ? ' was' : 's were'} rejected because of errors in the data.`
: 'Every row was accepted.'}
</Note>
) : null}
</Drawer>
);
}
const DISPOSITION: Record<string, string> = {
inserted: 'Added',
backfilled: 'Filled in',
unchanged: 'Already there',
};
function Mono({ children }: { children: ReactNode }) {
return (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-ink-4)' }}>
{children}
</span>
);
}
function when(value: string | null | undefined): string {
if (!value) return '';
const at = new Date(value);
if (Number.isNaN(at.getTime())) return '';
return at.toLocaleString(undefined, {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
/**
* The report, built here from what the run already told us.
*
* There is no report endpoint on the ingest service. Rather than link to one
* that does not exist, this writes the manifest the drawer is already showing —
* every product and what the run did to it — as a CSV. It is the same data,
* in the form somebody can take to a spreadsheet and work through.
*/
function downloadReport(receipt: UploadReceipt, products: IngestProduct[]): void {
const escape = (value: string) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const lines = [
['product_name', 'sku', 'sku_source', 'disposition', 'image_id'].join(','),
...products.map((product) =>
[
escape(product.product_name),
escape(product.product_sku ?? ''),
escape(product.sku_source ?? ''),
escape(product.disposition),
escape(product.image_id),
].join(','),
),
];
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${(receipt.filename || 'upload').replace(/\.[^.]+$/, '')}-report.csv`;
link.click();
// Revoked on the next tick — released immediately, the click may not have
// read it yet in some browsers.
setTimeout(() => URL.revokeObjectURL(url), 0);
}

View File

@@ -28,35 +28,44 @@
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Badge } from '@astryxdesign/core/Badge';
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 { AlertTriangle, CheckCircle2, Clock, PackageCheck, RefreshCw, XCircle } from 'lucide-react';
import { FileSpreadsheet, RefreshCw, UploadCloud } from 'lucide-react';
import { errorMessage } from '@/api/client';
import {
currentStage,
fetchBatch,
isAwaitingReview,
isDismissed,
isSettled,
ACCEPTED_EXTENSIONS,
isStuckOnMissingRunner,
productsOf,
resolveBatch,
releasedRunId,
type IngestBatch,
type IngestProduct,
} from '@/api/ingest';
import { daysUntilExpiry, uploadsApi, type UploadReceipt } from '@/api/uploads';
import { uploadsApi, type UploadReceipt } from '@/api/uploads';
import type { SheetProductRow } from '@/api/products';
import { readStoredRows } from './shelveRows';
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet';
import { shelveBatch } from '@/features/nearle-admin/import/shelve';
import { SheetDropzone } from '@/components/SheetDropzone';
import { SectionHeader } from '@/components/SectionHeader';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { UploadDetailDrawer } from './UploadDetailDrawer';
import {
matchesDate,
matchesSearch,
matchesTab,
stateOf,
totalsOf,
STATE_COLOUR,
STATE_LABEL,
type UploadTab,
} from './uploadState';
import './uploads.css';
export interface UploadsPanelProps {
/**
@@ -66,6 +75,14 @@ export interface UploadsPanelProps {
tenantid?: number;
/** Narrow to one branch. A Store user is pinned to theirs. */
locationid?: number;
/**
* Opens this workspace's upload flow.
*
* Passed in rather than decided here: the three workspaces upload to two
* different places — a merchant's own product list, or the global catalogue —
* and a workspace with no upload route gets no button rather than a dead one.
*/
onUpload?: () => void;
}
/** The live reading for one receipt, once we have been able to take one. */
@@ -75,11 +92,15 @@ interface LiveReading {
products: IngestProduct[];
}
export function UploadsPanel({ tenantid, locationid }: UploadsPanelProps) {
export function UploadsPanel({ tenantid, locationid, onUpload }: UploadsPanelProps) {
const [receipts, setReceipts] = useState<UploadReceipt[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [live, setLive] = useState<Record<string, LiveReading>>({});
const [expanded, setExpanded] = useState<string | null>(null);
const [detail, setDetail] = useState<string | null>(null);
const [tab, setTab] = useState<UploadTab>('all');
const [search, setSearch] = useState('');
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [isRefreshing, setIsRefreshing] = useState(false);
const load = useCallback(async () => {
@@ -162,6 +183,34 @@ export function UploadsPanel({ tenantid, locationid }: UploadsPanelProps) {
}, [receipts, refreshLive]);
const rows = useMemo(() => receipts ?? [], [receipts]);
const showTenant = !tenantid;
/* ── The toolbar's state ──────────────────────────────────────────────── */
const shown = useMemo(() => {
return rows.filter((receipt) => {
const state = stateOf(receipt, live[receipt.batchid]?.batch);
return (
matchesTab(state, tab) && matchesSearch(receipt, search) && matchesDate(receipt, from, to)
);
});
}, [rows, live, tab, search, from, to]);
/* Counts are of everything, not of what is on screen: a tab that reported the
size of its own filtered view would always read the same as the list under
it and could never tell you there is a failure worth looking at. */
const counts = useMemo(() => {
const out = { all: rows.length, done: 0, failed: 0, running: 0 };
for (const receipt of rows) {
const state = stateOf(receipt, live[receipt.batchid]?.batch);
if (matchesTab(state, 'done')) out.done += 1;
if (matchesTab(state, 'failed')) out.failed += 1;
if (matchesTab(state, 'running')) out.running += 1;
}
return out;
}, [rows, live]);
const open = shown.find((receipt) => receipt.batchid === detail) ?? null;
const paged = usePaged(shown, { resetKey: `${tab}|${search}|${from}|${to}` });
if (error) {
return (
@@ -206,10 +255,50 @@ export function UploadsPanel({ tenantid, locationid }: UploadsPanelProps) {
return (
<VStack gap={2}>
<HStack align="center" justify="between" wrap="wrap" gap={1}>
<SectionHeader
title="Uploads"
note={`${rows.length} upload${rows.length === 1 ? "" : "s"}`}
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
<div className="up-toolbar">
<div className="up-tabs" role="tablist" aria-label="Filter uploads">
{TABS.map((entry) => (
<button
key={entry.id}
type="button"
role="tab"
className="up-tab"
aria-selected={tab === entry.id}
data-active={tab === entry.id}
onClick={() => setTab(entry.id)}
>
{entry.label}
<span>{counts[entry.id]}</span>
</button>
))}
</div>
<div className="up-toolbar-spacer" />
<input
className="up-search"
type="search"
value={search}
placeholder="Search by file name…"
aria-label="Search by file name"
onChange={(event) => setSearch(event.target.value)}
/>
<input
className="up-date"
type="date"
value={from}
max={to || undefined}
aria-label="Uploaded on or after"
onChange={(event) => setFrom(event.target.value)}
/>
<input
className="up-date"
type="date"
value={to}
min={from || undefined}
aria-label="Uploaded on or before"
onChange={(event) => setTo(event.target.value)}
/>
<Button
label={isRefreshing ? 'Checking…' : 'Check again'}
@@ -220,142 +309,155 @@ export function UploadsPanel({ tenantid, locationid }: UploadsPanelProps) {
icon={<RefreshCw size={13} />}
onClick={() => void refreshLive(rows)}
/>
</HStack>
<VStack gap={1.5}>
{rows.map((receipt) => (
<UploadRow
key={receipt.batchid}
receipt={receipt}
live={live[receipt.batchid]}
isExpanded={expanded === receipt.batchid}
onToggle={() =>
setExpanded((current) => (current === receipt.batchid ? null : receipt.batchid))
}
/* Re-reads the receipts so the row flips to "on the shelf" without a
manual refresh — the write has already happened server-side. */
onShelved={() => void load()}
showTenant={!tenantid}
{onUpload ? (
<Button
label="Upload spreadsheet"
variant="primary"
size="sm"
icon={<UploadCloud size={14} />}
onClick={onUpload}
/>
))}
</VStack>
) : null}
</div>
{/* ── The list ─────────────────────────────────────────────────────── */}
{shown.length === 0 ? (
<Card padding={4} variant="transparent">
<VStack gap={0.5}>
<Text type="label" weight="semibold">
Nothing matches
</Text>
<Text type="body" size="sm" color="secondary">
No upload matches this tab, search or date range.
</Text>
</VStack>
</Card>
) : (
<Card padding={0} elevation="low">
<div className="table-scroll">
<table className="up-table">
<thead>
<tr>
<th>File name</th>
<th>Uploaded on</th>
<th>Rows</th>
<th>Status</th>
<th />
</tr>
</thead>
<tbody>
{paged.rows.map((receipt) => {
const batch = live[receipt.batchid]?.batch;
const state = stateOf(receipt, batch);
const totals = totalsOf(receipt, batch);
const colour = STATE_COLOUR[state];
return (
<tr key={receipt.batchid}>
<td>
<div className="up-file">
<span className="up-file-icon" style={{ color: colour }}>
<FileSpreadsheet size={17} />
</span>
<span>
<span className="up-file-name">
{receipt.filename || 'Spreadsheet'}
</span>
<span className="up-file-sub">
{totals.rows} row{totals.rows === 1 ? '' : 's'}
{totals.rejected > 0 ? ` · ${totals.rejected} rejected` : ''}
</span>
</span>
</div>
</td>
<td>
<div className="up-when">{formatWhen(receipt.created)}</div>
<div className="up-who">
{[
showTenant ? receipt.tenantname : null,
receipt.locationname,
receipt.uploadedname,
]
.filter(Boolean)
.join(' · ')}
</div>
</td>
<td className="num">{totals.rows || '—'}</td>
<td>
<span
className="up-pill"
style={{
color: colour,
background: `color-mix(in oklab, ${colour} 12%, transparent)`,
}}
>
{STATE_LABEL[state]}
</span>
{/* Shelving is a separate claim from ingestion and is
stated separately — a catalogue product carries no
price and no stock. */}
{receipt.shelvedat ? (
<div className="up-who">{receipt.shelvedcount} on the shelf</div>
) : null}
</td>
<td className="right">
<Button
label="View details"
variant="ghost"
size="sm"
onClick={() => setDetail(receipt.batchid)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TablePager paged={paged} label="uploads" />
</Card>
)}
{open ? (
<UploadDetailDrawer
receipt={open}
batch={live[open.batchid]?.batch}
products={live[open.batchid]?.products ?? []}
showTenant={showTenant}
onClose={() => setDetail(null)}
{...(canShelveHere(open, live[open.batchid]?.batch)
? {
extra: (
<ShelveAction
receipt={open}
batch={live[open.batchid]!.batch}
onDone={() => void load()}
/>
),
}
: {})}
/>
) : null}
</VStack>
);
}
/* ── One receipt ──────────────────────────────────────────────────────────── */
/** The tabs, and the order they read in. */
const TABS: { id: UploadTab; label: string }[] = [
{ id: 'all', label: 'All uploads' },
{ id: 'done', label: 'Successful' },
{ id: 'failed', label: 'Failed' },
{ id: 'running', label: 'In progress' },
];
function UploadRow({
receipt,
live,
isExpanded,
onToggle,
onShelved,
showTenant,
}: {
receipt: UploadReceipt;
live: LiveReading | undefined;
isExpanded: boolean;
onToggle: () => void;
onShelved: () => void;
showTenant: boolean;
}) {
const batch = live?.batch;
const state = describeState(receipt, batch);
const expiresIn = daysUntilExpiry(receipt);
return (
<Card padding={3} variant="transparent">
<VStack gap={1.5}>
<HStack align="start" justify="between" gap={2} wrap="wrap">
<HStack align="start" gap={1.5}>
<state.Icon size={20} style={{ color: state.colour, flexShrink: 0, marginTop: 2 }} />
<VStack gap={0.5}>
<Text type="label" weight="semibold">
{receipt.filename || 'Spreadsheet'}
</Text>
<Text type="body" size="sm" color="secondary">
{state.line}
</Text>
<Text type="body" size="xsm" color="secondary">
{[
showTenant ? receipt.tenantname : null,
receipt.locationname,
receipt.uploadedname,
formatWhen(receipt.created),
]
.filter(Boolean)
.join(' · ')}
</Text>
</VStack>
</HStack>
<HStack gap={1} align="center">
{/* The shelving half, stated separately and always. A product in the
global catalogue carries no price and no stock — "added" and
"on sale here" are two different claims, and running them
together would tell a shopkeeper they can sell something no
customer can buy. */}
{receipt.shelvedat ? (
<Badge
variant="success"
label={`${receipt.shelvedcount} on the shelf`}
/>
) : state.canShelve ? (
<Badge variant="warning" label="Not on a shelf yet" />
) : null}
<Button
label={isExpanded ? 'Hide' : 'Details'}
variant="ghost"
size="sm"
onClick={onToggle}
/>
</HStack>
</HStack>
{/* Finishing the job, from here.
The step this button runs used to exist only in the tab that did the
upload, which meant it was normally impossible: the drop waits for
the ingest service's admin and the run then takes minutes, so by the
time there is anything to shelve that tab is closed. The products
then sit in the shared catalogue, unpriced and unstocked, and no
screen offered a way to finish. */}
{state.canShelve && !receipt.shelvedat && batch ? (
<ShelveAction receipt={receipt} batch={batch} onDone={onShelved} />
) : null}
{/* The seven-day clock. Their service deletes a drop nobody acts on,
and the remedy — asking an admin to release it — only works before
the deadline, so it cannot wait to be discovered. */}
{expiresIn !== null ? (
<Text
type="body"
size="sm"
style={{
color: expiresIn <= 2 ? 'var(--color-error, #d64545)' : 'var(--color-warning, #b7860b)',
}}
>
{expiresIn === 0
? 'This drop has passed the seven-day review window and may already have been deleted. Re-upload it.'
: `Still waiting for review. The catalogue service deletes an unreviewed drop after seven days — ${expiresIn} left.`}
</Text>
) : null}
{/* A batch handed to an orchestrator that is not deployed. Every other
signal — queued, stage 0, an empty bar — is identical to waiting
one's turn, so the difference has to be said out loud. */}
{batch && isStuckOnMissingRunner(batch) ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
This batch was staged for the Dagster orchestrator, which is not running in production —
it will wait indefinitely rather than fail. Ask the catalogue team to resume it onto the
in-process worker.
</Text>
) : null}
{isExpanded ? <UploadDetail receipt={receipt} live={live} /> : null}
</VStack>
</Card>
);
/**
* Can this upload still be finished from here?
*
* Needs a live batch — shelving reads the run's product list — and needs the run
* to have settled without having been shelved already.
*/
function canShelveHere(receipt: UploadReceipt, batch: IngestBatch | undefined): boolean {
if (!batch || receipt.shelvedat) return false;
return isSettled(batch) && batch.status !== 'failed' && !isDismissed(batch);
}
/**
@@ -501,284 +603,6 @@ function ShelveAction({
);
}
/* ── The detail: stages while it runs, products once it has ───────────────── */
function UploadDetail({ receipt, live }: { receipt: UploadReceipt; live: LiveReading | undefined }) {
const batch = live?.batch;
const products = live?.products ?? [];
return (
<VStack gap={2} style={{ borderTop: '1px solid var(--color-border, #e5e5e5)', paddingTop: 12 }}>
<HStack gap={2} wrap="wrap">
<Meta label="Batch" value={receipt.batchid} mono />
{receipt.runid ? <Meta label="Run" value={receipt.runid} mono /> : null}
<Meta label="Sheet rows" value={String(receipt.rowcount || '—')} />
<Meta label="Sent as" value={receipt.sender || '—'} />
</HStack>
{/* The pipeline, drawn from the service's own stage list rather than a
hardcoded one — so this cannot drift when they add or rename a stage. */}
{batch && !isSettled(batch) && !isAwaitingReview(batch) ? (
<StageTimeline batch={batch} />
) : null}
{batch?.brands?.length ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Brands touched
</Text>
<HStack gap={1} wrap="wrap">
{batch.brands.map((brand) => (
<Badge key={brand} variant="neutral" label={brand} />
))}
</HStack>
</VStack>
) : null}
{/* The confirmation itself: the products, one line each, with what the
run did to every one of them. */}
{products.length > 0 ? (
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
{products.length} product{products.length === 1 ? '' : 's'} in the catalogue
</Text>
<Text type="body" size="xsm" color="secondary">
“Already there” is a success, not a failure — re-sending a sheet writes nothing.
</Text>
<VStack gap={0.5}>
{products.slice(0, 100).map((product) => (
<HStack
key={product.image_id}
gap={1}
align="center"
justify="between"
wrap="wrap"
>
<VStack gap={0}>
<Text type="body" size="sm">
{product.product_name}
</Text>
<Text
type="body"
size="xsm"
color="secondary"
style={{ fontFamily: 'var(--font-mono)' }}
>
{product.product_sku ?? product.image_id}
{product.sku_source === 'Internal' ? ' · SKU minted by the service' : ''}
</Text>
</VStack>
<Badge {...dispositionBadge(product.disposition)} />
</HStack>
))}
</VStack>
{products.length > 100 ? (
<Text type="body" size="xsm" color="secondary">
Showing the first 100 of {products.length}.
</Text>
) : null}
</VStack>
) : batch && isSettled(batch) ? (
<Text type="body" size="sm" color="secondary">
The run reported no products for this file.
</Text>
) : null}
{/* Files the service refused, named rather than counted — "1 of 2 failed"
does not say which one to resend. */}
{(batch?.files ?? [])
.filter((file) => file.status === 'failed')
.map((file) => (
<Text
key={file.index}
type="body"
size="sm"
style={{ color: 'var(--color-error, #d64545)' }}
>
{file.filename} could not be read — {file.detail ?? 'no reason given'}
</Text>
))}
{receipt.shelvedat ? (
<HStack gap={1} align="center">
<PackageCheck size={16} style={{ color: 'var(--color-success, #10b981)' }} />
<Text type="body" size="sm" color="secondary">
{receipt.shelvedcount} priced and stocked at {receipt.locationname || 'this branch'} on{' '}
{formatWhen(receipt.shelvedat)}
{receipt.skippedcount > 0 ? `, ${receipt.skippedcount} left out` : ''}.
</Text>
</HStack>
) : null}
</VStack>
);
}
/**
* The eleven stages, with the one running now.
*
* `stage_names` is read from the response rather than hardcoded, deliberately:
* the service serves it so a client cannot drift out of step when a stage is
* added or renamed. When a build does not send it, the file's own timeline is
* the fallback and the list is simply shorter.
*/
function StageTimeline({ batch }: { batch: IngestBatch }) {
const file = batch.files?.[0];
if (!file) return null;
const stage = currentStage(file);
const names = batch.stage_names ?? [];
const total = file.total_stages ?? names.length ?? 11;
if (!stage) return null;
return (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Stage {stage.index} of {total} — {stage.name}
</Text>
{stage.rows_total ? (
<Text type="body" size="xsm" color="secondary">
{stage.rows_done ?? 0} of {stage.rows_total} rows. Image search and barcode enrichment
reach the network and are much the slowest — minutes here is normal.
</Text>
) : null}
{names.length > 0 ? (
/* Numbered pips rather than eleven spelled-out rows. The names are long
enough ("Brand Resolution & FSSAI Licence Mapping") that listing them
all would bury the one fact anybody wants, which is how far along it
is; the name is on the line above and on hover. `title` goes on the
wrapper because Badge takes no tooltip of its own. */
<HStack gap={0.5} wrap="wrap">
{names.map((name, index) => (
<span key={name} title={`${index + 1}. ${name}`}>
<Badge
variant={
index + 1 < stage.index
? 'success'
: index + 1 === stage.index
? 'warning'
: 'neutral'
}
label={String(index + 1)}
/>
</span>
))}
</HStack>
) : null}
</VStack>
);
}
/* ── Wording ──────────────────────────────────────────────────────────────── */
function dispositionBadge(disposition: IngestProduct['disposition']) {
if (disposition === 'inserted') return { variant: 'success' as const, label: 'Added' };
if (disposition === 'backfilled') return { variant: 'warning' as const, label: 'Filled in' };
return { variant: 'neutral' as const, label: 'Already there' };
}
/**
* One line saying where this upload stands.
*
* The live reading wins when we have one; the cached receipt answers when the
* service could not be reached, which is the case the whole receipt exists for.
* Every branch below is a state somebody can actually be in, and none of them
* is allowed to render as a bare status word — "retired" and "partial" mean
* nothing to a shopkeeper.
*/
function describeState(receipt: UploadReceipt, batch: IngestBatch | undefined) {
const warning = 'var(--color-warning, #b7860b)';
const error = 'var(--color-error, #d64545)';
const success = 'var(--color-success, #10b981)';
if (batch) {
if (isDismissed(batch)) {
const reason = (batch.files ?? []).map((file) => file.detail).find(Boolean);
return {
Icon: XCircle,
colour: error,
canShelve: false,
line: reason
? `Declined by the catalogue service: ${reason}`
: 'Declined by the catalogue service. Nothing was imported.',
};
}
if (isAwaitingReview(batch)) {
return {
Icon: Clock,
colour: warning,
canShelve: false,
line: 'Waiting for the catalogue service to review it. Nothing has run yet.',
};
}
if (!isSettled(batch)) {
return {
Icon: Clock,
colour: warning,
canShelve: false,
line: 'Running in the catalogue pipeline now.',
};
}
if (batch.status === 'failed') {
return { Icon: XCircle, colour: error, canShelve: false, line: 'No file could be ingested.' };
}
if (batch.status === 'interrupted') {
return {
Icon: AlertTriangle,
colour: warning,
canShelve: true,
line: 'A restart cut this run short. It does not resume on its own — ask the catalogue team.',
};
}
const totals = batch.totals;
return {
Icon: batch.status === 'partial' ? AlertTriangle : CheckCircle2,
colour: batch.status === 'partial' ? warning : success,
canShelve: true,
line: countLine(
totals?.inserted ?? 0,
totals?.backfilled ?? 0,
totals?.skipped_existing ?? 0,
totals?.rejected ?? 0,
),
};
}
// No live reading. Everything below is the cached receipt talking.
if (receipt.laststatus === 'pending') {
return {
Icon: Clock,
colour: warning,
canShelve: false,
line: 'Waiting for the catalogue service to review it. Nothing has run yet.',
};
}
if (receipt.laststatus === 'done' || receipt.laststatus === 'partial') {
return {
Icon: CheckCircle2,
colour: success,
canShelve: true,
line: countLine(receipt.inserted, receipt.backfilled, receipt.skipped, receipt.rejected),
};
}
return {
Icon: AlertTriangle,
colour: warning,
canShelve: true,
// Named as unreachable rather than dressed up. A drop past its seven-day
// window is genuinely gone from their side, and the receipt is all that is
// left of it — saying "loading" forever would hide that.
line: `Last known: ${receipt.laststatus}. The catalogue service could not be reached for a fresh reading.`,
};
}
function countLine(inserted: number, backfilled: number, skipped: number, rejected: number): string {
const parts = [`${inserted} added to the catalogue`];
if (backfilled > 0) parts.push(`${backfilled} filled in`);
if (skipped > 0) parts.push(`${skipped} already there`);
if (rejected > 0) parts.push(`${rejected} rejected`);
return parts.join(' · ');
}
function formatWhen(value: string | null): string {
if (!value) return '';
const parsed = new Date(value);
@@ -790,20 +614,3 @@ function formatWhen(value: string | null): string {
minute: '2-digit',
});
}
function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<VStack gap={0}>
<Text type="body" size="xsm" color="secondary">
{label}
</Text>
<Text
type="body"
size="sm"
{...(mono ? { style: { fontFamily: 'var(--font-mono)' } } : {})}
>
{value}
</Text>
</VStack>
);
}

View File

@@ -0,0 +1,262 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import type { IngestBatch } from '@/api/ingest';
import type { UploadReceipt } from '@/api/uploads';
import {
fileSize,
journeyOf,
matchesDate,
matchesSearch,
matchesTab,
pipelineOf,
stateOf,
totalsOf,
} from './uploadState';
const receipt = (over: Partial<UploadReceipt> = {}): UploadReceipt =>
({
uploadid: 1,
tenantid: 1147,
locationid: 1185,
categoryid: 2,
batchid: 'batch-1',
runid: '',
filename: 'nearle-sample-products-3.xlsx',
sender: 'R mart',
uploadedby: 1,
uploadedname: 'Suriya',
rowcount: 17,
laststatus: 'done',
inserted: 15,
backfilled: 0,
skipped: 0,
rejected: 2,
created: '2026-09-03T09:37:00',
shelvedat: null,
shelvedcount: 0,
skippedcount: 0,
...over,
}) as UploadReceipt;
const batch = (over: Partial<IngestBatch> = {}): IngestBatch =>
({
batch_id: 'batch-1',
status: 'done',
detail: null,
files_total: 1,
files_done: 1,
files_failed: 0,
totals: {
rows_total: 17,
products_built: 17,
inserted: 15,
backfilled: 0,
skipped_existing: 0,
rejected: 2,
},
brands: [],
files: [],
...over,
}) as IngestBatch;
/* ── State ───────────────────────────────────────────────────────────────── */
test('a finished run is Completed', () => {
assert.equal(stateOf(receipt(), batch()), 'done');
});
test('a partial run is not dressed up as a success', () => {
assert.equal(stateOf(receipt(), batch({ status: 'partial' })), 'partial');
});
test('an interrupted run reads as partial, not as failed', () => {
// A restart cut it short. Some rows landed; calling it Failed would tell the
// shop to re-send a sheet that is already half in the catalogue.
assert.equal(stateOf(receipt(), batch({ status: 'interrupted' })), 'partial');
});
test('the cached receipt answers when the service cannot be reached', () => {
/*
Not an error state. The ingest service is frequently unreachable and the
receipt is a real answer — the alternative is a row that says "loading"
forever for an upload that finished last week.
*/
assert.equal(stateOf(receipt({ laststatus: 'done' }), undefined), 'done');
assert.equal(stateOf(receipt({ laststatus: 'pending' }), undefined), 'waiting');
assert.equal(stateOf(receipt({ laststatus: 'lost-in-the-post' }), undefined), 'unknown');
});
/* ── Tabs, search, dates ─────────────────────────────────────────────────── */
test('the Failed tab holds partial runs too', () => {
// Somebody checking for problems wants the half-done ones, and they would not
// think to look under Successful for them.
assert.equal(matchesTab('partial', 'failed'), true);
assert.equal(matchesTab('failed', 'failed'), true);
assert.equal(matchesTab('done', 'failed'), false);
});
test('awaiting review counts as in progress', () => {
// From the shop's side, "in their inbox" and "running" are one answer: it has
// not landed yet.
assert.equal(matchesTab('waiting', 'running'), true);
assert.equal(matchesTab('running', 'running'), true);
});
test('All means all, including states we could not read', () => {
for (const state of ['done', 'partial', 'failed', 'running', 'waiting', 'unknown'] as const) {
assert.equal(matchesTab(state, 'all'), true, state);
}
});
test('search is case-insensitive and matches partway through a name', () => {
assert.equal(matchesSearch(receipt(), 'SAMPLE'), true);
assert.equal(matchesSearch(receipt(), 'products-3'), true);
assert.equal(matchesSearch(receipt(), 'invoice'), false);
assert.equal(matchesSearch(receipt(), ' '), true, 'a blank box filters nothing');
});
test('the date filter is inclusive at both ends', () => {
const row = receipt({ created: '2026-09-03T09:37:00' });
assert.equal(matchesDate(row, '2026-09-03', '2026-09-03'), true, 'the day itself');
assert.equal(matchesDate(row, '2026-09-04', ''), false);
assert.equal(matchesDate(row, '', '2026-09-02'), false);
assert.equal(matchesDate(row, '', ''), true);
});
/* ── The journey ─────────────────────────────────────────────────────────── */
test('a step with no recorded time renders without one rather than borrowing', () => {
/*
The gaps are the point. Somebody opens this to find where an upload stalled,
and a journey that fills a missing stamp from its neighbour hides exactly
that.
*/
const steps = journeyOf(receipt({ created: '2026-09-03T09:37:00' }), undefined);
assert.equal(steps[0]?.at, '2026-09-03T09:37:00');
assert.equal(steps[1]?.at, null, 'no batch, so no release time is known');
});
test('reaching the shelf is its own step, and is not implied by success', () => {
// A catalogue product carries no price and no stock. Merging the two would
// tell a shopkeeper they can sell something no customer can buy.
const steps = journeyOf(receipt({ shelvedat: null }), batch());
const shelf = steps.at(-1)!;
assert.equal(shelf.label, 'Reached shelf');
assert.equal(shelf.isDone, false, 'a completed run has still not reached a shelf');
assert.equal(shelf.isCurrent, true, 'and that is the step it is sitting on');
});
test('"Added to catalogue" stays open when everything was rejected', () => {
// Processed, yes. Added, no. Two different claims.
const nothing = batch({
totals: { rows_total: 2, products_built: 0, inserted: 0, backfilled: 0, skipped_existing: 0, rejected: 2 },
});
const steps = journeyOf(receipt({ inserted: 0, rejected: 2 }), nothing);
assert.equal(steps[2]?.isDone, true, 'processed');
assert.equal(steps[3]?.isDone, false, 'but nothing was added');
});
test('rows already in the catalogue still count as added', () => {
// Re-sending a sheet writes nothing and is a success, not a failure.
const again = batch({
totals: { rows_total: 5, products_built: 5, inserted: 0, backfilled: 0, skipped_existing: 5, rejected: 0 },
});
assert.equal(journeyOf(receipt(), again)[3]?.isDone, true);
});
test('a failed upload has no current step', () => {
// It stopped. A "you are here" marker on a dead run implies it is still moving.
const steps = journeyOf(receipt({ laststatus: 'failed' }), batch({ status: 'failed' }));
assert.equal(steps.some((step) => step.isCurrent), false);
});
/* ── The pipeline ────────────────────────────────────────────────────────── */
test('stage names come from the service, never from a list here', () => {
/*
Hardcoding eleven names is how a client drifts out of step the day the service
renames one. An empty `stage_names` yields an empty pipeline, which is correct
— we genuinely do not know the stages.
*/
const running = batch({
status: 'running',
stage_names: ['Brand Resolution', 'Row Intake', 'Image Search'],
files: [
{
index: 0,
filename: 'f.xlsx',
status: 'running',
total_stages: 3,
stages: [
{ index: 1, name: 'Brand Resolution', rows_done: 20, rows_total: 20, finished_at: 100 },
{ index: 2, name: 'Row Intake', rows_done: 2, rows_total: 19, finished_at: null },
],
},
],
} as Partial<IngestBatch>);
const { steps, done, total } = pipelineOf(running);
assert.equal(total, 3);
assert.equal(done, 1);
assert.equal(steps[0]?.isDone, true);
assert.equal(steps[1]?.isCurrent, true, 'finished_at null is the stage running now');
assert.equal(steps[1]?.progress, '2 / 19');
assert.equal(steps[2]?.isDone, false);
assert.equal(steps[2]?.progress, '', 'a stage never entered reports no counts');
assert.deepEqual(pipelineOf(undefined).steps, []);
});
/* ── Numbers ─────────────────────────────────────────────────────────────── */
test('row count falls back to what we parsed before sending', () => {
/*
A waiting batch reports nothing at all. Showing a dash where the operator can
see seventeen rows in their own spreadsheet reads as a fault in the console.
*/
assert.equal(totalsOf(receipt({ rowcount: 17 }), undefined).rows, 17);
assert.equal(totalsOf(receipt({ rowcount: 17 }), batch()).rows, 17);
});
test('the live run wins over the cached receipt', () => {
const fresher = batch({
totals: { rows_total: 17, products_built: 17, inserted: 17, backfilled: 0, skipped_existing: 0, rejected: 0 },
});
const out = totalsOf(receipt({ inserted: 15, rejected: 2 }), fresher);
assert.equal(out.added, 17);
assert.equal(out.rejected, 0);
});
test('file size reads as a person would say it', () => {
assert.equal(fileSize(245 * 1024), '245 KB');
assert.equal(fileSize(900), '900 B');
assert.equal(fileSize(3 * 1024 * 1024), '3.0 MB');
assert.equal(fileSize(undefined), '—', 'the service does not always report it');
assert.equal(fileSize(0), '—');
});
test('a later completed step implies every earlier one', () => {
/*
The two systems answer independently, so a hole can open in the middle: with
the ingest service unreachable the receipt says done and shelved, while
nothing records when the drop was released. That rendered as Processed and
Reached shelf ticked with Released for processing open between them — which
cannot have happened, and reads as a stuck upload rather than a missing
timestamp.
*/
const steps = journeyOf(
receipt({ laststatus: 'done', runid: '', shelvedat: '2026-09-03T17:05:00' }),
undefined,
);
assert.deepEqual(steps.map((step) => step.isDone), [true, true, true, true, true]);
assert.equal(steps[1]?.at, null, 'the tick is inferred; the stamp is still honestly missing');
});
test('the hole is filled backwards, never forwards', () => {
// Reaching a shelf is not implied by being processed — that is the whole
// point of it being a separate step.
const steps = journeyOf(receipt({ shelvedat: null }), batch());
assert.equal(steps[2]?.isDone, true, 'processed');
assert.equal(steps.at(-1)?.isDone, false, 'but not shelved');
});

View File

@@ -0,0 +1,281 @@
import { currentStage, isAwaitingReview, isDismissed, isSettled, type IngestBatch } from '@/api/ingest';
import type { UploadReceipt } from '@/api/uploads';
/**
* What an upload is doing, as one word.
*
* Derived rather than stored. Two systems hold half the answer each — Fiesta
* has the receipt, the ingest service has the run — and the receipt is the
* fallback whenever the service cannot be reached, which is common enough that
* it is a normal state and not an error.
*/
export type UploadState = 'done' | 'partial' | 'failed' | 'running' | 'waiting' | 'unknown';
/** The toolbar's tabs. `all` is not a state, it is the absence of a filter. */
export type UploadTab = 'all' | 'done' | 'failed' | 'running';
export function stateOf(receipt: UploadReceipt, batch: IngestBatch | undefined): UploadState {
if (batch) {
if (isDismissed(batch)) return 'failed';
if (isAwaitingReview(batch)) return 'waiting';
if (!isSettled(batch)) return 'running';
if (batch.status === 'failed') return 'failed';
if (batch.status === 'interrupted') return 'partial';
if (batch.status === 'partial') return 'partial';
return 'done';
}
// No live reading — the cached receipt is talking.
if (receipt.laststatus === 'pending') return 'waiting';
if (receipt.laststatus === 'done') return 'done';
if (receipt.laststatus === 'partial') return 'partial';
if (receipt.laststatus === 'failed' || receipt.laststatus === 'dismissed') return 'failed';
return 'unknown';
}
export const STATE_LABEL: Record<UploadState, string> = {
done: 'Completed',
partial: 'Partially failed',
failed: 'Failed',
running: 'In progress',
waiting: 'Awaiting review',
unknown: 'Unknown',
};
/**
* One colour per state, and only four colours in play.
*
* Green for finished, orange for anything still moving or half-done, red for
* refused, grey for a state we could not read. Purple is deliberately absent:
* it is the brand and the interaction colour here, not a status, and using it
* for one status would make that status look like a control.
*/
export const STATE_COLOUR: Record<UploadState, string> = {
done: 'var(--color-success)',
partial: 'var(--color-warning)',
failed: 'var(--color-error)',
running: 'var(--color-warning)',
waiting: 'var(--color-warning)',
unknown: 'var(--color-ink-4)',
};
/**
* Which tab a row belongs to.
*
* `waiting` counts as in-progress rather than as its own tab: from the shop's
* side "sitting in their review inbox" and "running now" are the same
* answer — it has not landed yet — and a fourth tab holding one row most days
* is a tab nobody presses.
*/
export function matchesTab(state: UploadState, tab: UploadTab): boolean {
if (tab === 'all') return true;
if (tab === 'done') return state === 'done';
if (tab === 'failed') return state === 'failed' || state === 'partial';
return state === 'running' || state === 'waiting';
}
/** Case-insensitive, on the file name — which is what the search box says. */
export function matchesSearch(receipt: UploadReceipt, term: string): boolean {
const wanted = term.trim().toLowerCase();
if (!wanted) return true;
return (receipt.filename ?? '').toLowerCase().includes(wanted);
}
/** On or after `from`, on or before `to`. Either end may be blank. */
export function matchesDate(receipt: UploadReceipt, from: string, to: string): boolean {
if (!from && !to) return true;
const day = (receipt.created ?? '').slice(0, 10);
if (!day) return false;
if (from && day < from) return false;
if (to && day > to) return false;
return true;
}
/* ── The journey ─────────────────────────────────────────────────────────── */
export interface JourneyStep {
label: string;
/** ISO string or null. Null with `isDone` true means "done, time unknown". */
at: string | null;
isDone: boolean;
/** The one the upload is sitting on now. */
isCurrent: boolean;
/** The final green step rather than another purple one. */
isFinal?: boolean;
}
/** Epoch SECONDS to an ISO string. The service sends seconds, not millis. */
function fromEpoch(seconds: number | undefined): string | null {
if (!seconds) return null;
const at = new Date(seconds * 1000);
return Number.isNaN(at.getTime()) ? null : at.toISOString();
}
/**
* The five steps an upload walks, with real timestamps only.
*
* Every stamp here comes from a field one of the two systems actually sets. A
* step whose time is not recorded renders without one rather than borrowing a
* neighbour's — a journey that invents times is worse than one with gaps,
* because the gaps are exactly what somebody is looking for when they open
* this.
*
* "Reached shelf" is a separate step from "Added to catalogue" on purpose. A
* product in the global catalogue carries no price and no stock; those are two
* different claims, and running them together tells a shopkeeper they can sell
* something no customer can buy.
*/
export function journeyOf(receipt: UploadReceipt, batch: IngestBatch | undefined): JourneyStep[] {
const state = stateOf(receipt, batch);
const settled = batch ? isSettled(batch) : state === 'done' || state === 'partial';
const released = Boolean(receipt.runid) || Boolean(batch && !isAwaitingReview(batch));
const shelved = Boolean(receipt.shelvedat);
const { added, backfilled, alreadyThere } = totalsOf(receipt, batch);
const steps: JourneyStep[] = [
{ label: 'File uploaded', at: receipt.created ?? null, isDone: true, isCurrent: false },
{
label: 'Released for processing',
at: fromEpoch(batch?.created_at),
isDone: released,
isCurrent: false,
},
{
label: 'Processed',
at: settled ? fromEpoch(batch?.updated_at) : null,
isDone: settled,
isCurrent: false,
},
{
label: 'Added to catalogue',
at: settled ? fromEpoch(batch?.updated_at) : null,
/*
Settled is not enough on its own — that is the step above. This one is
done when the catalogue actually HOLDS these products, which includes the
rows that were already there: re-sending a sheet writes nothing and is a
success, not a failure. A run that settled having rejected every row has
been processed but has added nothing, and this step correctly stays open.
*/
isDone: settled && added + backfilled + alreadyThere > 0,
isCurrent: false,
},
{
label: 'Reached shelf',
at: receipt.shelvedat ?? null,
isDone: shelved,
isCurrent: false,
isFinal: true,
},
];
/*
A journey cannot have a hole in it.
The steps are read from two systems that answer independently, so a later one
can be known while an earlier one is not: with the ingest service unreachable,
the receipt says "done" and "shelved" while nothing records WHEN the drop was
released. That rendered as Processed and Reached shelf ticked with Released
for processing sitting open between them — which cannot have happened, and
reads as a stuck upload rather than as a missing timestamp.
So completion flows backwards: if a step is done, everything before it is too.
The missing STAMP is still missing and still renders blank, which is honest —
it is the tick that is being inferred, and only from something stronger.
*/
for (let i = steps.length - 1; i > 0; i -= 1) {
if (steps[i]!.isDone) steps[i - 1]!.isDone = true;
}
// The current step is the first unfinished one. A failed upload has no
// current step — it stopped, and drawing a "you are here" on a dead run
// implies it is still moving.
if (state !== 'failed') {
const at = steps.findIndex((step) => !step.isDone);
if (at >= 0) steps[at]!.isCurrent = true;
}
return steps;
}
/* ── The processing pipeline ─────────────────────────────────────────────── */
export interface PipelineStep {
index: number;
name: string;
isDone: boolean;
isCurrent: boolean;
/** "12 / 19", or empty when the service reports no row counts for the stage. */
progress: string;
}
/**
* The eleven stages, with the one running now.
*
* Names come from `stage_names` on the response rather than from a list here,
* deliberately: the service serves them so a client cannot drift out of step
* when a stage is added or renamed on their side.
*/
export function pipelineOf(batch: IngestBatch | undefined): {
steps: PipelineStep[];
done: number;
total: number;
} {
const file = batch?.files?.find((entry) => entry.stages?.length) ?? batch?.files?.[0];
const names = batch?.stage_names ?? [];
const stage = file ? currentStage(file) : undefined;
const total = file?.total_stages ?? names.length ?? 0;
const steps: PipelineStep[] = names.map((name, i) => {
const index = i + 1;
const entered = file?.stages?.find((entry) => entry.index === index);
const isCurrent = stage ? index === stage.index : false;
const isDone = stage ? index < stage.index : Boolean(entered?.finished_at);
const rowsTotal = entered?.rows_total;
return {
index,
name,
isDone,
isCurrent,
progress: rowsTotal ? `${entered?.rows_done ?? 0} / ${rowsTotal}` : '',
};
});
return { steps, done: steps.filter((step) => step.isDone).length, total: total || steps.length };
}
/* ── Numbers ─────────────────────────────────────────────────────────────── */
export interface UploadTotals {
rows: number;
added: number;
backfilled: number;
alreadyThere: number;
rejected: number;
}
/**
* The counts, preferring the live run over the cached receipt.
*
* `rows` falls back to what WE parsed before sending. That is the more honest
* number while a batch waits: the service reports nothing until it has read the
* file, and showing a dash where the operator can see seventeen rows in their
* own spreadsheet reads as a fault.
*/
export function totalsOf(receipt: UploadReceipt, batch: IngestBatch | undefined): UploadTotals {
const t = batch?.totals;
return {
rows: t?.rows_total || receipt.rowcount || 0,
added: t?.inserted ?? receipt.inserted ?? 0,
backfilled: t?.backfilled ?? receipt.backfilled ?? 0,
alreadyThere: t?.skipped_existing ?? receipt.skipped ?? 0,
rejected: t?.rejected ?? receipt.rejected ?? 0,
};
}
/** "245 KB". Bytes are what the service reports; nobody reads bytes. */
export function fileSize(bytes: number | undefined): string {
if (!bytes || bytes <= 0) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,272 @@
/* ══ Uploads ═══════════════════════════════════════════════════════════════
The list, its toolbar, and the Upload Details drawer. Built on the console's
own tokens and on the drawer system in `store-admin/drawer.css` — the shell,
the close button, the sticky footer and the card radius are shared, so this
reads as the same product. What is local is the part that is genuinely
different: a file header, a pipeline, a rejection panel. */
/* ── Toolbar ────────────────────────────────────────────────────────────── */
.up-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.up-toolbar-spacer { flex: 1 1 auto; }
.up-tabs {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 12px;
background: var(--color-surface-sunken);
}
.up-tab {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border: 0;
border-radius: 9px;
background: transparent;
color: var(--color-ink-3);
font: 500 13px/1 var(--font-sans);
cursor: pointer;
}
.up-tab:hover { color: var(--color-ink-1); }
.up-tab[data-active='true'] {
background: var(--color-surface);
color: var(--color-brand);
font-weight: 600;
box-shadow: 0 1px 2px rgb(15 23 42 / 0.06);
}
.up-tab span {
min-width: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--color-surface-sunken);
font: 700 11px/18px var(--font-sans);
font-variant-numeric: tabular-nums;
}
.up-tab[data-active='true'] span { background: var(--color-brand); color: #fff; }
.up-tab:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
.up-search, .up-date {
height: 38px;
padding: 0 12px;
border: 1px solid var(--color-line);
border-radius: 11px;
background: var(--color-surface);
font: 400 13.5px/1 var(--font-sans);
color: var(--color-ink-1);
}
.up-search { min-width: 220px; }
.up-date { font-variant-numeric: tabular-nums; color: var(--color-ink-2); }
.up-search:focus, .up-date:focus { outline: 2px solid var(--color-brand); outline-offset: -1px; }
.up-search::placeholder { color: var(--color-ink-4); }
/* ── Table ──────────────────────────────────────────────────────────────── */
.up-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.up-table th {
text-align: left;
padding: 10px 14px;
border-bottom: 1px solid var(--color-line);
font: 600 11.5px/1 var(--font-sans);
letter-spacing: 0.04em;
color: var(--color-ink-3);
white-space: nowrap;
}
.up-table td {
padding: 12px 14px;
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 55%, transparent);
vertical-align: middle;
}
.up-table tbody tr:last-child td { border-bottom: 0; }
.up-table tbody tr:hover { background: var(--color-surface-subtle); }
.up-table td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
.up-table td.right { text-align: right; white-space: nowrap; }
/* The file cell: a status dot, the name, and the counts under it. */
.up-file { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; }
.up-file-icon { display: grid; place-items: center; margin-top: 1px; }
/* Block, not inline: as spans these two ran together on one line and read as
'nearle-sample-products-3.xlsx20 rows'. */
.up-file-name {
display: block;
font: 600 13.5px/1.35 var(--font-sans);
color: var(--color-ink-1);
overflow-wrap: anywhere;
}
.up-file-sub { display: block; font: 400 12px/1.4 var(--font-sans); color: var(--color-ink-3); }
.up-when { font: 400 13px/1.4 var(--font-sans); color: var(--color-ink-2); white-space: nowrap; }
.up-who { font: 400 12px/1.4 var(--font-sans); color: var(--color-ink-4); }
.up-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 999px;
font: 600 12px/1.5 var(--font-sans);
white-space: nowrap;
}
/* ── Drawer: the file header ────────────────────────────────────────────── */
/* This is what makes it an Upload Details workspace rather than the generic
drawer: the file itself is the subject, so it gets an icon and its own block
above everything. */
.up-head { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 14px; align-items: start; }
.up-head-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: grid;
place-items: center;
flex: none;
background: var(--color-brand-tint);
color: var(--color-brand);
}
.up-head-body { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.up-head-name {
font: 600 17px/1.3 var(--font-sans);
color: var(--color-ink-1);
overflow-wrap: anywhere;
}
.up-head-sub { font: 400 13px/1.4 var(--font-sans); color: var(--color-ink-3); }
.up-head-meta { font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-4); }
.up-head-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/* ── Drawer: the journey ────────────────────────────────────────────────── */
.up-journey { display: flex; flex-direction: column; }
.up-step { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 12px; }
.up-step-rail { display: flex; flex-direction: column; align-items: center; }
.up-dot {
width: 20px;
height: 20px;
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);
}
.up-dot[data-done='true'] {
background: var(--color-brand);
border-color: var(--color-brand);
color: #fff;
}
/* The last step is the only green one. Everything before it is a processing
step and stays purple — green means "this upload is finished and on sale",
which is a different claim from "the pipeline ran". */
.up-dot[data-done='true'][data-final='true'] {
background: var(--color-success);
border-color: var(--color-success);
}
.up-dot[data-current='true'] {
background: var(--color-surface);
border: 2px solid var(--color-brand);
box-shadow: 0 0 0 4px var(--color-brand-tint);
color: var(--color-brand);
}
.up-line { width: 2px; flex: 1 1 auto; min-height: 18px; background: var(--color-line); }
.up-line[data-done='true'] { background: var(--color-brand); }
.up-step-body { padding-bottom: 14px; display: flex; flex-direction: column; gap: 1px; }
.up-step:last-child .up-step-body { padding-bottom: 0; }
.up-step-label { font: 500 14px/1.35 var(--font-sans); color: var(--color-ink-4); }
.up-step-label[data-done='true'] { color: var(--color-ink-1); }
.up-step-label[data-current='true'] { color: var(--color-brand); font-weight: 600; }
.up-step-at {
font: 400 12.5px/1.35 var(--font-sans);
color: var(--color-ink-3);
font-variant-numeric: tabular-nums;
}
/* ── Drawer: the processing pipeline ────────────────────────────────────── */
.up-pipeline { display: flex; flex-direction: column; }
.up-stage {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 9px 14px;
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 55%, transparent);
}
.up-stage:last-child { border-bottom: 0; }
.up-stage-name { font: 400 13.5px/1.4 var(--font-sans); color: var(--color-ink-4); overflow-wrap: anywhere; }
.up-stage[data-done='true'] .up-stage-name { color: var(--color-ink-2); }
/* The running stage is the one thing on this list anybody is looking at. */
.up-stage[data-current='true'] { background: var(--color-brand-tint); }
.up-stage[data-current='true'] .up-stage-name { color: var(--color-brand); font-weight: 600; }
.up-stage-count {
font: 500 12.5px/1 var(--font-sans);
color: var(--color-ink-3);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.up-stage-icon { display: grid; place-items: center; width: 18px; color: var(--color-ink-4); }
.up-stage[data-done='true'] .up-stage-icon { color: var(--color-brand); }
.up-stage[data-current='true'] .up-stage-icon { color: var(--color-brand); }
/* The bar above the pipeline: how far through, in one line. */
.up-progress { display: flex; flex-direction: column; gap: 6px; }
.up-progress-track {
height: 6px;
border-radius: 999px;
background: var(--color-surface-sunken);
overflow: hidden;
}
.up-progress-fill { height: 100%; background: var(--color-brand); border-radius: 999px; transition: width 300ms ease; }
.up-progress-text {
font: 500 12.5px/1 var(--font-sans);
color: var(--color-ink-3);
font-variant-numeric: tabular-nums;
}
/* ── Drawer: rejections ─────────────────────────────────────────────────── */
/* Red, and only here. It is the one thing on this screen that means somebody
has to go and fix a spreadsheet. */
.up-reject {
border: 1px solid color-mix(in oklab, var(--color-error) 26%, transparent);
border-radius: 12px;
background: color-mix(in oklab, var(--color-error) 5%, transparent);
overflow: hidden;
}
.up-reject-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
}
.up-reject-count {
font: 600 15px/1.2 var(--font-sans);
color: var(--color-error);
font-variant-numeric: tabular-nums;
}
.up-reject-body {
padding: 0 14px 12px;
font: 400 13px/1.55 var(--font-sans);
color: var(--color-ink-2);
}
.up-reject-body ul { margin: 6px 0 0; padding-left: 18px; }
.up-reject-body code {
font: 500 12.5px/1.5 var(--font-mono);
color: var(--color-ink-1);
}
/* ── Narrow ─────────────────────────────────────────────────────────────── */
@media (max-width: 720px) {
.up-toolbar-spacer { display: none; }
.up-search { min-width: 0; flex: 1 1 160px; }
}