drawer and upload
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user