/** * 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 { 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 { FileSpreadsheet, UploadCloud } from 'lucide-react'; import { errorMessage } from '@/api/client'; import { fetchBatch, isDismissed, isSettled, ACCEPTED_EXTENSIONS, productsOf, resolveBatch, releasedRunId, type IngestBatch, type IngestProduct, } from '@/api/ingest'; import { uploadsApi, type UploadReceipt } from '@/api/uploads'; import type { DateRange } from '@/api/insights'; 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 { 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 { /** * 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 date range, owned by the page header. * * Every page in the console filters by date from the same control in the same * place; a second pair of date boxes inside one page's own toolbar meant this * screen alone asked the question twice, in a different shape, a few pixels * lower down. */ range?: DateRange; /** Clears the header's date filter, offered by the empty state. */ onClearRange?: () => void; /** * 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. */ interface LiveReading { batch: IngestBatch; /** Narrowed to the file this receipt is for — see the note on `productsOf`. */ products: IngestProduct[]; } export function UploadsPanel({ tenantid, locationid, range, onClearRange, onUpload, }: UploadsPanelProps) { const [receipts, setReceipts] = useState(null); const [error, setError] = useState(null); const [live, setLive] = useState>({}); const [detail, setDetail] = useState(null); const [tab, setTab] = useState('all'); const [search, setSearch] = useState(''); const from = range?.fromdate ?? ''; const to = range?.todate ?? ''; 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[]) => { 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 })); }, [], ); useEffect(() => { if (receipts && receipts.length > 0) void refreshLive(receipts); }, [receipts, refreshLive]); /** Receipts that could still change. A finished, shelved one never will. */ const moving = useMemo( () => (receipts ?? []).filter((receipt) => !(receipt.laststatus === 'done' && receipt.shelvedat)), [receipts], ); /** * Keeps the moving rows current without anyone reloading the page. * * The list used to be read once, when the page loaded. Everything after that * — their admin releasing a drop, the run finishing, products reaching the * shelf — arrived only if the operator reloaded the page, which is exactly * what a status page exists to avoid. The "Check again" button that used to * paper over it is gone with the same change. * * Fifteen seconds, and only while something is unfinished: a page of forty * settled receipts makes no requests at all. It also pauses entirely while * the tab is in the background and takes a reading the moment it comes * forward, so a tab left open all afternoon costs nothing and is up to date * by the time it is looked at. */ useEffect(() => { if (moving.length === 0) return; let stopped = false; let timer: ReturnType; const read = () => { if (stopped || document.visibilityState !== 'visible') return; void refreshLive(moving); }; const loop = () => { read(); timer = setTimeout(loop, 15000); }; timer = setTimeout(loop, 15000); // Coming back to the tab is worth a reading straight away — that is the // moment somebody wants the answer. const onVisible = () => read(); document.addEventListener('visibilitychange', onVisible); return () => { stopped = true; clearTimeout(timer); document.removeEventListener('visibilitychange', onVisible); }; }, [moving, 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 ignore the TAB but respect every other filter. Ignoring the tab is the point — a tab reporting the size of its own filtered view would always equal the list under it and could never tell you there is a failure worth looking at. Respecting the search and the date is equally the point, and was missed: with a date range that excluded everything, the list said "Nothing matches" while the tab beside it said two, and the two contradicted each other on the same screen. */ const counts = useMemo(() => { const scoped = rows.filter( (receipt) => matchesSearch(receipt, search) && matchesDate(receipt, from, to), ); const out = { all: scoped.length, done: 0, failed: 0, running: 0 }; for (const receipt of scoped) { 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, search, from, to]); const open = shown.find((receipt) => receipt.batchid === detail) ?? null; const paged = usePaged(shown, { resetKey: `${tab}|${search}|${from}|${to}` }); if (error) { return ( {error} ))}
setSearch(event.target.value)} /> {/* "Check again" stood here. It is gone because the page reads the moving rows by itself now — every fifteen seconds while something is unfinished, and again the moment the tab comes forward. A refresh button beside a list that refreshes itself is a button that never changes anything, and pressing it to find out whether it did is worse than not having it. */} {onUpload ? (
{/* ── The list ─────────────────────────────────────────────────────── */} {shown.length === 0 ? ( Nothing matches {/* The date range is named as the likely cause, with the way out. This page opens on the month like every other, and a receipt log's most-wanted row is often older than that — an empty list that does not say which filter emptied it sends people to the refresh button. */} {rows.length > 0 && (from || to) ? `${rows.length} upload${rows.length === 1 ? ' is' : 's are'} outside the selected date range.` : 'No upload matches this tab, search or date range.'} {onClearRange && (from || to) ? (