diff --git a/src/features/catalogue/CatalogueDetailDrawer.tsx b/src/features/catalogue/CatalogueDetailDrawer.tsx
index 7a8524b..7a72a08 100644
--- a/src/features/catalogue/CatalogueDetailDrawer.tsx
+++ b/src/features/catalogue/CatalogueDetailDrawer.tsx
@@ -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({
: undefined}
width={540}
onClose={onClose}
+ {...(isImported ? { meta: } : {})}
+ {...(!isImported && !blockedReason && onImport
+ ? {
+ isFooterFilled: true,
+ footer: (
+ }
+ 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. */}
-
+
-
- {images.length > 1 ? (
-
-
-
-
-
-
+ {images.length > 1 ? (
+ <>
+
+ {images.map((src, index) => (
+
+ ))}
+
+
{images.length} photos — only the first is imported
-
-
-
- {images.map((src, index) => (
-
- ))}
-
-
- ) : null}
+
+ >
+ ) : null}
+
{/* 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. */}
-
-
-
-
- MARKET PRICE RANGE
-
-
+
+
+
) : 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. */}
-
+
{product.highlights?.length || product.nutrients?.length ? (
-
) : null}
{facts.length > 0 ? (
- <>
-
-
+
+
{facts.map((fact) => (
-
-
- {fact.label}
-
-
- {fact.value}
-
-
+ {fact.value}
+ ) : (
+ fact.value
+ )
+ }
+ />
))}
-
- >
+
+
) : 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 ? (
) : onImport ? (
-
-
- {categoryOptions ? (
- 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}
-
- Adds this product with no price. It reaches no shop and cannot be sold until you
- price and publish it.
-
- }
- width="full"
- isDisabled={Boolean(isBusy)}
- onClick={onImport}
- />
-
-
+
+
+
+ {categoryOptions ? (
+ 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}
+
+ Adds this product with no price. It reaches no shop and cannot be sold until you
+ price and publish it.
+
+
+
+
) : null}
{/* Gallery mode, driven by the same index the thumbnails set — so
diff --git a/src/features/nearle-admin/pages/GlobalCataloguePage.tsx b/src/features/nearle-admin/pages/GlobalCataloguePage.tsx
index 7562733..d6e085d 100644
--- a/src/features/nearle-admin/pages/GlobalCataloguePage.tsx
+++ b/src/features/nearle-admin/pages/GlobalCataloguePage.tsx
@@ -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 (
diff --git a/src/features/nearle-admin/pages/UploadsPage.tsx b/src/features/nearle-admin/pages/UploadsPage.tsx
index de3611c..ab9c611 100644
--- a/src/features/nearle-admin/pages/UploadsPage.tsx
+++ b/src/features/nearle-admin/pages/UploadsPage.tsx
@@ -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 (
-
+ {/* 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. */}
+ navigate('/nearle/catalogue?tab=sheet')} />
);
}
diff --git a/src/features/store-admin/DeliveryProgress.tsx b/src/features/store-admin/DeliveryProgress.tsx
index 3ea77ac..89e5639 100644
--- a/src/features/store-admin/DeliveryProgress.tsx
+++ b/src/features/store-admin/DeliveryProgress.tsx
@@ -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(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(null);
diff --git a/src/features/store-admin/Drawer.tsx b/src/features/store-admin/Drawer.tsx
index e780e7a..f397116 100644
--- a/src/features/store-admin/Drawer.tsx
+++ b/src/features/store-admin/Drawer.tsx
@@ -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 (
-
);
diff --git a/src/features/store-admin/HealthScorePanel.tsx b/src/features/store-admin/HealthScorePanel.tsx
index 9d2dd41..aaa35f3 100644
--- a/src/features/store-admin/HealthScorePanel.tsx
+++ b/src/features/store-admin/HealthScorePanel.tsx
@@ -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 (
-
-
-
+
+
This product is not linked to the global catalogue, so there is no health score for it.
Products imported from the catalogue carry one.
-
-
+
+
);
}
if (query.isLoading) {
return (
-
-
-
- Checking the health score…
-
-
+
+ Checking the health score…
+
);
}
@@ -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 (
-
-
-
- No health score available for this product yet.
-
-
+
+ No health score available for this product yet.
+
);
}
if (shown.isPending) {
return (
-
-
-
+
+
This product is in the catalogue but has not been scored yet. It will appear here, and in
the app, once it has.
-
-
+
+
);
}
const colour = shown.band ? BAND_COLOR[shown.band] : 'var(--color-ink-3)';
return (
-
-
-
+
+
+
);
}
diff --git a/src/features/store-admin/OrderDetailDrawer.tsx b/src/features/store-admin/OrderDetailDrawer.tsx
index 198ab4d..ef5b1f9 100644
--- a/src/features/store-admin/OrderDetailDrawer.tsx
+++ b/src/features/store-admin/OrderDetailDrawer.tsx
@@ -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' ? (
+
+ ) : (
+
+ );
+}
+
+function DeliveryDrawer({ job, onClose }: { job: DeliveryRow; onClose: () => void }) {
+ const moves = useDeliveryMoves(job);
+ return (
+ })}
+ caption={}
+ />
+ );
+}
+
+type Moves = ReturnType;
+
+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 (
-
+ {/* 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. */}
+
+
+
+ {job.transitminutes ? (
+
+ ) : null}
+
+
+ ) : (
+ No rider assigned yet.
+ )}
+ {/* 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}
+
+ ) : order?.rider ? (
+
+
+ }
/>
- }
- heading="Drop"
- name={row.deliverycustomer}
- detail={row.deliveryaddress || row.deliverysuburb}
- phone={row.deliverycontactno}
- />
-
+ {order.ridercontactno ? (
+ } />
+ ) : null}
+
+
+ ) : null}
- {/* ── Rider ─────────────────────────────────────────────────── */}
- {job ? (
-
-
- {job.ridername ? (
-
-
-
-
-
-
-
-
- {job.ridername}
-
- {job.ridercontact ? (
-
- {job.ridercontact}
-
- ) : null}
-
-
- {/* 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. */}
-
-
-
- {job.transitminutes ? (
-
- ) : null}
-
-
-
- ) : (
-
- No rider assigned yet.
-
- )}
- {/* 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. */}
-
-
- ) : order?.rider ? (
-
-
-
- {order.rider}
- {order.ridercontactno ? ` · ${order.ridercontactno}` : ''}
-
-
- ) : null}
+ {row.ordernotes || job?.notes ? (
+
+
+
+
+
+ ) : null}
- {/* ── Notes ─────────────────────────────────────────────────── */}
- {row.ordernotes || job?.notes ? (
-
-
-
- {row.ordernotes || job?.notes}
-
-
- ) : null}
+
+ Line items are not in the list read — Fiesta returns them from the order-detail endpoint,
+ which this sheet does not call yet.
+
+
+ );
+}
-
- Line items are not in the list read — Fiesta returns them from the order-detail
- endpoint, which this sheet does not call yet.
-
-
+/* ── 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) => (
+ run(step.to, step.stamp)}
+ />
+ ))}
+ >
+ );
+}
+
+function MoveCaption({ moves }: { moves: Moves }) {
+ const { isSettled, problem, status } = moves;
+
+ if (isSettled) {
+ return This job is {status}. Nothing further to record.;
+ }
+ if (problem) {
+ return (
+
+
+ {problem}
-
+ );
+ }
+ return (
+
+ Use the buttons below only when the rider's app has not. Marking a job delivered here also
+ closes the order.
+
);
}
@@ -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 (
-
+
+ {/* ── 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. */}
+
+ 0 ? money(price) : 'not set'} />
+ 0 ? money(cost) : '—'} isSmall />
+
+ {margin === null ? null : (
+
+ )}
+
- {reason ? (
-
- {reason}
-
- ) : null}
+ {reason ? {reason} : 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 ? (
-
-
-
- {images.slice(0, 4).map((url) => (
-
- ))}
-
-
+
+
+
+
+ {images.length > 1 ? (
+
+ {images.map((url, index) => (
+
+ ))}
+
+ ) : null}
+
) : 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 ? (
) : 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. */}
- {/* ── In your shops ─────────────────────────────────────────────── */}
-
-
-
-
-
-
-
-
-
-
-
-
{product.productdesc ? (
-
-
-
- {product.productdesc}
-
-
+
+ {/* Prose, not a labelled row — the section heading already says what
+ this is, and a row reading "Description: …" says it twice. */}
+
{product.productdesc}
+
) : null}
- {/* ── The fields the import left behind ─────────────────────────── */}
+ {/* ── In your shops ─────────────────────────────────────────────────── */}
+
+
+ }
+ />
+
+ }
+ />
+ }
+ />
+
+
+
+ {/* ── The fields the import left behind ─────────────────────────────── */}
{source.isLoading ? (
-
- Looking up the full catalogue entry…
-
+ Looking up the full catalogue entry…
) : source.data ? (
) : (
-
-
-
+
+
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.
-
-
+
+
)}
- {canManage && isPublished(product) ? (
-
-
- {confirmHide ? (
-
- {/*
- 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 {product.productname} from your shops’ product
- list. It does not 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.
-
-
-
-
- ) : (
- }
- 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 ? (
+
+
+ {/*
+ 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 {product.productname} from your shops’ product list. It
+ does not 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.
+
+
+
) : null}
);
@@ -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 (
-
-
-
+
{data.size || data.price_range ? (
-
+ );
+}
+
+/* ── 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 (
+
+ {label}
+
+ );
+}
+
+/* ── Note ────────────────────────────────────────────────────────────────── */
+
+export function Note({
+ children,
+ tone,
+ icon,
+}: {
+ children: ReactNode;
+ tone?: 'brand';
+ icon?: ReactNode;
+}) {
+ return (
+
+
+ {/* ── Summary ───────────────────────────────────────────────────────── */}
+
+
+
+ 0 ? (
+
+
+ {totals.rejected}
+
+ ) : (
+ 0
+ )
+ }
+ isSmall
+ />
+
+
+ {/* ── Whatever needs saying out loud, before the detail ─────────────── */}
+ {expiresIn !== null ? (
+ }>
+ {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.`}
+
+ ) : null}
+
+ {batch && batch.runner === 'dagster' && !isSettled(batch) ? (
+ }>
+ 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.
+
+ ) : null}
+
+ {/* ── Processing, while it runs ─────────────────────────────────────── */}
+ {isRunning && pipeline.steps.length > 0 ? (
+
+
+ {/*
+ 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:
+
+ {unrecognised.map((column) => (
+
+ {column}
+
+ ))}
+
+ >
+ ) : (
+ ' Check the sheet for blank product names, missing brands and malformed prices.'
+ )}
+
+
+
+ ) : null}
+
+ {/* Files the service refused, named rather than counted — "1 of 2 failed"
+ does not say which one to resend. */}
+ {failedFiles.map((entry) => (
+ }>
+ {entry.filename} could not be read — {entry.detail ?? 'no reason given'}
+
+ ))}
+
+ {/* ── What it did ───────────────────────────────────────────────────── */}
+ {batch?.brands?.length ? (
+
+
+ {batch.brands.map((brand) => (
+
+ ))}
+
+
+ ) : null}
+
+ {products.length > 0 ? (
+
+
+ “Already there” is a success, not a failure — re-sending a sheet writes nothing.
+
+
+ {products.slice(0, 100).map((product) => (
+
+
+ {product.product_name}
+
+
+ {product.product_sku ?? product.image_id}
+ {product.sku_source === 'Internal' ? ' · SKU minted by the service' : ''}
+
+
+ }
+ value={DISPOSITION[product.disposition] ?? product.disposition}
+ tone={product.disposition === 'unchanged' ? 'muted' : undefined}
+ />
+ ))}
+
+ {products.length > 100 ? (
+ Showing the first 100 of {products.length}.
+ ) : null}
+
+ ) : batch && isSettled(batch) ? (
+ The run reported no products for this file.
+ ) : null}
+
+ {/* ── On the shelf ──────────────────────────────────────────────────── */}
+ {receipt.shelvedat ? (
+ }>
+ {receipt.shelvedcount} priced and stocked at {receipt.locationname || 'this branch'} on{' '}
+ {when(receipt.shelvedat)}
+ {receipt.skippedcount > 0 ? `, ${receipt.skippedcount} left out` : ''}.
+
+ ) : null}
+
+ {/* Finishing the job — the step the ingest service cannot do. */}
+ {extra}
+
+ {state === 'done' || state === 'partial' ? (
+ }>
+ 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.'}
+
+ ) : null}
+
+ );
+}
+
+const DISPOSITION: Record = {
+ inserted: 'Added',
+ backfilled: 'Filled in',
+ unchanged: 'Already there',
+};
+
+function Mono({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+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);
+}
diff --git a/src/features/uploads/UploadsPanel.tsx b/src/features/uploads/UploadsPanel.tsx
index 4e326ff..3cf7db5 100644
--- a/src/features/uploads/UploadsPanel.tsx
+++ b/src/features/uploads/UploadsPanel.tsx
@@ -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(null);
const [error, setError] = useState(null);
const [live, setLive] = useState>({});
- const [expanded, setExpanded] = useState(null);
+ const [detail, setDetail] = useState(null);
+ const [tab, setTab] = useState('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 (
-
-
+
+
+ {STATE_LABEL[state]}
+
+ {/* Shelving is a separate claim from ingestion and is
+ stated separately — a catalogue product carries no
+ price and no stock. */}
+ {receipt.shelvedat ? (
+
{receipt.shelvedcount} on the shelf
+ ) : null}
+
+
+ setDetail(receipt.batchid)}
+ />
+
+
+ );
+ })}
+
+
+
+
+
+ )}
+
+ {open ? (
+ setDetail(null)}
+ {...(canShelveHere(open, live[open.batchid]?.batch)
+ ? {
+ extra: (
+ void load()}
+ />
+ ),
+ }
+ : {})}
+ />
+ ) : null}
);
}
-/* ── 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 (
-
-
-
-
-
-
-
- {receipt.filename || 'Spreadsheet'}
-
-
- {state.line}
-
-
- {[
- showTenant ? receipt.tenantname : null,
- receipt.locationname,
- receipt.uploadedname,
- formatWhen(receipt.created),
- ]
- .filter(Boolean)
- .join(' · ')}
-
-
-
-
-
- {/* 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 ? (
-
- ) : state.canShelve ? (
-
- ) : null}
-
-
-
-
- {/* 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 ? (
-
- ) : 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 ? (
-
- {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.`}
-
- ) : 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) ? (
-
- 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.
-
- ) : null}
-
- {isExpanded ? : null}
-
-
- );
+/**
+ * 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 (
-
-
-
- {receipt.runid ? : null}
-
-
-
-
- {/* 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) ? (
-
- ) : null}
-
- {batch?.brands?.length ? (
-
-
- Brands touched
-
-
- {batch.brands.map((brand) => (
-
- ))}
-
-
- ) : null}
-
- {/* The confirmation itself: the products, one line each, with what the
- run did to every one of them. */}
- {products.length > 0 ? (
-
-
- {products.length} product{products.length === 1 ? '' : 's'} in the catalogue
-
-
- “Already there” is a success, not a failure — re-sending a sheet writes nothing.
-
-
- {products.slice(0, 100).map((product) => (
-
-
-
- {product.product_name}
-
-
- {product.product_sku ?? product.image_id}
- {product.sku_source === 'Internal' ? ' · SKU minted by the service' : ''}
-
-
-
-
- ))}
-
- {products.length > 100 ? (
-
- Showing the first 100 of {products.length}.
-
- ) : null}
-
- ) : batch && isSettled(batch) ? (
-
- The run reported no products for this file.
-
- ) : 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) => (
-
- {file.filename} could not be read — {file.detail ?? 'no reason given'}
-
- ))}
-
- {receipt.shelvedat ? (
-
-
-
- {receipt.shelvedcount} priced and stocked at {receipt.locationname || 'this branch'} on{' '}
- {formatWhen(receipt.shelvedat)}
- {receipt.skippedcount > 0 ? `, ${receipt.skippedcount} left out` : ''}.
-
-
- ) : null}
-
- );
-}
-
-/**
- * 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 (
-
-
- Stage {stage.index} of {total} — {stage.name}
-
- {stage.rows_total ? (
-
- {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.
-
- ) : 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. */
-
- {names.map((name, index) => (
-
-
-
- ))}
-
- ) : null}
-
- );
-}
-
-/* ── 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 (
-
-
- {label}
-
-
- {value}
-
-
- );
-}
diff --git a/src/features/uploads/uploadState.test.ts b/src/features/uploads/uploadState.test.ts
new file mode 100644
index 0000000..9d42a31
--- /dev/null
+++ b/src/features/uploads/uploadState.test.ts
@@ -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 =>
+ ({
+ 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 =>
+ ({
+ 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);
+
+ 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');
+});
diff --git a/src/features/uploads/uploadState.ts b/src/features/uploads/uploadState.ts
new file mode 100644
index 0000000..a03f689
--- /dev/null
+++ b/src/features/uploads/uploadState.ts
@@ -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 = {
+ 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 = {
+ 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`;
+}
diff --git a/src/features/uploads/uploads.css b/src/features/uploads/uploads.css
new file mode 100644
index 0000000..cee0c58
--- /dev/null
+++ b/src/features/uploads/uploads.css
@@ -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; }
+}