drawer and upload
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,47 @@ const MOVES = [
|
||||
/** The end states. Nothing follows them, so nothing is offered. */
|
||||
const SETTLED = ['delivered', 'cancelled'];
|
||||
|
||||
/**
|
||||
* The moves, split from their rendering.
|
||||
*
|
||||
* The drawer puts the buttons in its fixed footer and the caption in the body,
|
||||
* and those two need to share one mutation — a second instance would give the
|
||||
* footer its own pending state and its own error, so a failure would be
|
||||
* reported in a place the operator is not looking. Returning both from one hook
|
||||
* keeps a single source of truth for what is happening.
|
||||
*/
|
||||
export function useDeliveryMoves(job: DeliveryRow) {
|
||||
const client = useQueryClient();
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const status = (job.orderstatus ?? '').trim().toLowerCase();
|
||||
const isSettled = SETTLED.includes(status);
|
||||
|
||||
const move = useMutation({
|
||||
mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
|
||||
deliveriesApi.update({
|
||||
deliveryid: job.deliveryid,
|
||||
orderheaderid: job.orderheaderid ?? 0,
|
||||
orderstatus: to,
|
||||
[stamp]: stampNow(),
|
||||
}),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: queryKeys.insights.all }),
|
||||
onError: (error) => setProblem(errorMessage(error)),
|
||||
});
|
||||
|
||||
return {
|
||||
status,
|
||||
isSettled,
|
||||
isPending: move.isPending,
|
||||
problem,
|
||||
moves: MOVES,
|
||||
run: (to: string, stamp: string) => {
|
||||
setProblem(null);
|
||||
move.mutate({ to, stamp });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function DeliveryProgress({ job }: { job: DeliveryRow }) {
|
||||
const client = useQueryClient();
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
@@ -1,35 +1,51 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { X } from 'lucide-react';
|
||||
import './drawer.css';
|
||||
|
||||
/**
|
||||
* The side drawer, matched to `OrderDetailDrawer`.
|
||||
* The console's side drawer. Every drawer in the product is this one.
|
||||
*
|
||||
* That one is the site's drawer and every measurement here is taken from it:
|
||||
* the flex-end wrapper at z-index 70, a full-bleed scrim BUTTON rather than a
|
||||
* div (so it is reachable and announces itself), a 460px panel capped at 92vw,
|
||||
* `-24px 0 56px -12px` of shadow, a STICKY header on a hairline, and a 30px
|
||||
* rounded close button on `--color-surface-sunken`.
|
||||
* ── Why the panel is a flex column ──────────────────────────────────────────
|
||||
*
|
||||
* Extracted rather than copied a third time. The drawers I added had drifted —
|
||||
* different scrim opacity, different shadow, a bare `X`, a header that scrolled
|
||||
* away — which is exactly how two screens stop looking like one product.
|
||||
* It used to be a single scrolling box with a sticky header. That cannot hold a
|
||||
* footer: an action bar inside a scrolling container scrolls away with the
|
||||
* content, which is exactly wrong for the button you opened the drawer to
|
||||
* press. Header, scrolling body and action bar are now three rows of a column,
|
||||
* so only the middle one moves.
|
||||
*
|
||||
* ── What belongs here and what does not ─────────────────────────────────────
|
||||
*
|
||||
* The shell owns the chrome — scrim, panel, header, close, footer — and nothing
|
||||
* else. Content comes from `drawerKit`. A drawer that styles its own card or
|
||||
* badge is the beginning of the drift this component exists to end.
|
||||
*/
|
||||
export interface DrawerProps {
|
||||
title: string;
|
||||
/** One line under the title. */
|
||||
/** One line under the title — a brand, an SKU, a store name. */
|
||||
subtitle?: string;
|
||||
/** Sits beside the title — a status chip, usually. */
|
||||
titleAside?: ReactNode;
|
||||
/** Renders the title in the mono face. For codes: order ids, SKUs. */
|
||||
isTitleMono?: boolean;
|
||||
/** The status pill and any metadata beside it, on the row under the title. */
|
||||
meta?: ReactNode;
|
||||
/**
|
||||
* Panel width in px. 460 is the site default; only widen for content that
|
||||
* genuinely cannot fold, such as a grid of product cards.
|
||||
* Panel width in px. The design system's range is 480–560; 520 is the
|
||||
* default and only content that genuinely cannot fold should exceed it.
|
||||
*/
|
||||
width?: number;
|
||||
/** Renders the body unpadded, for panels that manage their own layout. */
|
||||
isBare?: boolean;
|
||||
/**
|
||||
* The fixed action bar. Omit it entirely for a read-only drawer — an empty
|
||||
* bar is a band of chrome that costs height and offers nothing.
|
||||
*/
|
||||
footer?: ReactNode;
|
||||
/** Stretch footer actions to fill the width, for a single primary action. */
|
||||
isFooterFilled?: boolean;
|
||||
/**
|
||||
* Push the first action to the left and the rest right — a destructive
|
||||
* action kept away from the one people mean to press.
|
||||
*/
|
||||
isFooterSpread?: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -37,12 +53,23 @@ export interface DrawerProps {
|
||||
export function Drawer({
|
||||
title,
|
||||
subtitle,
|
||||
titleAside,
|
||||
width = 460,
|
||||
isTitleMono,
|
||||
meta,
|
||||
width = 520,
|
||||
isBare,
|
||||
footer,
|
||||
isFooterFilled,
|
||||
isFooterSpread,
|
||||
onClose,
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
/*
|
||||
Escape closes.
|
||||
|
||||
A dialog dismissible only by finding the scrim with a mouse is not dismissible
|
||||
at all for anyone on a keyboard, and this one sits over the list the operator
|
||||
is working through.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
@@ -52,83 +79,36 @@ export function Drawer({
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 70, display: 'flex', justifyContent: 'flex-end' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
border: 0,
|
||||
background: 'rgb(15 23 42 / .32)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="dialog-panel"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width,
|
||||
maxWidth: '92vw',
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--color-surface)',
|
||||
borderLeft: '1px solid var(--color-line)',
|
||||
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
justify="between"
|
||||
align="start"
|
||||
gap={1}
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
}}
|
||||
>
|
||||
<VStack gap={0.5} style={{ minWidth: 0 }}>
|
||||
<Text type="label" size="lg" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
{titleAside}
|
||||
</VStack>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 10,
|
||||
border: 0,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'var(--color-surface-sunken)',
|
||||
color: 'var(--color-ink-3)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</HStack>
|
||||
<div className="drawer-root" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<button type="button" className="drawer-scrim" aria-label="Close" onClick={onClose} />
|
||||
|
||||
{isBare ? children : <VStack gap={3} padding={2}>{children}</VStack>}
|
||||
<div className="drawer-panel dialog-panel" style={{ width }}>
|
||||
<header className="drawer-head">
|
||||
<div className="drawer-head-text">
|
||||
<h2 className="drawer-title" {...(isTitleMono ? { 'data-mono': 'true' } : {})}>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle ? <span className="drawer-sub">{subtitle}</span> : null}
|
||||
{meta ? <div className="drawer-meta">{meta}</div> : null}
|
||||
</div>
|
||||
<button type="button" className="drawer-close" aria-label="Close" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="drawer-body" {...(isBare ? { 'data-bare': 'true' } : {})}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer ? (
|
||||
<footer
|
||||
className="drawer-foot"
|
||||
{...(isFooterFilled ? { 'data-fill': 'true' } : {})}
|
||||
{...(isFooterSpread ? { 'data-spread': 'true' } : {})}
|
||||
>
|
||||
{footer}
|
||||
</footer>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Check, ExternalLink, Leaf } from 'lucide-react';
|
||||
import { nutritionApi } from '@/api/nutrition';
|
||||
import type { Product } from '@/api/types';
|
||||
import { DrawerCard, Note, Row, Section } from './drawerKit';
|
||||
import { BAND_COLOR, BAND_LABEL, facts, present } from './healthScore';
|
||||
import './pages/deliveries.css';
|
||||
|
||||
@@ -57,24 +56,20 @@ export function HealthScorePanel({ product }: { product: Product }) {
|
||||
*/
|
||||
if (!brand || !imageId) {
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<Label />
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
<Section title="Health score">
|
||||
<Note>
|
||||
This product is not linked to the global catalogue, so there is no health score for it.
|
||||
Products imported from the catalogue carry one.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Note>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<Label />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Checking the health score…
|
||||
</Text>
|
||||
</VStack>
|
||||
<Section title="Health score">
|
||||
<Note>Checking the health score…</Note>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,33 +79,28 @@ export function HealthScorePanel({ product }: { product: Product }) {
|
||||
// "known but unscored", and said differently, for the same reason as above.
|
||||
if (shown.isEmpty) {
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<Label />
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
No health score available for this product yet.
|
||||
</Text>
|
||||
</VStack>
|
||||
<Section title="Health score">
|
||||
<Note>No health score available for this product yet.</Note>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
if (shown.isPending) {
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<Label />
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
<Section title="Health score">
|
||||
<Note>
|
||||
This product is in the catalogue but has not been scored yet. It will appear here, and in
|
||||
the app, once it has.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Note>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const colour = shown.band ? BAND_COLOR[shown.band] : 'var(--color-ink-3)';
|
||||
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<Label />
|
||||
|
||||
<Section title="Health score">
|
||||
<div className="health-body">
|
||||
<div className="health-head">
|
||||
<span
|
||||
className="health-score"
|
||||
@@ -179,22 +169,17 @@ export function HealthScorePanel({ product }: { product: Product }) {
|
||||
) : null}
|
||||
|
||||
{facts(query.data ?? null).length > 0 ? (
|
||||
<div className="health-facts">
|
||||
{facts(query.data ?? null).map((row) => (
|
||||
<div key={row.label}>
|
||||
<span>{row.label}</span>
|
||||
<strong>{row.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
<p>per 100 g</p>
|
||||
</div>
|
||||
<>
|
||||
<DrawerCard>
|
||||
{facts(query.data ?? null).map((row) => (
|
||||
<Row key={row.label} label={row.label} value={row.value} />
|
||||
))}
|
||||
</DrawerCard>
|
||||
<span className="health-per">per 100 g</span>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{shown.caveat ? (
|
||||
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
|
||||
{shown.caveat}
|
||||
</Text>
|
||||
) : null}
|
||||
{shown.caveat ? <span className="health-caveat">{shown.caveat}</span> : null}
|
||||
|
||||
{shown.source ? (
|
||||
<a className="health-source" href={shown.source.url} target="_blank" rel="noreferrer">
|
||||
@@ -202,14 +187,7 @@ export function HealthScorePanel({ product }: { product: Product }) {
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Label() {
|
||||
return (
|
||||
<Text type="label" size="xsm" color="secondary">
|
||||
Health score
|
||||
</Text>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Check, MapPin, Phone, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ArrowDown, Bike, Check, MapPin, Phone, X } from 'lucide-react';
|
||||
import type { DeliveryRow, OrderRow } from '@/api/types';
|
||||
import { DeliveryProgress } from './DeliveryProgress';
|
||||
import { useDeliveryMoves } from './DeliveryProgress';
|
||||
import { Drawer } from './Drawer';
|
||||
import { Badge, DrawerButton, DrawerCard, Metric, Metrics, Note, Row, Section } from './drawerKit';
|
||||
import { branchLabel, moneyExact } from './format';
|
||||
import { DELIVERY_STATUS, ORDER_STATUS, orderQuantity, orderValue, statusColor } from './orderStatus';
|
||||
|
||||
type Row = OrderRow | DeliveryRow;
|
||||
type Row_ = OrderRow | DeliveryRow;
|
||||
|
||||
export type RowKind = 'order' | 'delivery';
|
||||
|
||||
@@ -44,11 +42,57 @@ export function OrderDetailDrawer({
|
||||
kind,
|
||||
onClose,
|
||||
}: {
|
||||
row: Row;
|
||||
row: Row_;
|
||||
kind: RowKind;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const delivery = kind === 'delivery';
|
||||
/*
|
||||
Split before the hook, not inside it.
|
||||
|
||||
The moves hook drives both the footer buttons and the caption that
|
||||
reports their errors, and it can only be called when there IS a delivery.
|
||||
Calling it twice — once per renderer — would give each its own pending state
|
||||
and its own error, so a failed write would be reported where nobody is
|
||||
looking. One instance, passed to both.
|
||||
*/
|
||||
return kind === 'delivery' ? (
|
||||
<DeliveryDrawer job={row as DeliveryRow} onClose={onClose} />
|
||||
) : (
|
||||
<Sheet row={row} isDelivery={false} onClose={onClose} />
|
||||
);
|
||||
}
|
||||
|
||||
function DeliveryDrawer({ job, onClose }: { job: DeliveryRow; onClose: () => void }) {
|
||||
const moves = useDeliveryMoves(job);
|
||||
return (
|
||||
<Sheet
|
||||
row={job}
|
||||
isDelivery
|
||||
onClose={onClose}
|
||||
{...(moves.isSettled ? {} : { footer: <MoveActions moves={moves} /> })}
|
||||
caption={<MoveCaption moves={moves} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type Moves = ReturnType<typeof useDeliveryMoves>;
|
||||
|
||||
function Sheet({
|
||||
row,
|
||||
isDelivery,
|
||||
onClose,
|
||||
footer,
|
||||
caption,
|
||||
}: {
|
||||
row: Row_;
|
||||
/** Passed explicitly, never inferred from the presence of a footer — a
|
||||
settled job has no actions and would otherwise read as an order. */
|
||||
isDelivery: boolean;
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
caption?: ReactNode;
|
||||
}) {
|
||||
const delivery = isDelivery;
|
||||
// Narrowed once, here. `kind` comes from the table the row was clicked in, so
|
||||
// it is authoritative in a way that sniffing fields is not — a delivery row
|
||||
// carries an `orderheaderid` too, which is what made the first attempt at
|
||||
@@ -56,16 +100,6 @@ export function OrderDetailDrawer({
|
||||
const job = delivery ? (row as DeliveryRow) : undefined;
|
||||
const order = delivery ? undefined : (row as OrderRow);
|
||||
|
||||
// Escape closes. A dialog that can only be dismissed by finding the scrim
|
||||
// with a mouse is not dismissible for anyone on a keyboard, and this one sits
|
||||
// over the list an operator is working through.
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
const title = row.orderid || (job ? `DLV-${job.deliveryid}` : `#${order?.orderheaderid}`);
|
||||
const status = row.orderstatus;
|
||||
const colour = statusColor(delivery ? DELIVERY_STATUS : ORDER_STATUS, status);
|
||||
@@ -74,234 +108,187 @@ export function OrderDetailDrawer({
|
||||
const cancelled = (status ?? '').toLowerCase().includes('cancel');
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Order ${title}`}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 70, display: 'flex', justifyContent: 'flex-end' }}
|
||||
<Drawer
|
||||
title={title}
|
||||
isTitleMono
|
||||
onClose={onClose}
|
||||
meta={
|
||||
<>
|
||||
<Badge label={status || '—'} colour={colour} />
|
||||
<span className="drawer-meta-text">{branchLabel(row.locationname)}</span>
|
||||
</>
|
||||
}
|
||||
{...(footer ? { footer } : {})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
border: 0,
|
||||
background: 'rgb(15 23 42 / .32)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="dialog-panel"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 460,
|
||||
maxWidth: '92vw',
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--color-surface)',
|
||||
borderLeft: '1px solid var(--color-line)',
|
||||
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
justify="between"
|
||||
align="start"
|
||||
gap={1}
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
}}
|
||||
>
|
||||
<VStack gap={0.5}>
|
||||
<Text
|
||||
type="label"
|
||||
size="lg"
|
||||
weight="semibold"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<HStack gap={1} align="center">
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
borderRadius: 999,
|
||||
padding: '2px 9px',
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
color: colour,
|
||||
background: `color-mix(in oklab, ${colour} 12%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{status || '—'}
|
||||
</span>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{branchLabel(row.locationname)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 10,
|
||||
border: 0,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'var(--color-surface-sunken)',
|
||||
color: 'var(--color-ink-3)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</HStack>
|
||||
{/* ── Money ─────────────────────────────────────────────────────────
|
||||
Three equal columns, the shape the spec asks for and the one that
|
||||
answers "what is this worth, who pays what, how much of it". */}
|
||||
<Metrics>
|
||||
<Metric label="Value" value={value > 0 ? moneyExact(value) : '—'} />
|
||||
{job ? (
|
||||
<Metric
|
||||
label="Rider charge"
|
||||
value={job.deliverycharges ? moneyExact(job.deliverycharges) : '—'}
|
||||
isSmall
|
||||
/>
|
||||
) : (
|
||||
<Metric
|
||||
label="Cash to collect"
|
||||
value={order?.collectionamt ? moneyExact(order.collectionamt) : '—'}
|
||||
isSmall
|
||||
/>
|
||||
)}
|
||||
<Metric
|
||||
label="Items"
|
||||
value={job ? String(job.itemcount ?? '—') : String(orderQuantity(row) || '—')}
|
||||
isSmall
|
||||
/>
|
||||
</Metrics>
|
||||
|
||||
<VStack gap={3} padding={2}>
|
||||
{/* ── Money ─────────────────────────────────────────────────── */}
|
||||
<div className="form-grid">
|
||||
<Figure label="Value" value={value > 0 ? moneyExact(value) : '—'} isStrong />
|
||||
{job ? (
|
||||
<Figure
|
||||
label="Rider charge"
|
||||
value={job.deliverycharges ? moneyExact(job.deliverycharges) : '—'}
|
||||
/>
|
||||
) : (
|
||||
<Figure
|
||||
label="Cash to collect"
|
||||
value={order?.collectionamt ? moneyExact(order.collectionamt) : '—'}
|
||||
/>
|
||||
)}
|
||||
<Figure
|
||||
label="Items"
|
||||
value={job ? String(job.itemcount ?? '—') : String(orderQuantity(row) || '—')}
|
||||
/>
|
||||
<Section title="Order journey">
|
||||
<Timeline row={row} isCancelled={cancelled} />
|
||||
</Section>
|
||||
|
||||
<Section title="Route">
|
||||
<DrawerCard>
|
||||
<Party
|
||||
kind="Pickup"
|
||||
name={row.pickupcustomer || branchLabel(row.locationname)}
|
||||
detail={
|
||||
job
|
||||
? job.pickupsuburb || job.pickuplocation || job.Pickupaddress || job.pickupaddress
|
||||
: order?.pickupaddress || order?.pickupsuburb
|
||||
}
|
||||
phone={row.pickupcontactno}
|
||||
/>
|
||||
<div className="drawer-party-link" aria-hidden>
|
||||
<ArrowDown size={13} />
|
||||
</div>
|
||||
<Party
|
||||
kind="Drop"
|
||||
name={row.deliverycustomer}
|
||||
detail={row.deliveryaddress || row.deliverysuburb}
|
||||
phone={row.deliverycontactno}
|
||||
/>
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
|
||||
{/* ── Journey ───────────────────────────────────────────────── */}
|
||||
<VStack gap={1}>
|
||||
<Label>Journey</Label>
|
||||
<Timeline row={row} isCancelled={cancelled} />
|
||||
</VStack>
|
||||
|
||||
{/* ── Pickup and drop ───────────────────────────────────────── */}
|
||||
<VStack gap={1}>
|
||||
<Label>Route</Label>
|
||||
<Party
|
||||
icon={<MapPin size={13} />}
|
||||
heading="Pickup"
|
||||
name={row.pickupcustomer || branchLabel(row.locationname)}
|
||||
detail={
|
||||
job
|
||||
? job.pickupsuburb || job.pickuplocation || job.Pickupaddress || job.pickupaddress
|
||||
: order?.pickupaddress || order?.pickupsuburb
|
||||
}
|
||||
phone={row.pickupcontactno}
|
||||
{/* ── Rider ─────────────────────────────────────────────────────────── */}
|
||||
{job ? (
|
||||
<Section title="Rider">
|
||||
{job.ridername ? (
|
||||
<DrawerCard>
|
||||
<div className="drawer-party">
|
||||
<span className="drawer-party-icon">
|
||||
<Bike size={15} />
|
||||
</span>
|
||||
<div className="drawer-party-body">
|
||||
<span className="drawer-party-name">{job.ridername}</span>
|
||||
{job.ridercontact ? (
|
||||
<a className="drawer-party-phone" href={`tel:${job.ridercontact}`}>
|
||||
<Phone size={12} />
|
||||
{job.ridercontact}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Planned against actual. The gap is the number worth reading —
|
||||
a job quoted 3km and ridden 9 is either a bad address or a
|
||||
rider taking a detour, and one figure hides both. */}
|
||||
<Metrics cols={job.transitminutes ? 3 : 2}>
|
||||
<Metric label="Planned" value={km(job.kms)} isSmall />
|
||||
<Metric label="Actual" value={km(job.actualkms || job.riderkms)} isSmall />
|
||||
{job.transitminutes ? (
|
||||
<Metric label="Transit" value={`${job.transitminutes} min`} isSmall />
|
||||
) : null}
|
||||
</Metrics>
|
||||
</DrawerCard>
|
||||
) : (
|
||||
<Note>No rider assigned yet.</Note>
|
||||
)}
|
||||
{/* The caption for the footer's actions. It stays in the body while
|
||||
the buttons sit in the bar — it explains when to use them, which
|
||||
is reading matter, not a control. */}
|
||||
{caption}
|
||||
</Section>
|
||||
) : order?.rider ? (
|
||||
<Section title="Rider">
|
||||
<DrawerCard>
|
||||
<Row
|
||||
label="Rider"
|
||||
value={order.rider}
|
||||
icon={<Bike size={14} />}
|
||||
/>
|
||||
<Party
|
||||
icon={<MapPin size={13} />}
|
||||
heading="Drop"
|
||||
name={row.deliverycustomer}
|
||||
detail={row.deliveryaddress || row.deliverysuburb}
|
||||
phone={row.deliverycontactno}
|
||||
/>
|
||||
</VStack>
|
||||
{order.ridercontactno ? (
|
||||
<Row label="Contact" value={order.ridercontactno} icon={<Phone size={14} />} />
|
||||
) : null}
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── Rider ─────────────────────────────────────────────────── */}
|
||||
{job ? (
|
||||
<VStack gap={1}>
|
||||
<Label>Rider</Label>
|
||||
{job.ridername ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<HStack gap={1} align="center">
|
||||
<span
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'color-mix(in oklab, #8b5cf6 14%, transparent)',
|
||||
color: '#8b5cf6',
|
||||
}}
|
||||
>
|
||||
<Bike size={14} />
|
||||
</span>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{job.ridername}
|
||||
</Text>
|
||||
{job.ridercontact ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{job.ridercontact}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
{/* Planned against actual. The gap is the number worth
|
||||
reading — a job quoted 3km and ridden 9 is either a bad
|
||||
address or a rider taking a detour, and one figure hides
|
||||
both. */}
|
||||
<HStack gap={2}>
|
||||
<Figure label="Planned" value={km(job.kms)} />
|
||||
<Figure label="Actual" value={km(job.actualkms || job.riderkms)} />
|
||||
{job.transitminutes ? (
|
||||
<Figure label="Transit" value={`${job.transitminutes} min`} />
|
||||
) : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
No rider assigned yet.
|
||||
</Text>
|
||||
)}
|
||||
{/* Recording progress by hand. Under the rider rather than beside
|
||||
the status chip at the top: it is a correction, not the normal
|
||||
way a job moves, and putting it in the header would invite it
|
||||
to be used as one. */}
|
||||
<DeliveryProgress job={job} />
|
||||
</VStack>
|
||||
) : order?.rider ? (
|
||||
<VStack gap={1}>
|
||||
<Label>Rider</Label>
|
||||
<Text type="body" size="sm">
|
||||
{order.rider}
|
||||
{order.ridercontactno ? ` · ${order.ridercontactno}` : ''}
|
||||
</Text>
|
||||
</VStack>
|
||||
) : null}
|
||||
{row.ordernotes || job?.notes ? (
|
||||
<Section title="Notes">
|
||||
<DrawerCard tone="subtle">
|
||||
<Row label="Note" value={row.ordernotes || job?.notes} isStacked />
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── Notes ─────────────────────────────────────────────────── */}
|
||||
{row.ordernotes || job?.notes ? (
|
||||
<VStack gap={1}>
|
||||
<Label>Notes</Label>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{row.ordernotes || job?.notes}
|
||||
</Text>
|
||||
</VStack>
|
||||
) : null}
|
||||
<Note>
|
||||
Line items are not in the list read — Fiesta returns them from the order-detail endpoint,
|
||||
which this sheet does not call yet.
|
||||
</Note>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Line items are not in the list read — Fiesta returns them from the order-detail
|
||||
endpoint, which this sheet does not call yet.
|
||||
</Text>
|
||||
</VStack>
|
||||
/* ── Recording progress by hand ──────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The moves, in the drawer's fixed action bar.
|
||||
*
|
||||
* In the footer rather than beside the status chip: it is a correction, not the
|
||||
* normal way a job moves, and putting it in the header would invite it to be
|
||||
* used as one. It is still the drawer's primary action, which is why it is
|
||||
* pinned rather than buried at the end of a scroll.
|
||||
*/
|
||||
function MoveActions({ moves: state }: { moves: Moves }) {
|
||||
const { isPending, moves, status, run } = state;
|
||||
|
||||
return (
|
||||
<>
|
||||
{moves.map((step) => (
|
||||
<DrawerButton
|
||||
key={step.to}
|
||||
label={step.label}
|
||||
variant={step.to === 'cancelled' ? 'danger' : step.to === 'delivered' ? 'primary' : 'secondary'}
|
||||
isDisabled={isPending || step.to === status}
|
||||
onClick={() => run(step.to, step.stamp)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveCaption({ moves }: { moves: Moves }) {
|
||||
const { isSettled, problem, status } = moves;
|
||||
|
||||
if (isSettled) {
|
||||
return <Note>This job is {status}. Nothing further to record.</Note>;
|
||||
}
|
||||
if (problem) {
|
||||
return (
|
||||
<div className="drawer-note" role="alert" style={{ color: 'var(--color-error)' }}>
|
||||
<X size={15} />
|
||||
<span>{problem}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Note>
|
||||
Use the buttons below only when the rider's app has not. Marking a job delivered here also
|
||||
closes the order.
|
||||
</Note>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,7 +302,7 @@ export function OrderDetailDrawer({
|
||||
* have a missing stamp, and drawing that step as done would paper over the
|
||||
* data-quality problem the operator opened this sheet to find.
|
||||
*/
|
||||
function Timeline({ row, isCancelled }: { row: Row; isCancelled: boolean }) {
|
||||
function Timeline({ row, isCancelled }: { row: Row_; isCancelled: boolean }) {
|
||||
const steps = STEPS.map((step) => ({
|
||||
label: step.label,
|
||||
// Read by name: the two row shapes do not carry the same stamps — a
|
||||
@@ -326,196 +313,101 @@ function Timeline({ row, isCancelled }: { row: Row; isCancelled: boolean }) {
|
||||
.find(Boolean),
|
||||
}));
|
||||
|
||||
/* The step the job is sitting on: the first one without a stamp. Marked as
|
||||
"current" rather than merely "not done", so the eye lands on where the
|
||||
order actually is. A finished journey has none, which is correct. */
|
||||
const current = isCancelled ? -1 : steps.findIndex((step) => !step.at);
|
||||
|
||||
return (
|
||||
<VStack gap={0}>
|
||||
<div className="drawer-timeline">
|
||||
{steps.map((step, index) => {
|
||||
const done = Boolean(step.at);
|
||||
const isLast = index === steps.length - 1;
|
||||
const isCurrent = index === current;
|
||||
const isLast = index === steps.length - 1 && !isCancelled;
|
||||
return (
|
||||
<HStack key={step.label} gap={1.5} align="start">
|
||||
<VStack gap={0} align="center" style={{ width: 20, flex: 'none' }}>
|
||||
<div key={step.label} className="drawer-step">
|
||||
<span className="drawer-step-rail">
|
||||
<span
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
flex: 'none',
|
||||
background: done ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
||||
color: done ? '#fff' : 'var(--color-ink-4)',
|
||||
border: done ? 0 : '1px solid var(--color-line)',
|
||||
}}
|
||||
className="drawer-step-dot"
|
||||
data-done={done}
|
||||
{...(isCurrent ? { 'data-current': 'true' } : {})}
|
||||
>
|
||||
{done ? <Check size={11} /> : null}
|
||||
</span>
|
||||
{!isLast ? (
|
||||
<span
|
||||
style={{
|
||||
width: 2,
|
||||
height: 22,
|
||||
background: done ? 'var(--color-brand)' : 'var(--color-line)',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
<VStack gap={0} style={{ paddingBottom: isLast ? 0 : 8 }}>
|
||||
<Text
|
||||
type="body"
|
||||
size="sm"
|
||||
style={{ color: done ? 'var(--color-ink-1)' : 'var(--color-ink-4)' }}
|
||||
{!isLast ? <span className="drawer-step-line" data-done={done} /> : null}
|
||||
</span>
|
||||
<span className="drawer-step-body">
|
||||
<span
|
||||
className="drawer-step-label"
|
||||
data-done={done}
|
||||
{...(isCurrent ? { 'data-current': 'true' } : {})}
|
||||
>
|
||||
{step.label}
|
||||
</Text>
|
||||
{step.at ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{new Date(step.at).toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</span>
|
||||
{step.at ? <span className="drawer-step-at">{when(step.at)}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isCancelled ? (
|
||||
<HStack gap={1.5} align="center" style={{ paddingTop: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'color-mix(in oklab, var(--color-error, #d64545) 14%, transparent)',
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
<div className="drawer-step">
|
||||
<span className="drawer-step-rail">
|
||||
<span className="drawer-step-dot" data-tone="danger">
|
||||
<X size={11} />
|
||||
</span>
|
||||
</span>
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
Cancelled
|
||||
{row.canceltime
|
||||
? ` · ${new Date(row.canceltime).toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})}`
|
||||
: ''}
|
||||
</Text>
|
||||
</HStack>
|
||||
<span className="drawer-step-body">
|
||||
<span className="drawer-step-label" style={{ color: 'var(--color-error)', fontWeight: 600 }}>
|
||||
Cancelled
|
||||
</span>
|
||||
{row.canceltime ? <span className="drawer-step-at">{when(row.canceltime)}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Party({
|
||||
icon,
|
||||
heading,
|
||||
kind,
|
||||
name,
|
||||
detail,
|
||||
phone,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
heading: string;
|
||||
kind: string;
|
||||
name: string | undefined;
|
||||
detail: string | undefined;
|
||||
phone: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<HStack gap={1.5} align="start">
|
||||
<span
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 8,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<div className="drawer-party">
|
||||
<span className="drawer-party-icon">
|
||||
<MapPin size={15} />
|
||||
</span>
|
||||
<VStack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
{heading}
|
||||
</Text>
|
||||
<Text type="body" size="sm" weight="semibold">
|
||||
{name || '—'}
|
||||
</Text>
|
||||
{detail ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{detail}
|
||||
</Text>
|
||||
) : null}
|
||||
<div className="drawer-party-body">
|
||||
<span className="drawer-party-kind">{kind}</span>
|
||||
<span className="drawer-party-name">{name || '—'}</span>
|
||||
{detail ? <span className="drawer-party-detail">{detail}</span> : null}
|
||||
{phone ? (
|
||||
<HStack gap={0.5} align="center">
|
||||
<Phone size={11} style={{ color: 'var(--color-ink-4)' }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{phone}
|
||||
</Text>
|
||||
</HStack>
|
||||
<a className="drawer-party-phone" href={`tel:${phone}`}>
|
||||
<Phone size={12} />
|
||||
{phone}
|
||||
</a>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({
|
||||
label,
|
||||
value,
|
||||
isStrong,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={0}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
type="label"
|
||||
size={isStrong ? 'lg' : 'sm'}
|
||||
weight="semibold"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const Label = ({ children }: { children: React.ReactNode }) => (
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
const when = (at: string): string =>
|
||||
new Date(at).toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const km = (value: string | undefined): string => {
|
||||
const n = Number(value);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
@@ -6,11 +6,12 @@ import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Check, Copy, X } from 'lucide-react';
|
||||
import { AlertTriangle, Check, Copy } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { normaliseMobile, posUsersApi, staffApi, weekdayLabel } from '@/api/people';
|
||||
import type { PosRole, PosUser, StaffInfo, StaffShift, TenantLocation } from '@/api/types';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from './Drawer';
|
||||
import { branchLabel } from './format';
|
||||
|
||||
/* ── Shell ───────────────────────────────────────────────────────────────── */
|
||||
@@ -34,102 +35,17 @@ function Sheet({
|
||||
children: React.ReactNode;
|
||||
footer: React.ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
<Drawer
|
||||
title={title}
|
||||
width={440}
|
||||
onClose={onClose}
|
||||
footer={footer}
|
||||
isFooterSpread
|
||||
{...(subtitle ? { subtitle } : {})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
border: 0,
|
||||
background: 'rgb(15 23 42 / .32)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="dialog-panel"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 440,
|
||||
maxWidth: '92vw',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--color-surface)',
|
||||
borderLeft: '1px solid var(--color-line)',
|
||||
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
justify="between"
|
||||
align="start"
|
||||
gap={1}
|
||||
style={{ padding: 20, borderBottom: '1px solid var(--color-line)', flex: 'none' }}
|
||||
>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="lg" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 10,
|
||||
border: 0,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'var(--color-surface-sunken)',
|
||||
color: 'var(--color-ink-3)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</HStack>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>{children}</div>
|
||||
|
||||
<HStack
|
||||
justify="between"
|
||||
align="center"
|
||||
gap={1}
|
||||
style={{ padding: 16, borderTop: '1px solid var(--color-line)', flex: 'none' }}
|
||||
>
|
||||
{footer}
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { EyeOff } from 'lucide-react';
|
||||
import { BookOpen, EyeOff, Package, Ruler, Tag } from 'lucide-react';
|
||||
import { productsApi } from '@/api/products';
|
||||
import type { Product } from '@/api/types';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useCatalogueProduct } from '@/queries/hooks';
|
||||
import { useBranchScope } from './BranchScope';
|
||||
import { Drawer } from './Drawer';
|
||||
import {
|
||||
Badge,
|
||||
Bullets,
|
||||
DrawerButton,
|
||||
DrawerCard,
|
||||
Metric,
|
||||
Metrics,
|
||||
Note,
|
||||
Row,
|
||||
Section,
|
||||
} from './drawerKit';
|
||||
import { branchLabel, count, money } from './format';
|
||||
import { HealthScorePanel } from './HealthScorePanel';
|
||||
import { ProductSizes } from './ProductSizes';
|
||||
@@ -59,6 +65,7 @@ export function ProductDrawer({
|
||||
const { tenantid, current, scoped } = useBranchScope();
|
||||
const client = useQueryClient();
|
||||
const [confirmHide, setConfirmHide] = useState(false);
|
||||
const [shot, setShot] = useState(0);
|
||||
|
||||
const source = useCatalogueProduct(product.productbrand, product.productsku);
|
||||
const images = imagesOf(product);
|
||||
@@ -76,61 +83,82 @@ export function ProductDrawer({
|
||||
});
|
||||
|
||||
|
||||
const price = effectivePrice(product);
|
||||
const cost = product.productcost ?? 0;
|
||||
/* Margin is derived, not stored. Shown only when both halves are real — a
|
||||
margin against a missing cost is a number that looks like a fact. */
|
||||
const margin = price > 0 && cost > 0 ? price - cost : null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={product.productname ?? 'Product'}
|
||||
subtitle={[product.productbrand, product.productsku].filter(Boolean).join(' · ') || undefined}
|
||||
titleAside={
|
||||
<HStack gap={1} align="center" wrap="wrap" style={{ paddingTop: 2 }}>
|
||||
<Chip label={STATE_LABEL[state]} color={STATE_COLOR[state]} />
|
||||
</HStack>
|
||||
}
|
||||
onClose={onClose}
|
||||
meta={<Badge label={STATE_LABEL[state]} colour={STATE_COLOR[state]} />}
|
||||
footer={
|
||||
canManage && isPublished(product) && !confirmHide ? (
|
||||
<DrawerButton
|
||||
label="Hide from the store catalogue"
|
||||
variant="secondary"
|
||||
icon={<EyeOff size={14} />}
|
||||
onClick={() => setConfirmHide(true)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{/* ── Money ─────────────────────────────────────────────────────── */}
|
||||
<div className="form-grid">
|
||||
<Figure
|
||||
label="Selling price"
|
||||
value={effectivePrice(product) > 0 ? money(effectivePrice(product)) : 'not set'}
|
||||
isStrong
|
||||
/>
|
||||
<Figure label="Cost" value={product.productcost ? money(product.productcost) : '—'} />
|
||||
<Figure label="Tax" value={`${product.taxpercent ?? 0}%`} />
|
||||
</div>
|
||||
{/* ── Money ─────────────────────────────────────────────────────────
|
||||
Margin joins the three the spec lists, because "what do I make on
|
||||
this" is the question the other three get asked in service of. */}
|
||||
<Metrics cols={margin === null ? 3 : 4}>
|
||||
<Metric label="Selling price" value={price > 0 ? money(price) : 'not set'} />
|
||||
<Metric label="Cost" value={cost > 0 ? money(cost) : '—'} isSmall />
|
||||
<Metric label="Tax" value={`${product.taxpercent ?? 0}%`} isSmall />
|
||||
{margin === null ? null : (
|
||||
<Metric
|
||||
label="Margin"
|
||||
value={money(margin)}
|
||||
note={`${((margin / price) * 100).toFixed(1)}%`}
|
||||
isSmall
|
||||
/>
|
||||
)}
|
||||
</Metrics>
|
||||
|
||||
{reason ? (
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
{reason}
|
||||
</Text>
|
||||
) : null}
|
||||
{reason ? <Note>{reason}</Note> : null}
|
||||
|
||||
{/* ── Pack shots ────────────────────────────────────────────────── */}
|
||||
{/* ── Pack shot ─────────────────────────────────────────────────────
|
||||
One large image with thumbnails under it, rather than a row of four
|
||||
equals: the first image is the pack a shopper meets, the rest are
|
||||
angles of the same thing. */}
|
||||
{images.length > 0 ? (
|
||||
<VStack gap={1}>
|
||||
<Label>Pack</Label>
|
||||
<HStack gap={1} wrap="wrap">
|
||||
{images.slice(0, 4).map((url) => (
|
||||
<img
|
||||
key={url}
|
||||
src={url}
|
||||
alt=""
|
||||
style={{
|
||||
width: 76,
|
||||
height: 76,
|
||||
objectFit: 'contain',
|
||||
mixBlendMode: 'multiply',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Section title="Pack">
|
||||
<div className="drawer-figure">
|
||||
<img
|
||||
src={images[shot] ?? images[0]}
|
||||
alt={product.productname ?? 'Product'}
|
||||
style={{ mixBlendMode: 'multiply' }}
|
||||
/>
|
||||
</div>
|
||||
{images.length > 1 ? (
|
||||
<div className="drawer-thumbs">
|
||||
{images.map((url, index) => (
|
||||
<button
|
||||
key={url}
|
||||
type="button"
|
||||
className="drawer-thumb"
|
||||
data-active={index === shot}
|
||||
aria-label={`Photo ${index + 1}`}
|
||||
onClick={() => setShot(index)}
|
||||
>
|
||||
<img src={url} alt="" style={{ mixBlendMode: 'multiply' }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── Sizes ─────────────────────────────────────────────────────── */}
|
||||
{/* Above "In your shops" because it changes what a SHOPPER meets, which
|
||||
{/* ── Sizes ─────────────────────────────────────────────────────────
|
||||
Above "In your shops" because it changes what a SHOPPER meets, which
|
||||
is the bigger fact about a product than where it is stocked. */}
|
||||
{tenantid && branch ? (
|
||||
<ProductSizes
|
||||
@@ -141,103 +169,87 @@ export function ProductDrawer({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* ── Health score ──────────────────────────────────────────────── */}
|
||||
{/* Under Sizes and above the shop detail: it is what a SHOPPER reads,
|
||||
{/* ── Health score ──────────────────────────────────────────────────
|
||||
Under Sizes and above the shop detail: it is what a SHOPPER reads,
|
||||
and it belongs with the other shopper-facing facts. */}
|
||||
<HealthScorePanel product={product} />
|
||||
|
||||
{/* ── In your shops ─────────────────────────────────────────────── */}
|
||||
<VStack gap={1}>
|
||||
<Label>In your shops</Label>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<Line
|
||||
label={branch ? `Stock at ${branchLabel(branch.locationname)}` : 'Stock'}
|
||||
value={count(stockOf(product))}
|
||||
/>
|
||||
<Line label="Released to shops" value={isPublished(product) ? 'yes' : 'not yet'} />
|
||||
<Line
|
||||
label="Category"
|
||||
value={product.categoryname || product.Subcategoryname || 'uncategorised'}
|
||||
/>
|
||||
<Line
|
||||
label="Unit"
|
||||
value={[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
{product.productdesc ? (
|
||||
<VStack gap={1}>
|
||||
<Label>Description</Label>
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
|
||||
{product.productdesc}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Section title="Description">
|
||||
{/* Prose, not a labelled row — the section heading already says what
|
||||
this is, and a row reading "Description: …" says it twice. */}
|
||||
<p className="drawer-prose">{product.productdesc}</p>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── The fields the import left behind ─────────────────────────── */}
|
||||
{/* ── In your shops ─────────────────────────────────────────────────── */}
|
||||
<Section title="In your shops">
|
||||
<DrawerCard>
|
||||
<Row
|
||||
label={branch ? `Stock at ${branchLabel(branch.locationname)}` : 'Stock'}
|
||||
value={count(stockOf(product))}
|
||||
icon={<Package size={14} />}
|
||||
/>
|
||||
<Row
|
||||
label="Released to shops"
|
||||
value={isPublished(product) ? 'Yes' : 'Not yet'}
|
||||
{...(isPublished(product) ? { tone: 'success' as const } : {})}
|
||||
/>
|
||||
<Row
|
||||
label="Category"
|
||||
value={product.categoryname || product.Subcategoryname || 'Uncategorised'}
|
||||
icon={<Tag size={14} />}
|
||||
/>
|
||||
<Row
|
||||
label="Unit"
|
||||
value={[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'}
|
||||
icon={<Ruler size={14} />}
|
||||
/>
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
|
||||
{/* ── The fields the import left behind ─────────────────────────────── */}
|
||||
{source.isLoading ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Looking up the full catalogue entry…
|
||||
</Text>
|
||||
<Note>Looking up the full catalogue entry…</Note>
|
||||
) : source.data ? (
|
||||
<CatalogueExtras data={source.data} />
|
||||
) : (
|
||||
<VStack gap={1}>
|
||||
<Label>Catalogue entry</Label>
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
<Section title="Catalogue entry">
|
||||
<Note>
|
||||
Not available — this item has been revised in the global catalogue since you imported
|
||||
it. Your copy is unaffected; only the extra reference details are gone.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Note>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{canManage && isPublished(product) ? (
|
||||
<VStack gap={1}>
|
||||
<div style={{ height: 1, background: 'var(--color-line)' }} />
|
||||
{confirmHide ? (
|
||||
<VStack gap={1}>
|
||||
{/*
|
||||
The honest description. `publishedat` is read by exactly one
|
||||
query in the whole backend and filtered by none: the till gates
|
||||
on `price > 0 && status != 'outofstock'`, and the customer-app
|
||||
feed does not select the column at all. So this hides the
|
||||
product HERE and nowhere else.
|
||||
*/}
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
|
||||
This hides <strong>{product.productname}</strong> from your shops’ product
|
||||
list. It does <strong>not</strong> take it off the till or out of the customer app
|
||||
— those go by price and stock, not by this setting. To stop selling it, set its
|
||||
stock to zero.
|
||||
</Text>
|
||||
<HStack gap={1} justify="end" wrap="wrap">
|
||||
<Button
|
||||
label="Cancel"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmHide(false)}
|
||||
/>
|
||||
<Button
|
||||
label={unpublish.isPending ? 'Hiding…' : 'Hide from the list'}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => unpublish.mutate()}
|
||||
isDisabled={unpublish.isPending || !tenantid}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<Button
|
||||
label="Hide from the store catalogue"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<EyeOff size={13} />}
|
||||
onClick={() => setConfirmHide(true)}
|
||||
{/* ── Hiding it ─────────────────────────────────────────────────────
|
||||
The confirmation stays in the body rather than the footer: it is the
|
||||
explanation of what the button does, and it has to be read before the
|
||||
decision rather than sit underneath it. */}
|
||||
{canManage && isPublished(product) && confirmHide ? (
|
||||
<Section title="Hide from the store catalogue">
|
||||
<Note tone="brand">
|
||||
{/*
|
||||
The honest description. `publishedat` is read by exactly one
|
||||
query in the whole backend and filtered by none: the till gates on
|
||||
`price > 0 && status != 'outofstock'`, and the customer-app feed
|
||||
does not select the column at all. So this hides the product HERE
|
||||
and nowhere else.
|
||||
*/}
|
||||
This hides <strong>{product.productname}</strong> from your shops’ product list. It
|
||||
does <strong>not</strong> take it off the till or out of the customer app — those go by
|
||||
price and stock, not by this setting. To stop selling it, set its stock to zero.
|
||||
</Note>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<DrawerButton label="Cancel" variant="ghost" onClick={() => setConfirmHide(false)} />
|
||||
<DrawerButton
|
||||
label={unpublish.isPending ? 'Hiding…' : 'Hide from the list'}
|
||||
variant="danger"
|
||||
onClick={() => unpublish.mutate()}
|
||||
isDisabled={unpublish.isPending || !tenantid}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
</Drawer>
|
||||
);
|
||||
@@ -246,9 +258,9 @@ export function ProductDrawer({
|
||||
/**
|
||||
* The catalogue's own reference data.
|
||||
*
|
||||
* Laid out as figure tiles and a card, the same as everything above it — a
|
||||
* two-column key/value list was the thing that made this drawer read as a
|
||||
* different product from the order drawer beside it.
|
||||
* Built from the same Section / Card / Row pieces as everything above it — a
|
||||
* two-column key/value list of its own was the thing that made this drawer read
|
||||
* as a different product from the order drawer beside it.
|
||||
*/
|
||||
function CatalogueExtras({
|
||||
data,
|
||||
@@ -261,121 +273,45 @@ function CatalogueExtras({
|
||||
if (data.providers?.length) lines.push(['Sold by', data.providers.join(', ')]);
|
||||
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<Label>From the global catalogue</Label>
|
||||
|
||||
<Section title="From the global catalogue">
|
||||
{data.size || data.price_range ? (
|
||||
<div className="form-grid">
|
||||
{data.size ? <Figure label="Pack size" value={data.size} /> : null}
|
||||
{data.price_range ? <Figure label="Typical retail" value={data.price_range} /> : null}
|
||||
</div>
|
||||
<Metrics cols={2}>
|
||||
{data.price_range ? <Metric label="Typical retail" value={data.price_range} /> : null}
|
||||
{data.size ? <Metric label="Pack size" value={data.size} isSmall /> : null}
|
||||
</Metrics>
|
||||
) : null}
|
||||
|
||||
{lines.length > 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
{lines.map(([label, value]) => (
|
||||
<Line key={label} label={label} value={value} />
|
||||
))}
|
||||
</VStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{data.highlights?.length ? (
|
||||
<VStack gap={0}>
|
||||
{data.highlights.map((line) => (
|
||||
<Text key={line} type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
• {line}
|
||||
</Text>
|
||||
<DrawerCard>
|
||||
{lines.map(([label, value]) => (
|
||||
<Row
|
||||
key={label}
|
||||
label={label}
|
||||
value={value}
|
||||
{...(label === 'Catalogue category' ? { icon: <BookOpen size={14} /> } : {})}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</DrawerCard>
|
||||
) : null}
|
||||
|
||||
{data.nutrients?.length ? (
|
||||
<VStack gap={1}>
|
||||
<Label>Nutrition</Label>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={2}>
|
||||
{data.nutrients.map((line) => (
|
||||
<Text key={line} type="body" size="sm" color="secondary">
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
{/* Highlights and nutrition side by side — two short lists that each read
|
||||
as a column, rather than one long stack that buries the second. */}
|
||||
{data.highlights?.length || data.nutrients?.length ? (
|
||||
<div className="drawer-bullets">
|
||||
{data.highlights?.length ? (
|
||||
<div className="drawer-section">
|
||||
<h4 className="drawer-section-title">Highlights</h4>
|
||||
<Bullets items={data.highlights} />
|
||||
</div>
|
||||
) : null}
|
||||
{data.nutrients?.length ? (
|
||||
<div className="drawer-section">
|
||||
<h4 className="drawer-section-title">Nutrition</h4>
|
||||
<Bullets items={data.nutrients} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bits, taken from OrderDetailDrawer so the two drawers read alike ────── */
|
||||
|
||||
function Figure({ label, value, isStrong }: { label: string; value: string; isStrong?: boolean }) {
|
||||
return (
|
||||
<VStack gap={0}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
type="label"
|
||||
size={isStrong ? 'lg' : 'sm'}
|
||||
weight="semibold"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Line({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<HStack justify="between" align="start" gap={2}>
|
||||
<Text type="body" size="sm" color="secondary" style={{ flex: 'none' }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text type="body" size="sm" style={{ textAlign: 'right' }}>
|
||||
{value}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
const Label = ({ children }: { children: React.ReactNode }) => (
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
||||
/** The status chip, same shape as the tables use. */
|
||||
function Chip({ label, color }: { label: string; color: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 999,
|
||||
padding: '2px 9px',
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
color,
|
||||
background: `color-mix(in oklab, ${color} 12%, transparent)`,
|
||||
border: `1px solid color-mix(in oklab, ${color} 26%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
@@ -13,6 +12,7 @@ import type { TenantLocation } from '@/api/types';
|
||||
import { usePartners, useRiderShifts } from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from './Drawer';
|
||||
import { DrawerButton } from './drawerKit';
|
||||
import './pages/deliveries.css';
|
||||
|
||||
/**
|
||||
@@ -118,6 +118,19 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
|
||||
subtitle={isNew ? 'They deliver for this shop' : row.contactno}
|
||||
width={480}
|
||||
onClose={onClose}
|
||||
isFooterSpread
|
||||
footer={
|
||||
<>
|
||||
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
|
||||
<DrawerButton
|
||||
label={save.isPending ? 'Saving…' : isNew ? 'Add rider' : 'Save'}
|
||||
variant="primary"
|
||||
icon={<Bike size={15} />}
|
||||
isDisabled={!canSave}
|
||||
onClick={() => save.mutate()}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VStack gap={3}>
|
||||
{/* The region is stated, not offered. If the branch has none, saying so
|
||||
@@ -229,18 +242,6 @@ export function RiderDrawer({ row, tenantid, branch, onClose }: RiderDrawerProps
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} />
|
||||
<Button
|
||||
label={save.isPending ? 'Saving…' : isNew ? 'Add rider' : 'Save'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Bike size={14} />}
|
||||
isDisabled={!canSave}
|
||||
onClick={() => save.mutate()}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* Said before the button, not after. This is the behaviour most likely
|
||||
to be read as a failed save. */}
|
||||
{isNew ? (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { optimiserApi, type SequencedStop } from '@/api/optimiser';
|
||||
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from './Drawer';
|
||||
import { DrawerButton } from './drawerKit';
|
||||
import { buildDeliveries, riderName } from './assignDelivery';
|
||||
import { money } from './format';
|
||||
import { orderValue } from './orderStatus';
|
||||
@@ -166,6 +167,27 @@ export function RoutePlanDrawer({
|
||||
subtitle={`${riderName(rider)} · ${orders.length} order${orders.length === 1 ? '' : 's'}`}
|
||||
width={520}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
|
||||
<span style={{ display: 'flex', gap: 8, marginLeft: 'auto' }}>
|
||||
<DrawerButton
|
||||
label="Re-plan"
|
||||
variant="secondary"
|
||||
icon={<Route size={15} />}
|
||||
isDisabled={sequence.isPending}
|
||||
onClick={() => sequence.mutate()}
|
||||
/>
|
||||
<DrawerButton
|
||||
label={commit.isPending ? 'Assigning…' : 'Assign this round'}
|
||||
variant="primary"
|
||||
icon={<Bike size={15} />}
|
||||
isDisabled={Boolean(blocked) || commit.isPending}
|
||||
onClick={() => commit.mutate()}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VStack gap={3}>
|
||||
{sequence.isPending ? (
|
||||
@@ -261,27 +283,6 @@ export function RoutePlanDrawer({
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} />
|
||||
<HStack gap={1} align="center">
|
||||
<Button
|
||||
label="Re-plan"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Route size={14} />}
|
||||
isDisabled={sequence.isPending}
|
||||
onClick={() => sequence.mutate()}
|
||||
/>
|
||||
<Button
|
||||
label={commit.isPending ? 'Assigning…' : 'Assign this round'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Bike size={14} />}
|
||||
isDisabled={Boolean(blocked) || commit.isPending}
|
||||
onClick={() => commit.mutate()}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{blocked && !sequence.isPending ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
|
||||
575
src/features/store-admin/drawer.css
Normal file
575
src/features/store-admin/drawer.css
Normal file
@@ -0,0 +1,575 @@
|
||||
/* ══ The drawer design system ══════════════════════════════════════════════
|
||||
One shell, one header, one card, one row, one badge, one button — used by
|
||||
every side drawer in the console so they read as one product rather than as
|
||||
several. Everything below is built on the tokens already in `index.css`:
|
||||
#662582 is the Nearle purple, #0f172a the navy, and the ink scale supplies
|
||||
the muted greys. No drawer defines a colour of its own.
|
||||
|
||||
Spacing is an 8px system throughout: 8 / 12 / 16 / 20 / 24. */
|
||||
|
||||
/* ── Shell ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 70;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* A button, not a div: the scrim is the primary way a mouse user dismisses
|
||||
this, so it has to be reachable and to announce itself. Light enough that
|
||||
the list behind stays readable — the operator is comparing the drawer
|
||||
against the row it came from, and blacking that out removes the comparison. */
|
||||
.drawer-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: rgb(15 23 42 / 0.32);
|
||||
cursor: pointer;
|
||||
animation: drawer-fade 180ms ease;
|
||||
}
|
||||
|
||||
.drawer-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Column layout, not `overflow: auto` on the panel — that is what lets the
|
||||
header and the action bar stay put while only the middle scrolls. */
|
||||
height: 100%;
|
||||
max-width: 92vw;
|
||||
background: var(--color-surface);
|
||||
box-shadow: -24px 0 56px -12px rgb(15 23 42 / 0.18);
|
||||
animation: drawer-in 220ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
@keyframes drawer-in {
|
||||
from { transform: translateX(16px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes drawer-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
/* Motion is decoration here; the panel must still arrive. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.drawer-panel, .drawer-scrim { animation: none; }
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-head {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.drawer-head-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
|
||||
.drawer-title {
|
||||
margin: 0;
|
||||
font: 600 21px/1.25 var(--font-sans);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-ink-1);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* An order id is a code, and a code reads better tabular. */
|
||||
.drawer-title[data-mono='true'] { font-family: var(--font-mono); font-size: 20px; }
|
||||
|
||||
.drawer-sub {
|
||||
font: 400 13.5px/1.4 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The row under the title: badge, then any metadata. */
|
||||
.drawer-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.drawer-meta-text { font: 400 13px/1.4 var(--font-sans); color: var(--color-ink-3); }
|
||||
|
||||
.drawer-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-ink-3);
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, color 160ms ease;
|
||||
}
|
||||
.drawer-close:hover { background: var(--color-slate-200); color: var(--color-ink-1); }
|
||||
.drawer-close:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
|
||||
/* ── Body ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 20px 24px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
/* Panels that lay themselves out — a gallery, a form with its own rhythm. */
|
||||
.drawer-body[data-bare='true'] { padding: 0; gap: 0; }
|
||||
|
||||
/* Children keep their own height.
|
||||
|
||||
The body is a column flex container, so by default every child is allowed to
|
||||
SHRINK once the content is taller than the panel — which is always. Anything
|
||||
carrying `overflow: hidden` has a min-content height of zero and therefore
|
||||
collapses completely: the metric grid rendered as a 1px hairline with its
|
||||
figures clipped out of existence, and the cards were being squeezed too. The
|
||||
body scrolls; its children do not compress. */
|
||||
.drawer-body > * { flex: 0 0 auto; }
|
||||
|
||||
/* ── Footer action bar ──────────────────────────────────────────────────── */
|
||||
/* Fixed, so the thing you opened the drawer to DO stays reachable however long
|
||||
the content runs. Rendered only when a drawer actually has actions — an
|
||||
empty bar is a band of chrome that costs height and offers nothing. */
|
||||
.drawer-foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid var(--color-line);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.drawer-foot > * { flex: 0 1 auto; }
|
||||
/* A lone action stretches: a single button hugging the right edge of a 520px
|
||||
panel looks like it was left there by accident. */
|
||||
.drawer-foot[data-fill='true'] > * { flex: 1 1 auto; }
|
||||
/* Destructive on the left, the intended action on the right, with the width of
|
||||
the panel between them. */
|
||||
.drawer-foot[data-spread='true'] { justify-content: space-between; }
|
||||
|
||||
/* ── Section ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-section { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.drawer-section-title {
|
||||
font: 600 11.5px/1 var(--font-sans);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
/* ── Card ───────────────────────────────────────────────────────────────── */
|
||||
/* One radius, one border, everywhere. Rows inside are separated by hairlines
|
||||
rather than by giving each its own card — a stack of cards for four labelled
|
||||
values is the clutter this redesign exists to remove. */
|
||||
.drawer-card {
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.drawer-card[data-tone='subtle'] { background: var(--color-surface-subtle); }
|
||||
.drawer-card[data-tone='brand'] {
|
||||
background: var(--color-brand-tint);
|
||||
border-color: color-mix(in oklab, var(--color-brand) 18%, transparent);
|
||||
}
|
||||
|
||||
/* ── Row: label left, value right ───────────────────────────────────────── */
|
||||
|
||||
.drawer-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: baseline;
|
||||
padding: 11px 14px;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
|
||||
}
|
||||
.drawer-row:last-child { border-bottom: 0; }
|
||||
|
||||
.drawer-row-label {
|
||||
font: 400 13.5px/1.45 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.drawer-row-label svg { color: var(--color-brand); flex: none; }
|
||||
|
||||
/* Right-aligned, tabular, so a column of values lines up on the decimal. */
|
||||
.drawer-row-value {
|
||||
font: 500 14px/1.45 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.drawer-row-value[data-tone='success'] { color: #0f8a5f; font-weight: 600; }
|
||||
.drawer-row-value[data-tone='muted'] { color: var(--color-ink-4); }
|
||||
|
||||
/* A row that wraps its value under the label — for prose, not for figures. */
|
||||
.drawer-row[data-stacked='true'] { grid-template-columns: 1fr; gap: 4px; }
|
||||
.drawer-row[data-stacked='true'] .drawer-row-value { text-align: left; font-weight: 400; }
|
||||
|
||||
/* ── Metric grid ────────────────────────────────────────────────────────── */
|
||||
/* Three equal columns. Equal, not auto: the whole point is that the figures sit
|
||||
on a rhythm, and `auto` columns move them every time a value changes width. */
|
||||
.drawer-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--color-line);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.drawer-metrics[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.drawer-metrics[data-cols='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
|
||||
.drawer-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
background: var(--color-surface);
|
||||
min-width: 0;
|
||||
}
|
||||
.drawer-metric-label {
|
||||
font: 600 11px/1 var(--font-sans);
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
.drawer-metric-value {
|
||||
font: 600 17px/1.2 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.drawer-metric-value[data-size='sm'] { font-size: 14.5px; }
|
||||
.drawer-metric-note { font: 400 12px/1.3 var(--font-sans); color: var(--color-ink-4); }
|
||||
|
||||
/* ── Status badge ───────────────────────────────────────────────────────── */
|
||||
/* One badge, tinted from a single colour, so every status in the console is the
|
||||
same shape and only the hue carries meaning. */
|
||||
.drawer-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font: 600 12px/1.5 var(--font-sans);
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Timeline ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-timeline { display: flex; flex-direction: column; }
|
||||
|
||||
.drawer-step { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; }
|
||||
|
||||
.drawer-step-rail { display: flex; flex-direction: column; align-items: center; }
|
||||
|
||||
.drawer-step-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
background: var(--color-surface-sunken);
|
||||
border: 1px solid var(--color-line);
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
.drawer-step-dot[data-done='true'] {
|
||||
background: var(--color-brand);
|
||||
border-color: var(--color-brand);
|
||||
color: #fff;
|
||||
}
|
||||
/* The step the order is actually sitting on — a ring rather than a fill, so it
|
||||
reads as "here now" and not as "finished". */
|
||||
.drawer-step-dot[data-current='true'] {
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-brand);
|
||||
box-shadow: 0 0 0 4px var(--color-brand-tint);
|
||||
}
|
||||
.drawer-step-dot[data-tone='danger'] {
|
||||
background: color-mix(in oklab, var(--color-error) 14%, transparent);
|
||||
border-color: transparent;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Purple only as far as the journey has actually got. */
|
||||
.drawer-step-line { width: 2px; flex: 1 1 auto; min-height: 20px; background: var(--color-line); }
|
||||
.drawer-step-line[data-done='true'] { background: var(--color-brand); }
|
||||
|
||||
.drawer-step-body { padding-bottom: 14px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.drawer-step:last-child .drawer-step-body { padding-bottom: 0; }
|
||||
.drawer-step-label { font: 500 14px/1.35 var(--font-sans); color: var(--color-ink-4); }
|
||||
.drawer-step-label[data-done='true'] { color: var(--color-ink-1); }
|
||||
.drawer-step-label[data-current='true'] { color: var(--color-brand); font-weight: 600; }
|
||||
.drawer-step-at { font: 400 12.5px/1.35 var(--font-sans); color: var(--color-ink-3); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── Route ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-party { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 12px; padding: 12px 14px; }
|
||||
|
||||
.drawer-party-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.drawer-party-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.drawer-party-kind {
|
||||
font: 600 10.5px/1 var(--font-sans);
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
.drawer-party-name { font: 600 14.5px/1.35 var(--font-sans); color: var(--color-ink-1); overflow-wrap: anywhere; }
|
||||
.drawer-party-detail { font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-3); overflow-wrap: anywhere; }
|
||||
|
||||
/* The phone reads as something you could act on, without pretending to be a
|
||||
button — these drawers are opened on a desktop where tel: does nothing. */
|
||||
.drawer-party-phone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
font: 500 12.5px/1 var(--font-sans);
|
||||
color: var(--color-brand);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-decoration: none;
|
||||
}
|
||||
.drawer-party-phone:hover { text-decoration: underline; }
|
||||
|
||||
/* The arrow between pickup and drop. */
|
||||
.drawer-party-link {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 18px;
|
||||
color: var(--color-ink-4);
|
||||
border-top: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
|
||||
background: var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
/* ── Informational note ─────────────────────────────────────────────────── */
|
||||
/* Deliberately quiet. It explains an absence — no health score, no line items —
|
||||
and an explanation that shouts competes with the data it is apologising for. */
|
||||
.drawer-note {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-line);
|
||||
background: var(--color-surface-subtle);
|
||||
font: 400 13px/1.5 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
.drawer-note svg { color: var(--color-ink-4); margin-top: 1px; }
|
||||
.drawer-note[data-tone='brand'] {
|
||||
background: var(--color-brand-tint);
|
||||
border-color: color-mix(in oklab, var(--color-brand) 16%, transparent);
|
||||
}
|
||||
.drawer-note[data-tone='brand'] svg { color: var(--color-brand); }
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
||||
/* One height, one radius, one type ramp for every button in every drawer. */
|
||||
.drawer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 42px;
|
||||
padding: 0 18px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid transparent;
|
||||
font: 600 14px/1 var(--font-sans);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
.drawer-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
.drawer-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.drawer-btn[data-variant='primary'] { background: var(--color-brand); color: #fff; }
|
||||
.drawer-btn[data-variant='primary']:hover:not(:disabled) { background: var(--color-brand-strong); }
|
||||
|
||||
.drawer-btn[data-variant='secondary'] {
|
||||
background: var(--color-surface);
|
||||
border-color: color-mix(in oklab, var(--color-brand) 35%, transparent);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
.drawer-btn[data-variant='secondary']:hover:not(:disabled) { background: var(--color-brand-tint); }
|
||||
|
||||
/* Outline, never filled: a destructive action should be reachable without
|
||||
being the brightest thing in the drawer. */
|
||||
.drawer-btn[data-variant='danger'] {
|
||||
background: var(--color-surface);
|
||||
border-color: color-mix(in oklab, var(--color-error) 40%, transparent);
|
||||
color: var(--color-error);
|
||||
}
|
||||
.drawer-btn[data-variant='danger']:hover:not(:disabled) {
|
||||
background: color-mix(in oklab, var(--color-error) 8%, transparent);
|
||||
}
|
||||
|
||||
.drawer-btn[data-variant='ghost'] { background: transparent; color: var(--color-ink-2); }
|
||||
.drawer-btn[data-variant='ghost']:hover:not(:disabled) { background: var(--color-surface-sunken); }
|
||||
|
||||
/* A text-weight action that lives inside a section, not in the footer —
|
||||
"Group with another size". */
|
||||
.drawer-link-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: 600 13px/1 var(--font-sans);
|
||||
color: var(--color-brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
.drawer-link-action:hover { text-decoration: underline; }
|
||||
.drawer-link-action:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; }
|
||||
|
||||
/* ── Image area ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-figure {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface-subtle);
|
||||
/* The backstop. A catalogue photo can be 2000px wide, and an auto grid track
|
||||
will happily size to it and push the image straight out of the panel —
|
||||
which is exactly what happened. Nothing leaves this box. */
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Capped rather than stretched: the original ratio is preserved, and a tall
|
||||
bottle does not push the rest of the drawer off screen. Both axes are capped
|
||||
— capping only the height lets a wide image grow sideways instead. */
|
||||
.drawer-figure img {
|
||||
max-height: 200px;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
/* The catalogue's hero, where the pack text has to be legible before you zoom.
|
||||
The cap is in px, not 100%: a percentage max-height resolves against the grid
|
||||
area, which is treated as indefinite here, so the browser drops it entirely —
|
||||
a 646px image sat inside a 260px box, clipped rather than fitted. 228 is the
|
||||
260 box less its 16px padding on each side. */
|
||||
.drawer-figure[data-tall='true'] { height: 260px; }
|
||||
.drawer-figure[data-tall='true'] img { max-height: 228px; }
|
||||
|
||||
.drawer-thumbs { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.drawer-thumb {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-line);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.drawer-thumb img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.drawer-thumb[data-active='true'] {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 2px var(--color-brand-tint);
|
||||
}
|
||||
.drawer-thumb:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
|
||||
/* ── Highlight bullets ──────────────────────────────────────────────────── */
|
||||
|
||||
.drawer-bullets { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
@media (max-width: 520px) { .drawer-bullets { grid-template-columns: 1fr; } }
|
||||
|
||||
.drawer-bullet-list { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; list-style: none; }
|
||||
.drawer-bullet {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
font: 400 13.5px/1.45 var(--font-sans);
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.drawer-bullet::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── Prose ──────────────────────────────────────────────────────────────── */
|
||||
/* 15px and 1.6 — a description is the one thing in a drawer that gets read
|
||||
rather than scanned. */
|
||||
.drawer-prose {
|
||||
font: 400 14.5px/1.6 var(--font-sans);
|
||||
color: var(--color-ink-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.drawer-prose p { margin: 0 0 10px; }
|
||||
.drawer-prose p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* ── Narrower screens ───────────────────────────────────────────────────── */
|
||||
/* The panel is already capped at 92vw; below that the padding is what has to
|
||||
give, or a 520px layout inside a 360px panel just clips. */
|
||||
@media (max-width: 600px) {
|
||||
.drawer-head { padding: 16px 16px 14px; }
|
||||
.drawer-body { padding: 16px; gap: 20px; }
|
||||
.drawer-foot { padding: 12px 16px; }
|
||||
.drawer-title { font-size: 19px; }
|
||||
.drawer-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
/* ── Health score, inside the drawer system ─────────────────────────────── */
|
||||
/* The panel's own pieces — score chip, points, tags — already sit on the brand
|
||||
palette and are kept. What is added here is the wrapper that makes it read as
|
||||
one compact card rather than as a loose stack, which is what the redesign
|
||||
asks of it: informative, and not competing with the product it describes. */
|
||||
.health-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
.health-per {
|
||||
font: 400 12px/1.3 var(--font-sans);
|
||||
color: var(--color-ink-4);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.health-caveat { font: 400 12.5px/1.55 var(--font-sans); color: var(--color-ink-3); }
|
||||
224
src/features/store-admin/drawerKit.tsx
Normal file
224
src/features/store-admin/drawerKit.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import './drawer.css';
|
||||
|
||||
/**
|
||||
* The pieces every drawer is built from.
|
||||
*
|
||||
* A drawer should be assembled out of these and should not style anything
|
||||
* itself. That is the whole point: the console had eleven drawers that had each
|
||||
* drifted — different header, different card, different badge — and the way to
|
||||
* stop that happening again is for there to be nothing local to drift.
|
||||
*
|
||||
* Everything reads from `drawer.css`, which reads from the brand tokens in
|
||||
* `index.css`. No component here names a colour.
|
||||
*/
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
/** A text action beside the title — "Group with another size". */
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="drawer-section">
|
||||
{title || action ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
{title ? <h3 className="drawer-section-title">{title}</h3> : <span />}
|
||||
{action}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Card and rows ───────────────────────────────────────────────────────── */
|
||||
|
||||
export function DrawerCard({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone?: 'subtle' | 'brand';
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="drawer-card" {...(tone ? { 'data-tone': tone } : {})}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled value: label left, value right, hairline below.
|
||||
*
|
||||
* `isStacked` puts the value under the label instead — for prose, where a
|
||||
* right-aligned paragraph would be unreadable.
|
||||
*/
|
||||
export function Row({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
tone,
|
||||
isStacked,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
icon?: ReactNode;
|
||||
tone?: 'success' | 'muted';
|
||||
isStacked?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="drawer-row" {...(isStacked ? { 'data-stacked': 'true' } : {})}>
|
||||
<span className="drawer-row-label">
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
<span className="drawer-row-value" {...(tone ? { 'data-tone': tone } : {})}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Metric grid ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export function Metrics({ cols, children }: { cols?: 2 | 3 | 4; children: ReactNode }) {
|
||||
return (
|
||||
<div className="drawer-metrics" {...(cols && cols !== 3 ? { 'data-cols': String(cols) } : {})}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Metric({
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
isSmall,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
note?: ReactNode;
|
||||
isSmall?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="drawer-metric">
|
||||
<span className="drawer-metric-label">{label}</span>
|
||||
<span className="drawer-metric-value" {...(isSmall ? { 'data-size': 'sm' } : {})}>
|
||||
{value}
|
||||
</span>
|
||||
{note ? <span className="drawer-metric-note">{note}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Status badge ────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* One badge shape for every status in the console.
|
||||
*
|
||||
* The colour is passed in rather than derived here, because the status
|
||||
* vocabularies already live in `orderStatus.ts` — orders and deliveries use
|
||||
* different words, and duplicating that mapping is how the two drift apart.
|
||||
*/
|
||||
export function Badge({ label, colour }: { label: string; colour: string }) {
|
||||
return (
|
||||
<span
|
||||
className="drawer-badge"
|
||||
style={{ color: colour, background: `color-mix(in oklab, ${colour} 12%, transparent)` }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Note ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function Note({
|
||||
children,
|
||||
tone,
|
||||
icon,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: 'brand';
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="drawer-note" {...(tone ? { 'data-tone': tone } : {})}>
|
||||
{icon ?? <Info size={15} />}
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function DrawerButton({
|
||||
label,
|
||||
variant = 'secondary',
|
||||
icon,
|
||||
isDisabled,
|
||||
onClick,
|
||||
type = 'button',
|
||||
}: {
|
||||
label: string;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
icon?: ReactNode;
|
||||
isDisabled?: boolean;
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit';
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type === 'submit' ? 'submit' : 'button'}
|
||||
className="drawer-btn"
|
||||
data-variant={variant}
|
||||
disabled={isDisabled}
|
||||
{...(onClick ? { onClick } : {})}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A text-weight action inside a section rather than in the footer. */
|
||||
export function LinkAction({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
isDisabled,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
onClick: () => void;
|
||||
isDisabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className="drawer-link-action" onClick={onClick} disabled={isDisabled}>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bullets ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function Bullets({ items }: { items: readonly string[] }) {
|
||||
return (
|
||||
<ul className="drawer-bullet-list">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="drawer-bullet">
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { UploadsPanel } from '@/features/uploads/UploadsPanel';
|
||||
import { SheetUploadDrawer } from '../SheetUploadDrawer';
|
||||
|
||||
/**
|
||||
* This merchant's uploads.
|
||||
@@ -19,6 +21,7 @@ import { UploadsPanel } from '@/features/uploads/UploadsPanel';
|
||||
export function UploadsPage() {
|
||||
const { user } = useAuth();
|
||||
const { selected } = useBranchScope();
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
@@ -28,8 +31,19 @@ export function UploadsPage() {
|
||||
/>
|
||||
<UploadsPanel
|
||||
tenantid={user?.tenantid ?? 0}
|
||||
onUpload={() => setUploading(true)}
|
||||
{...(selected === null ? {} : { locationid: selected })}
|
||||
/>
|
||||
{/* The same drawer Inventory opens. Uploading from the page that lists
|
||||
uploads is the obvious place for it, and routing somebody to another
|
||||
screen to press a second button is not a flow, it is a detour. */}
|
||||
{isUploading ? (
|
||||
<SheetUploadDrawer
|
||||
tenantid={user?.tenantid}
|
||||
locationid={selected ?? user?.locationid}
|
||||
onClose={() => setUploading(false)}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
445
src/features/uploads/UploadDetailDrawer.tsx
Normal file
445
src/features/uploads/UploadDetailDrawer.tsx
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
262
src/features/uploads/uploadState.test.ts
Normal file
262
src/features/uploads/uploadState.test.ts
Normal 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');
|
||||
});
|
||||
281
src/features/uploads/uploadState.ts
Normal file
281
src/features/uploads/uploadState.ts
Normal 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`;
|
||||
}
|
||||
272
src/features/uploads/uploads.css
Normal file
272
src/features/uploads/uploads.css
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user