/** * Every spreadsheet this shop has sent to the catalogue ingest service, and * what became of it. * * One component for all three workspaces. The question — "did my upload land?" * — is identical for a Nearle Admin, a Store Admin and a Store user; only the * scope differs, and that arrives as props rather than being re-decided here. * * ── Why this screen exists ────────────────────────────────────────────────── * * Before it, a confirmation lived in React state in one browser tab. That was * survivable only if uploads finished while you watched, and they do not: * nothing runs on arrival at the ingest service. A drop waits in a review inbox * until one of their admins presses Start — hours, sometimes days — by which * time the tab is long closed and the batch id, which is the ONLY credential * for reading the result, is gone with it. * * ── Two systems, read together ────────────────────────────────────────────── * * Fiesta holds the receipt: the batch id, which shop the sheet was for, who * sent it, and whether the products reached that shop's shelf. The ingest * service holds what became of the drop and is the only authority on it. * * So the list renders from Fiesta immediately — twenty receipts, no waiting on * a host that spends minutes per batch — and each unfinished row is then * refreshed against the ingest service in the background. What comes back is * written home, so the next person to open this page sees it without the wait. */ 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 { errorMessage } from '@/api/client'; import { currentStage, fetchBatch, isAwaitingReview, isDismissed, isSettled, isStuckOnMissingRunner, productsOf, resolveBatch, releasedRunId, type IngestBatch, type IngestProduct, } from '@/api/ingest'; import { daysUntilExpiry, uploadsApi, type UploadReceipt } from '@/api/uploads'; import { SectionHeader } from '@/components/SectionHeader'; export interface UploadsPanelProps { /** * Whose uploads to show. Omit — or pass 0 — for every merchant, which is what * a Nearle Admin sees and what nobody else may. */ tenantid?: number; /** Narrow to one branch. A Store user is pinned to theirs. */ locationid?: number; } /** The live reading for one receipt, once we have been able to take one. */ interface LiveReading { batch: IngestBatch; /** Narrowed to the file this receipt is for — see the note on `productsOf`. */ products: IngestProduct[]; } export function UploadsPanel({ tenantid, locationid }: UploadsPanelProps) { const [receipts, setReceipts] = useState(null); const [error, setError] = useState(null); const [live, setLive] = useState>({}); const [expanded, setExpanded] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); const load = useCallback(async () => { setError(null); try { setReceipts(await uploadsApi.list({ tenantid, locationid, pagesize: 50 })); } catch (cause) { setError(errorMessage(cause)); } }, [tenantid, locationid]); useEffect(() => { void load(); }, [load]); /** * Refreshes the receipts that are still moving, against the ingest service. * * Anonymous, and that is not laziness — it is what their documentation tells * integrators to do. Their ownership filter matches `sender` EXACTLY, and a * run an admin assembles from several drops carries a joined list * ("alice, bob"), so a caller presenting a credential gets a 404 on a run * containing their own file while the identical anonymous request returns * 200. The batch id is the credential here; sending anything else makes the * read fail. * * Settled receipts are skipped. A finished run does not change, and re-asking * for forty of them would spend a shop's connection confirming what Fiesta * already knows. */ const refreshLive = useCallback( async (rows: UploadReceipt[]) => { setIsRefreshing(true); const readings: Record = {}; for (const receipt of rows) { // Nothing further will arrive for these; the receipt is the answer. if (receipt.laststatus === 'done' && receipt.shelvedat) continue; try { // The DROP first, then the run it became. Both matter and they are // not interchangeable: `released_to` lives on the drop's files and is // gone from the run, so reading the run and asking it for a run id // gets null — and the pointer from the id we hold to the id with the // results would never be saved. const drop = await fetchBatch(receipt.batchid); const runid = releasedRunId(drop) ?? ''; const batch = await resolveBatch(drop); readings[receipt.batchid] = { batch, products: productsOf(batch, receipt.filename ? [receipt.filename] : undefined), }; // Write it home, so the next reader does not repeat the wait. Failing // here is not worth surfacing: the live reading is already on screen // and the cache is only an optimisation for somebody else later. void uploadsApi .updateStatus({ batchid: receipt.batchid, laststatus: batch.status, ...(runid ? { runid } : {}), inserted: batch.totals?.inserted ?? 0, backfilled: batch.totals?.backfilled ?? 0, skipped: batch.totals?.skipped_existing ?? 0, rejected: batch.totals?.rejected ?? 0, }) .catch(() => {}); } catch { /* A drop the service has forgotten — past its seven-day retention, or an id it never issued — reads as 404. The receipt survives and says so; that is precisely why the receipt exists. */ } } setLive((prev) => ({ ...prev, ...readings })); setIsRefreshing(false); }, [], ); useEffect(() => { if (receipts && receipts.length > 0) void refreshLive(receipts); }, [receipts, refreshLive]); const rows = useMemo(() => receipts ?? [], [receipts]); if (error) { return ( {error}