drawer and upload

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

View File

@@ -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>
);
}