diff --git a/src/App.tsx b/src/App.tsx
index e634d41..a44c6c9 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -32,6 +32,7 @@ const StoresPage = named('StoresPage', () => import('@/features/nearle-admin/pag
const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage'));
const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/nearle-admin/pages/OnboardTenantPage'));
const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage'));
+const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage'));
const ConsolePage = named('ConsolePage', () => import('@/features/store-admin/pages/ConsolePage'));
const SalesPage = named('SalesPage', () => import('@/features/store-admin/pages/SalesPage'));
@@ -40,6 +41,7 @@ const ReportsPage = named('ReportsPage', () => import('@/features/store-admin/pa
const OnboardBranchPage = named('OnboardBranchPage', () => import('@/features/store-admin/pages/OnboardBranchPage'));
const UsersPage = named('UsersPage', () => import('@/features/store-admin/pages/UsersPage'));
const TerminalsPage = named('TerminalsPage', () => import('@/features/store-admin/pages/TerminalsPage'));
+const AdminUploadsPage = named('UploadsPage', () => import('@/features/store-admin/pages/UploadsPage'));
/* The Store user workspace reuses the merchant's four pages, pinned to one
branch by `BranchScopeProvider pin=`. Only what a shop does differently is
@@ -48,6 +50,7 @@ const StoreProductsPage = named('StoreProductsPage', () => import('@/features/st
const StoreCustomersPage = named('StoreCustomersPage', () => import('@/features/store-user/pages/StoreCustomersPage'));
const StoreStaffPage = named('StoreStaffPage', () => import('@/features/store-user/pages/StoreStaffPage'));
const StoreAccountPage = named('StoreAccountPage', () => import('@/features/store-user/pages/StoreAccountPage'));
+const StoreUploadsPage = named('StoreUploadsPage', () => import('@/features/store-user/pages/StoreUploadsPage'));
function RouteFallback() {
return (
@@ -92,6 +95,7 @@ export function App() {
bookmark or a stale link lands on the directory instead of a 404. */}
} />
} />
+ } />
{/* Absorbed here rather than by the global `*`, so a wrong sub-path can
never bounce out to a HOME_ROUTE that points back into this
workspace and loop. */}
@@ -119,6 +123,7 @@ export function App() {
anyone works from day to day. See `AppShellProps.manageItems`. */}
} />
} />
+ } />
{/* Catches `/admin/dashboard` and anything else that does not resolve.
Without this, an unknown sub-path escapes to the global `*`, which
redirects to this role's HOME_ROUTE — and if that is itself an
@@ -149,6 +154,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/src/api/client.ts b/src/api/client.ts
index f7d48cc..f4cce1a 100644
--- a/src/api/client.ts
+++ b/src/api/client.ts
@@ -39,7 +39,15 @@ import type { FiestaEnvelope } from './types';
* Override per machine with `.env.local`, which is gitignored — set it to
* `/fiesta` to route through the dev proxy or nginx instead.
*/
-const configuredBase = (import.meta.env['VITE_API_BASE'] ?? '').trim();
+/**
+ * Optional-chained for the same reason `ingest.ts` is: `import.meta.env` is
+ * Vite's, and it is undefined anywhere Vite is not — the test runner included.
+ * Without the `?.` this line throws on import, so every test that so much as
+ * names a module reaching this one fails before it runs, with a TypeError
+ * pointing here rather than at the test. The value already has a fallback; this
+ * only stops the read itself from being fatal.
+ */
+const configuredBase = (import.meta.env?.['VITE_API_BASE'] ?? '').trim();
export const API_BASE = (configuredBase || 'https://fiesta.nearle.app').replace(
/\/+$/,
diff --git a/src/api/ingest.test.ts b/src/api/ingest.test.ts
index d1e0e71..6106f68 100644
--- a/src/api/ingest.test.ts
+++ b/src/api/ingest.test.ts
@@ -10,6 +10,8 @@ import {
isAwaitingReview,
isDismissed,
isSettled,
+ currentStage,
+ isStuckOnMissingRunner,
productsOf,
releasedRunId,
summarise,
@@ -203,3 +205,132 @@ test('only our own file contributes products', () => {
// and every caller that prices products must make it.
assert.equal(productsOf(run).length, 2);
});
+
+/*
+The stage timeline, and the runner that silently isn't there.
+
+Both arrived with the ingest team's 31 Aug documentation update. The timeline is
+what lets the console draw the real eleven stages instead of a file-count bar;
+the runner is a trap, and the more important of the two.
+*/
+
+const runningFile = {
+ index: 0,
+ filename: 'catalog.csv',
+ status: 'running' as const,
+ stage_index: 6,
+ stage_name: 'Image Search & Contamination Filtering',
+ total_stages: 11,
+ rows_done: 120,
+ rows_total: 400,
+ stages: [
+ {
+ index: 1,
+ name: 'Brand Resolution & FSSAI Licence Mapping',
+ rows_done: 400,
+ rows_total: 400,
+ started_at: 1756612800.1,
+ finished_at: 1756612801.4,
+ },
+ {
+ index: 6,
+ name: 'Image Search & Contamination Filtering',
+ rows_done: 120,
+ rows_total: 400,
+ started_at: 1756612809.7,
+ finished_at: null,
+ },
+ ],
+};
+
+// `finished_at: null` is the marker, not the last array entry and not
+// `stage_index`. Reading the position any other way breaks the moment a stage
+// completes out of order or the array carries a trailing finished entry.
+test('the running stage is the one with no finish time', () => {
+ const stage = currentStage(runningFile);
+ assert.equal(stage?.index, 6);
+ assert.equal(stage?.rows_done, 120);
+});
+
+// A finished file keeps its history, which is the whole reason the timeline
+// exists — the scalars only ever describe the present moment, and for a
+// finished file that moment is over.
+test('a finished file still reports its last stage', () => {
+ const done = {
+ ...runningFile,
+ status: 'done' as const,
+ stages: runningFile.stages.map((s) => ({ ...s, finished_at: s.finished_at ?? 1756612900.0 })),
+ };
+ assert.equal(currentStage(done)?.index, 6);
+});
+
+// A service build that predates the timeline still has to render. The scalars
+// are the fallback, not the source of truth.
+test('a response without a timeline falls back to the scalars', () => {
+ const { stages: _stages, ...noTimeline } = runningFile;
+ const stage = currentStage(noTimeline);
+ assert.equal(stage?.index, 6);
+ assert.equal(stage?.name, 'Image Search & Contamination Filtering');
+});
+
+test('a file that has not started reports no stage at all', () => {
+ assert.equal(currentStage({ index: 0, filename: 'a.csv', status: 'queued' }), null);
+});
+
+/*
+`runner: "dagster"` never runs in production — Dagster is a development tool,
+absent from the deployed image — so the batch waits for a worker that will never
+claim it. Every visible signal is identical to a batch merely waiting its turn,
+which is exactly why it has to be named rather than rendered as progress.
+*/
+test('a batch staged for the absent orchestrator is called out', () => {
+ assert.equal(isStuckOnMissingRunner({ ...held, status: 'queued', runner: 'dagster' }), true);
+});
+
+test('the in-process runner is not a stall', () => {
+ assert.equal(isStuckOnMissingRunner({ ...held, status: 'queued', runner: 'inprocess' }), false);
+});
+
+// A batch that reached `running` plainly found an executor, whatever it was
+// staged for. Warning then would contradict the progress on screen.
+test('a batch already running is not stuck, whatever it was staged for', () => {
+ assert.equal(isStuckOnMissingRunner({ ...held, status: 'running', runner: 'dagster' }), false);
+});
+
+/*
+A drop is not a run, and the difference is easy to lose.
+
+`released_to` lives on the DROP's files. Once an admin releases it, following
+that pointer lands on the run — and the run carries no `released_to` of its own,
+because nothing released it. So a caller who resolves first and asks for the run
+id second gets null, and the only pointer from the id they hold to the id with
+the results is never recorded.
+
+This cost a real bug in both directions: the Uploads page never saved a run id,
+and the import panel keyed the shelving write on `batch.batch_id` — which by
+then was the run — updating a receipt row that does not exist, silently.
+*/
+test('the run id is on the drop, and gone from the run it points to', () => {
+ const drop = {
+ ...held,
+ batch_id: 'drop-1',
+ status: 'retired' as const,
+ files: [
+ { index: 0, filename: 'catalog.csv', status: 'released' as const, released_to: 'run-1' },
+ ],
+ };
+ assert.equal(releasedRunId(drop), 'run-1');
+
+ // The same question asked of the run answers null. Read the drop first.
+ const run = {
+ ...held,
+ batch_id: 'run-1',
+ status: 'done' as const,
+ files: [{ index: 0, filename: 'catalog.csv', status: 'done' as const }],
+ };
+ assert.equal(releasedRunId(run), null);
+
+ // And the two ids differ, which is exactly why a receipt keyed on the drop
+ // cannot be written using the run's.
+ assert.notEqual(drop.batch_id, run.batch_id);
+});
diff --git a/src/api/ingest.ts b/src/api/ingest.ts
index 8ec72aa..f84efe0 100644
--- a/src/api/ingest.ts
+++ b/src/api/ingest.ts
@@ -172,6 +172,25 @@ export interface BatchFileResult {
products_truncated?: boolean;
}
+/**
+ * One stage a file has entered, from the run's own timeline.
+ *
+ * `finished_at: null` marks the stage running RIGHT NOW — that is how the
+ * current position is found, not by trusting `stage_index` alone. The array
+ * persists after the run ends, so a finished file can still show its whole
+ * history; the scalars on the file only ever describe the present moment, which
+ * is why they are not enough on their own.
+ */
+export interface BatchStage {
+ index: number;
+ name: string;
+ rows_done?: number;
+ rows_total?: number;
+ /** Epoch SECONDS as a float, like every other timestamp here. */
+ started_at?: number;
+ finished_at?: number | null;
+}
+
export interface BatchFile {
index: number;
filename: string;
@@ -194,6 +213,8 @@ export interface BatchFile {
stage_name?: string;
total_stages?: number;
rows_done?: number;
+ /** The stages this file has entered, oldest first. */
+ stages?: BatchStage[];
result?: BatchFileResult | null;
}
@@ -220,6 +241,26 @@ export interface IngestBatch {
current_file?: string | null;
use_llm?: boolean;
fetch_images?: boolean;
+ /**
+ * All eleven stage names, in order.
+ *
+ * Served rather than left for us to hardcode, deliberately — draw the
+ * pipeline from this and the console cannot drift out of step when a stage is
+ * added or renamed on their side.
+ */
+ stage_names?: string[];
+ /**
+ * Who is executing the batch.
+ *
+ * `"dagster"` is a silent failure in production and has to be surfaced rather
+ * than rendered as progress. Dagster is a local development orchestrator — it
+ * is absent from the deployed image, which never copies `orchestration/` — so
+ * a batch staged for it is handed to nobody and parks at `queued` forever
+ * saying "Waiting for the Dagster orchestrator to pick this batch up". From
+ * outside that is indistinguishable from a hang, and the fix is not to wait:
+ * an admin resumes it onto the in-process worker.
+ */
+ runner?: 'inprocess' | 'dagster' | string;
totals: BatchTotals;
/** The brands this batch touched — the way back into the catalogue view. */
brands: string[];
@@ -674,3 +715,49 @@ export function summarise(batch: IngestBatch): string {
export function progressOf(batch: IngestBatch): { done: number; total: number } {
return { done: batch.files_done + batch.files_failed, total: batch.files_total };
}
+
+/**
+ * True when the batch was handed to an orchestrator that is not there.
+ *
+ * `runner: "dagster"` never runs in production: Dagster is a development tool,
+ * absent from the deployed image, so the batch waits for a worker that will
+ * never claim it and sits at `queued` indefinitely. It has to be named, because
+ * every visible signal — a queued status, a stage index of 0, a progress bar at
+ * nothing — is identical to a batch that is merely waiting its turn.
+ *
+ * Only meaningful while it is still waiting. A batch that reached `running`
+ * plainly found an executor whatever it was staged for.
+ */
+export function isStuckOnMissingRunner(batch: IngestBatch): boolean {
+ return batch.runner === 'dagster' && (batch.status === 'queued' || batch.status === 'pending');
+}
+
+/**
+ * Where a file is in the eleven stages, read from the timeline rather than the
+ * scalars.
+ *
+ * `stages[]` is the authority: the entry with `finished_at: null` is the stage
+ * running now. `stage_index`/`stage_name` describe the same moment and are used
+ * as a fallback for a service build that does not send the timeline, but they
+ * cannot show a finished file's history and the timeline can.
+ *
+ * Returns null when there is nothing to draw — a file that has not started, or
+ * one from a response carrying neither.
+ */
+export function currentStage(file: BatchFile): BatchStage | null {
+ const running = (file.stages ?? []).find((stage) => stage.finished_at == null);
+ if (running) return running;
+
+ // Finished, or a build without the timeline. The last entered stage is the
+ // most useful thing to show for a file that has stopped moving.
+ const last = (file.stages ?? []).at(-1);
+ if (last) return last;
+
+ if (!file.stage_index) return null;
+ return {
+ index: file.stage_index,
+ name: file.stage_name ?? `Stage ${file.stage_index}`,
+ ...(file.rows_done === undefined ? {} : { rows_done: file.rows_done }),
+ ...(file.rows_total === undefined ? {} : { rows_total: file.rows_total }),
+ };
+}
diff --git a/src/api/uploads.test.ts b/src/api/uploads.test.ts
new file mode 100644
index 0000000..e907899
--- /dev/null
+++ b/src/api/uploads.test.ts
@@ -0,0 +1,100 @@
+/**
+ * The receipt, and the two things it has to get right.
+ *
+ * A receipt exists because the ingest service's batch id is the only credential
+ * for reading a result back, it is handed out once, and an unreviewed drop is
+ * deleted after seven days. So the tests that matter are about not losing that
+ * window, and about the label their admin reads when deciding whether to
+ * approve the file.
+ */
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { buildSender, daysUntilExpiry, DROP_RETENTION_DAYS, type UploadReceipt } from './uploads';
+
+function receipt(overrides: Partial = {}): UploadReceipt {
+ return {
+ uploadid: 1,
+ tenantid: 1141,
+ locationid: 1180,
+ categoryid: 2,
+ batchid: '49a82536866a483a9189954d3c749243',
+ runid: '',
+ filename: 'kmart-opening.xlsx',
+ sender: 'Kmart · Peelamedu · abhishek',
+ uploadedby: 1475,
+ uploadedname: 'abhishek',
+ rowcount: 20,
+ laststatus: 'pending',
+ inserted: 0,
+ backfilled: 0,
+ skipped: 0,
+ rejected: 0,
+ shelvedcount: 0,
+ skippedcount: 0,
+ shelvedat: null,
+ created: new Date().toISOString(),
+ updated: new Date().toISOString(),
+ ...overrides,
+ };
+}
+
+const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString();
+
+test('a drop uploaded today has the full window left', () => {
+ assert.equal(daysUntilExpiry(receipt()), DROP_RETENTION_DAYS);
+});
+
+test('the window closes as the drop sits unreviewed', () => {
+ assert.equal(daysUntilExpiry(receipt({ created: daysAgo(5) })), 2);
+});
+
+// Never negative. A drop past the deadline is gone, and "-3 days left" would
+// read as a countdown that is still running.
+test('an expired drop reports zero, not a negative', () => {
+ assert.equal(daysUntilExpiry(receipt({ created: daysAgo(30) })), 0);
+});
+
+/*
+Retention applies to a drop nobody acted on. Once an admin releases it the run
+is the record and the drop's own expiry is irrelevant — showing a countdown on
+a released upload would push someone to chase a deadline that has already been
+met.
+*/
+test('a released drop has no expiry to report', () => {
+ assert.equal(daysUntilExpiry(receipt({ runid: '8dcef8a2ad94', laststatus: 'retired' })), null);
+ assert.equal(daysUntilExpiry(receipt({ laststatus: 'done' })), null);
+});
+
+test('a receipt with an unreadable date reports nothing rather than guessing', () => {
+ assert.equal(daysUntilExpiry(receipt({ created: 'not a date' })), null);
+});
+
+/*
+The sender label. Free text on their side, capped at 60 characters, and shown to
+the admin who decides whether to run the file — so it has to identify the shop,
+not the console.
+*/
+test('the sender names the merchant, the branch and the person', () => {
+ assert.equal(
+ buildSender({ tenantname: 'Kmart', locationname: 'Peelamedu', username: 'abhishek' }),
+ 'Kmart · Peelamedu · abhishek',
+ );
+});
+
+// The tenant name is truncated, not the whole label. Cutting the tail would
+// drop the branch and the person — the two parts that say WHICH shelf and WHO —
+// and leave only a long restaurant name that identifies neither.
+test('a long merchant name is trimmed so the branch and person survive', () => {
+ const sender = buildSender({
+ tenantname: 'Ninhao The New Age Chinese Restaurant',
+ locationname: 'Race Course',
+ username: 'abhishek',
+ });
+ assert.ok(sender.length <= 60, `sender was ${sender.length} chars: ${sender}`);
+ assert.ok(sender.includes('Race Course'), sender);
+ assert.ok(sender.includes('abhishek'), sender);
+});
+
+test('a sender with nothing to say still labels the console', () => {
+ assert.equal(buildSender({}), 'nearle-console');
+});
diff --git a/src/api/uploads.ts b/src/api/uploads.ts
new file mode 100644
index 0000000..5e8094e
--- /dev/null
+++ b/src/api/uploads.ts
@@ -0,0 +1,191 @@
+/**
+ * Receipts for spreadsheets sent to the catalogue ingest service.
+ *
+ * This is OUR record, in Fiesta — not the ingest service's. The two are read
+ * together and neither is redundant:
+ *
+ * - **Fiesta** knows the batch id, which shop the sheet was for, who sent it,
+ * and whether the products reached that shop's shelf. None of which the
+ * ingest service has any concept of — it writes the shared global
+ * catalogue and has no tenant and no branch.
+ * - **The ingest service** knows what became of the drop, and is the only
+ * authority on that.
+ *
+ * Fiesta's status columns are a CACHE of the second, written by whichever
+ * browser last polled. They exist so a list of twenty receipts renders without
+ * twenty network calls to a host that spends minutes per batch; the live read
+ * is what any single receipt is judged by.
+ *
+ * ── Why the receipt has to exist at all ──────────────────────────────────────
+ *
+ * Three facts from the ingest service's own documentation, and any one of them
+ * would be enough:
+ *
+ * 1. **The batch id is the credential.** `GET /api/uploads/catalog/{id}` is
+ * anonymous by design — holding the id is the proof of having sent the
+ * drop. Handed out once, to one browser. Lose it and the result is
+ * unreadable by anyone, including whoever uploaded the file.
+ * 2. **An unreviewed drop is deleted after seven days.** Nothing runs on
+ * arrival; a drop waits for an admin to press Start. If nobody does, the
+ * evidence expires.
+ * 3. **We cannot list our own drops.** `GET /api/uploads/catalog` is scoped to
+ * the credential that sent them, production has no API keys configured at
+ * all, and the only account that could read it is a superuser over their
+ * entire application.
+ */
+
+import { api, WEB } from './client';
+
+/**
+ * One upload, as Fiesta stores it.
+ *
+ * The two count groups are deliberately not merged. `inserted` and its
+ * neighbours are the ingest service's — products in the GLOBAL catalogue, which
+ * every merchant shares and which therefore carries no price and no stock.
+ * `shelvedcount` is ours: priced, on a branch's shelf, with opening stock
+ * recorded. A product can be in the first and not the second, and reporting it
+ * as "added" would tell a shopkeeper they can sell something nobody can buy.
+ */
+export interface UploadReceipt {
+ uploadid: number;
+ tenantid: number;
+ locationid: number;
+ categoryid: number;
+
+ /** The drop id. The only field here that cannot be reconstructed. */
+ batchid: string;
+ /** The run an admin released the drop into, once they have. */
+ runid: string;
+
+ filename: string;
+ /** The label their review inbox shows. */
+ sender: string;
+
+ uploadedby: number;
+ uploadedname: string;
+ /** Rows we parsed before sending — independent of anything the service says. */
+ rowcount: number;
+
+ /* ── Cached from the ingest service ──────────────────────────────────── */
+ laststatus: string;
+ inserted: number;
+ backfilled: number;
+ skipped: number;
+ rejected: number;
+
+ /* ── Ours ────────────────────────────────────────────────────────────── */
+ shelvedcount: number;
+ skippedcount: number;
+ shelvedat: string | null;
+
+ created: string;
+ updated: string;
+
+ /** Joined for display; a receipt outlives the page that made it. */
+ tenantname?: string;
+ locationname?: string;
+}
+
+export interface RecordUploadBody {
+ tenantid: number;
+ locationid: number;
+ categoryid: number;
+ batchid: string;
+ filename: string;
+ sender: string;
+ uploadedby: number;
+ uploadedname: string;
+ rowcount: number;
+ laststatus?: string;
+}
+
+export interface UploadQuery {
+ /** 0 or omitted means every tenant — how a Nearle Admin sees the platform. */
+ tenantid?: number;
+ locationid?: number;
+ pageno?: number;
+ pagesize?: number;
+}
+
+/**
+ * How long the ingest service keeps a drop nobody has acted on.
+ *
+ * `BATCH_RETENTION_DAYS` on their side. Worth showing rather than discovering:
+ * a drop that expires unreviewed leaves no trace at either end, and the only
+ * remedy — asking an admin to release it — has to happen before the deadline.
+ */
+export const DROP_RETENTION_DAYS = 7;
+
+/** Days left before an unreviewed drop is deleted; null once it has run. */
+export function daysUntilExpiry(receipt: UploadReceipt): number | null {
+ // Only a drop still sitting in the review inbox expires. Once released, the
+ // run is the record and retention no longer applies to it.
+ if (receipt.runid || receipt.laststatus !== 'pending') return null;
+ const created = Date.parse(receipt.created);
+ if (Number.isNaN(created)) return null;
+ const elapsedDays = (Date.now() - created) / 86_400_000;
+ return Math.max(0, Math.ceil(DROP_RETENTION_DAYS - elapsedDays));
+}
+
+/**
+ * The label the ingest service's admin sees in their review inbox.
+ *
+ * Their field is free text capped at 60 characters, and until now every upload
+ * from this console arrived as the same constant — so an admin deciding what to
+ * approve could not tell one merchant's sheet from another's.
+ *
+ * Safe to make specific precisely because we never send a credential. Their
+ * ownership filter matches `sender` EXACTLY, and a run an admin assembles from
+ * several drops carries a joined list ("alice, bob") — so a credentialed caller
+ * gets a 404 on a run containing their own file. We read anonymously, holding
+ * the id, which is what their documentation tells integrators to do.
+ *
+ * The tenant name is truncated rather than the whole label, so the branch and
+ * the person survive: "Ninhao The New Age Chinese Restaurant" is 36 characters
+ * on its own and would otherwise push everything identifying off the end.
+ */
+export function buildSender(parts: {
+ tenantname?: string;
+ locationname?: string;
+ username?: string;
+}): string {
+ const tenant = (parts.tenantname ?? '').trim().slice(0, 24);
+ const label = [tenant, (parts.locationname ?? '').trim(), (parts.username ?? '').trim()]
+ .filter(Boolean)
+ .join(' · ');
+ return (label || 'nearle-console').slice(0, 60);
+}
+
+export const uploadsApi = {
+ /**
+ * Store the receipt. Called the instant the drop is accepted, before polling.
+ *
+ * That timing is the whole point: it is the one moment the batch id is
+ * guaranteed to exist and guaranteed not to have been lost to a closed tab.
+ * Idempotent on `batchid` server-side, so a retry or a second tab is safe.
+ */
+ record: (body: RecordUploadBody) => api.post(`${WEB}/uploads/record`, body),
+
+ list: (query: UploadQuery = {}) =>
+ api.list(`${WEB}/uploads/list`, {
+ tenantid: query.tenantid ?? 0,
+ locationid: query.locationid ?? 0,
+ pageno: query.pageno ?? 1,
+ pagesize: query.pagesize ?? 50,
+ }),
+
+ /** Cache what the ingest service last reported, so the next reader need not wait. */
+ updateStatus: (body: {
+ batchid: string;
+ laststatus: string;
+ runid?: string;
+ inserted?: number;
+ backfilled?: number;
+ skipped?: number;
+ rejected?: number;
+ }) => api.put(`${WEB}/uploads/update`, body),
+
+ /** Record the other half: priced, shelved and stocked at a branch. */
+ markShelved: (body: { batchid: string; shelved: number; skipped: number }) =>
+ api.put(`${WEB}/uploads/shelved`, body),
+};
diff --git a/src/features/nearle-admin/NearleAdminShell.tsx b/src/features/nearle-admin/NearleAdminShell.tsx
index a045313..ad17a3f 100644
--- a/src/features/nearle-admin/NearleAdminShell.tsx
+++ b/src/features/nearle-admin/NearleAdminShell.tsx
@@ -11,6 +11,7 @@ const NAV: readonly NavEntry[] = [
{ to: '/nearle/stores', label: 'Stores' },
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
{ to: '/nearle/catalogue', label: 'Global catalogue' },
+ { to: '/nearle/uploads', label: 'Uploads' },
];
export function NearleAdminShell() {
diff --git a/src/features/nearle-admin/import/SheetImportPanel.tsx b/src/features/nearle-admin/import/SheetImportPanel.tsx
index 3062b49..26cd45b 100644
--- a/src/features/nearle-admin/import/SheetImportPanel.tsx
+++ b/src/features/nearle-admin/import/SheetImportPanel.tsx
@@ -23,6 +23,9 @@ import {
type IngestBatch,
} from '@/api/ingest';
import { errorMessage } from '@/api/client';
+import { buildSender, uploadsApi } from '@/api/uploads';
+import { useAuth } from '@/auth/AuthContext';
+import { useTenantLocations, useTenants } from '@/queries/hooks';
import { catalogueApi } from '@/api/catalogue';
import { productsApi } from '@/api/products';
import {
@@ -78,6 +81,13 @@ export interface SheetImportPanelProps {
export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps = {}) {
const isTenantFixed = Boolean(tenantid);
const [target, setTarget] = useState>({ tenantid, locationid });
+ const { user } = useAuth();
+ /* Read only to NAME the merchant and branch on the receipt and in the label
+ their reviewer sees. Neither drives any request — the ids in `target` do —
+ so a lookup that has not resolved yet degrades to a shorter label rather
+ than to a wrong upload. */
+ const tenants = useTenants({ pageno: 1, pagesize: 200 });
+ const branches = useTenantLocations(target.tenantid);
/** The shelving step, after the run finishes. */
const [plan, setPlan] = useState(null);
const [shelving, setShelving] = useState(null);
@@ -96,7 +106,35 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
*/
/** The batch while it runs, and after it settles. */
const [batch, setBatch] = useState(null);
+ /**
+ * The DROP id, held separately from `batch`.
+ *
+ * `batch` follows the drop to the run once an admin releases it, so
+ * `batch.batch_id` stops being the id we uploaded under — and the receipt is
+ * keyed on the drop. Reading the shelving key off `batch` therefore updated
+ * a row that does not exist, silently, and the Uploads page would have gone
+ * on reporting products as never shelved after they had been.
+ */
+ const [dropId, setDropId] = useState(null);
const [isWorking, setIsWorking] = useState(false);
+ /** Set when the upload succeeded but its receipt could not be filed. */
+ const [receiptError, setReceiptError] = useState(null);
+
+ /* Derived at render rather than seeded into state by an effect: both lists
+ arrive asynchronously, and a state copy would hold whatever was known at
+ the moment the effect happened to run. Empty is a fine answer — the label
+ simply gets shorter. */
+ const tenantName = useMemo(
+ () =>
+ (tenants.data ?? []).find((entry) => entry.tenantid === target.tenantid)?.tenantname ?? '',
+ [tenants.data, target.tenantid],
+ );
+ const branchName = useMemo(
+ () =>
+ (branches.data ?? []).find((entry) => entry.locationid === target.locationid)?.locationname ??
+ '',
+ [branches.data, target.locationid],
+ );
async function handleFile(next: File | File[] | null) {
@@ -105,6 +143,8 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
setParsed(null);
setParseError(null);
setBatch(null);
+ setDropId(null);
+ setReceiptError(null);
if (!chosen) return;
try {
@@ -136,8 +176,56 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
// Sent as a one-file batch. The endpoint takes up to twenty, and the
// client already supports that — the dropzone is what takes one at a
// time, and widening it is a separate change.
- const submitted = await submitBatch({ files: [file] });
+ //
+ // The sender names the shop rather than the console. It is the label the
+ // catalogue service's admin reads when deciding whether to run the file,
+ // and until now every upload from here arrived as the same constant — so
+ // they could not tell one merchant's sheet from another's.
+ const submitted = await submitBatch({
+ files: [file],
+ sender: buildSender({
+ tenantname: tenantName,
+ locationname: branchName,
+ username: user?.name ?? '',
+ }),
+ });
setBatch(submitted);
+ setDropId(submitted.batch_id);
+
+ // The receipt, written BEFORE the first poll.
+ //
+ // This is the one instant the batch id is guaranteed to exist and
+ // guaranteed not to have been lost. Everything after it — the review
+ // wait, the run, the shelving — can be recovered from the id; the id
+ // cannot be recovered from anything, and the service hands it out once,
+ // to this tab. A drop nobody releases is deleted after seven days, so
+ // without this row an upload can vanish with no trace at either end.
+ //
+ // Failure here is reported, not swallowed, and deliberately does not stop
+ // the upload: the sheet is already with the service and the id is on
+ // screen. But it has to be visible, because the quiet version of this
+ // failure is an upload nobody can find a week later.
+ if (isTargetComplete(target)) {
+ try {
+ await uploadsApi.record({
+ tenantid: target.tenantid,
+ locationid: target.locationid,
+ categoryid: target.categoryid,
+ batchid: submitted.batch_id,
+ filename: file.name,
+ sender: submitted.submitted_by ?? '',
+ uploadedby: user?.userid ?? 0,
+ uploadedname: user?.name ?? '',
+ rowcount: parsed?.rows.length ?? 0,
+ laststatus: submitted.status,
+ });
+ } catch (cause) {
+ setReceiptError(
+ `The upload reached the catalogue service, but this console could not file its receipt: ${errorMessage(cause)}. Keep the batch id below — it is the only way back to this upload.`,
+ );
+ }
+ }
+
setBatch(await pollBatch(submitted.batch_id, setBatch));
} catch (cause) {
setParseError(errorMessage(cause));
@@ -192,6 +280,32 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
if (requests.length > 0) await productsApi.importFromCatalogue(requests);
setShelved({ count: requests.length, skipped: unresolved.length + unpriced.length });
+
+ // The other half of the confirmation, onto the receipt.
+ //
+ // The catalogue service confirms the GLOBAL catalogue, which every
+ // merchant shares and which therefore holds no price and no stock — so
+ // "added" and "this shop can sell them" are two different claims. Only
+ // this call can make the second one, and without it the Uploads page
+ // would show a successful import of products no customer can buy.
+ //
+ // Swallowed on failure: the shelving itself has already happened and
+ // succeeded, and failing the whole step over a bookkeeping write would
+ // invite someone to run it twice.
+ //
+ // Skipped entirely when there is no drop id, which happens only if the
+ // receipt was never filed. Sending the run id instead would update
+ // nothing and look identical to success.
+ if (dropId) {
+ void uploadsApi
+ .markShelved({
+ // The DROP id, never `batch.batch_id` — see the note on `dropId`.
+ batchid: dropId,
+ shelved: requests.length,
+ skipped: unresolved.length + unpriced.length,
+ })
+ .catch(() => {});
+ }
} catch (cause) {
setShelving(errorMessage(cause));
} finally {
@@ -267,6 +381,11 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
that could not be read stays in the batch rather than being dropped, so
this is where a sender learns what became of it. */
const refused = batch.files.filter((entry) => entry.status === 'failed');
+ /* What the run actually wrote, narrowed to our own file for the same reason
+ the shelving step narrows it: a run can be assembled from several drops,
+ and another sender's products have no business being reported here as
+ ours. */
+ const confirmed = productsOf(batch, file ? [file.name] : undefined);
return (
@@ -290,6 +409,26 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
Batch {batch.batch_id} · {batch.status}
+ {/* The receipt failed to file. Said here rather than swallowed,
+ because the quiet version of this is an upload nobody can find a
+ week later — the id above is then the only copy in existence, and
+ it is on a screen somebody is about to close. */}
+ {receiptError ? (
+
+ {receiptError}
+
+ ) : null}
+
+ {/* Where this upload can be found again once this page is closed.
+ Worth saying on every result, not only on the ones still waiting:
+ a released run finishes long after whoever sent it has moved on. */}
+ {!receiptError && isTargetComplete(target) ? (
+
+ This upload is saved under Uploads, so it can be checked again later without this
+ page.
+
+ ) : null}
+
{isAwaitingReview(batch) ? (
/* No counts while it waits. Every total is zero because nothing has
run yet, and showing them reads as an import that found nothing
@@ -309,9 +448,9 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
) : null}
- {/* The way back into the catalogue. `brands` is the only identity the
- batch returns — it reports counts, not product ids — so it is what
- tells an operator where to go and look for what just arrived. */}
+ {/* The way back into the catalogue — where to go and look at what
+ just arrived, as a group. The product-by-product answer is below;
+ this is the shortcut when there are four hundred of them. */}
{batch.brands.length > 0 ? (
@@ -325,6 +464,66 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
) : null}
+ {/* The confirmation itself: which products the run actually wrote.
+ Narrowed to OUR file — a run an admin assembles from several drops
+ carries other senders' products, and listing those here would
+ claim we had added someone else's goods.
+
+ `unchanged` is shown rather than filtered out. Re-sending a sheet
+ is the normal case and writes nothing, so hiding those rows would
+ turn a completely successful upload into an empty list. */}
+ {confirmed.length > 0 ? (
+
+
+ {confirmed.length} product{confirmed.length === 1 ? '' : 's'} in the catalogue
+
+
+ “Already there” is a success — the product exists and this sheet had
+ nothing to add to it.
+
+
+ {confirmed.slice(0, 50).map((product) => (
+
+
+
+ {product.product_name}
+
+
+ {product.product_sku ?? product.image_id}
+
+
+
+
+ ))}
+
+ {confirmed.length > 50 ? (
+
+ Showing the first 50 of {confirmed.length}. The full list is on the Uploads page.
+
+ ) : null}
+
+ ) : null}
+
{/* Named individually rather than counted. "1 of 2 files failed" does
not tell you which one to resend. */}
{refused.length > 0 ? (
diff --git a/src/features/nearle-admin/pages/UploadsPage.tsx b/src/features/nearle-admin/pages/UploadsPage.tsx
new file mode 100644
index 0000000..de3611c
--- /dev/null
+++ b/src/features/nearle-admin/pages/UploadsPage.tsx
@@ -0,0 +1,23 @@
+import { VStack } from '@astryxdesign/core/VStack';
+import { PageHeader } from '@/components/PageHeader';
+import { UploadsPanel } from '@/features/uploads/UploadsPanel';
+
+/**
+ * Every spreadsheet on the platform, whoever sent it.
+ *
+ * No tenant scope, which is the whole difference from the two store versions:
+ * the Nearle Admin is who chases the catalogue team when a drop sits unreviewed,
+ * and they cannot do that from one merchant at a time. The panel shows the
+ * merchant name on each row when it is not given one.
+ */
+export function UploadsPage() {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/features/store-admin/StoreAdminShell.tsx b/src/features/store-admin/StoreAdminShell.tsx
index 68c11b4..fd5a815 100644
--- a/src/features/store-admin/StoreAdminShell.tsx
+++ b/src/features/store-admin/StoreAdminShell.tsx
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
-import { Check, ChevronDown, Monitor, Store, Users } from 'lucide-react';
+import { Check, ChevronDown, FileSpreadsheet, Monitor, Store, Users } from 'lucide-react';
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
import { BranchScopeProvider, useBranchScope } from './BranchScope';
import { useLiveEvents } from '@/queries/useLiveEvents';
@@ -51,6 +51,16 @@ const MANAGE: readonly MenuEntry[] = [
icon: ,
note: 'Till health, per counter',
},
+ /* Here rather than in the nav for the reason stated above: the four nav slots
+ each answer a question about the trading day, and "did my spreadsheet
+ land?" is not one of them. It is checked in the days AFTER an upload,
+ while the catalogue service's admin decides whether to run it. */
+ {
+ to: '/admin/uploads',
+ label: 'Uploads',
+ icon: ,
+ note: 'Spreadsheets sent to the catalogue',
+ },
];
export function StoreAdminShell() {
diff --git a/src/features/store-admin/pages/UploadsPage.tsx b/src/features/store-admin/pages/UploadsPage.tsx
new file mode 100644
index 0000000..0dca7d1
--- /dev/null
+++ b/src/features/store-admin/pages/UploadsPage.tsx
@@ -0,0 +1,35 @@
+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';
+
+/**
+ * This merchant's uploads.
+ *
+ * Scoped from the session, never from a picker — the same rule the import
+ * itself follows. A store login's tenant is fixed by who they are, and offering
+ * a choice with one legal answer is only a chance to get it wrong.
+ *
+ * The branch comes from the shared scope selector, so this page answers
+ * whichever branch the header is currently pointed at; `selected` of null means
+ * all of them, which is the right default for a merchant looking across their
+ * outlets.
+ */
+export function UploadsPage() {
+ const { user } = useAuth();
+ const { selected } = useBranchScope();
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/features/store-user/StoreUserShell.tsx b/src/features/store-user/StoreUserShell.tsx
index 974a55b..ab8cf3c 100644
--- a/src/features/store-user/StoreUserShell.tsx
+++ b/src/features/store-user/StoreUserShell.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { Monitor, QrCode, Store, UserCog, UserRound, Users } from 'lucide-react';
+import { FileSpreadsheet, Monitor, QrCode, Store, UserCog, UserRound, Users } from 'lucide-react';
import { StoreQrDrawer } from './StoreQrDrawer';
import { AppShell, IconButton, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
import { useAuth } from '@/auth/AuthContext';
@@ -62,6 +62,15 @@ const MANAGE: readonly MenuEntry[] = [
icon: ,
note: 'Who can open the till',
},
+ /* Checked in the days after an upload rather than during a trading day —
+ the catalogue service's admin decides when a drop runs, and the answer
+ arrives long after the tab that sent it has closed. */
+ {
+ to: '/store/uploads',
+ label: 'Uploads',
+ icon: ,
+ note: 'Spreadsheets sent to the catalogue',
+ },
{
to: '/store/account',
label: 'My account',
diff --git a/src/features/store-user/pages/StoreUploadsPage.tsx b/src/features/store-user/pages/StoreUploadsPage.tsx
new file mode 100644
index 0000000..4c187de
--- /dev/null
+++ b/src/features/store-user/pages/StoreUploadsPage.tsx
@@ -0,0 +1,29 @@
+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';
+
+/**
+ * This branch's uploads.
+ *
+ * Both the tenant and the branch are fixed — a store user has one of each, and
+ * `BranchScopeProvider pin=` has already narrowed the scope to theirs. So this
+ * is the merchant's page with nothing left to choose, which is the same
+ * relationship the rest of the store-user workspace has to Store Admin.
+ */
+export function StoreUploadsPage() {
+ const { user } = useAuth();
+ const { selected } = useBranchScope();
+ const locationid = selected ?? user?.locationid ?? 0;
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/features/uploads/UploadsPanel.tsx b/src/features/uploads/UploadsPanel.tsx
new file mode 100644
index 0000000..2b2d474
--- /dev/null
+++ b/src/features/uploads/UploadsPanel.tsx
@@ -0,0 +1,644 @@
+/**
+ * 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}
+
+
+
+
+
+ );
+ }
+
+ if (!receipts) {
+ return (
+
+
+ Loading uploads…
+
+
+ );
+ }
+
+ if (rows.length === 0) {
+ return (
+
+
+
+ No spreadsheets have been uploaded yet
+
+
+ When a sheet is sent to the catalogue service, its receipt appears here — including
+ while it waits for their admin to release it.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ }
+ onClick={() => void refreshLive(rows)}
+ />
+
+
+
+ {rows.map((receipt) => (
+
+ setExpanded((current) => (current === receipt.batchid ? null : receipt.batchid))
+ }
+ showTenant={!tenantid}
+ />
+ ))}
+
+
+ );
+}
+
+/* ── One receipt ──────────────────────────────────────────────────────────── */
+
+function UploadRow({
+ receipt,
+ live,
+ isExpanded,
+ onToggle,
+ showTenant,
+}: {
+ receipt: UploadReceipt;
+ live: LiveReading | undefined;
+ isExpanded: boolean;
+ onToggle: () => void;
+ showTenant: boolean;
+}) {
+ const batch = live?.batch;
+ const state = describeState(receipt, batch);
+ const expiresIn = daysUntilExpiry(receipt);
+
+ return (
+
+
+
+
+
+
+
+ {receipt.filename || 'Spreadsheet'}
+
+
+ {state.line}
+
+
+ {[
+ showTenant ? receipt.tenantname : null,
+ receipt.locationname,
+ receipt.uploadedname,
+ formatWhen(receipt.created),
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+
+
+
+ {/* 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 ? (
+
+ ) : state.canShelve ? (
+
+ ) : 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 ? (
+
+ {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.`}
+
+ ) : 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) ? (
+
+ 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.
+
+ ) : null}
+
+ {isExpanded ? : null}
+
+
+ );
+}
+
+/* ── 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 (
+
+
+
+ {receipt.runid ? : null}
+
+
+
+
+ {/* 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) ? (
+
+ ) : null}
+
+ {batch?.brands?.length ? (
+
+
+ Brands touched
+
+
+ {batch.brands.map((brand) => (
+
+ ))}
+
+
+ ) : null}
+
+ {/* The confirmation itself: the products, one line each, with what the
+ run did to every one of them. */}
+ {products.length > 0 ? (
+
+
+ {products.length} product{products.length === 1 ? '' : 's'} in the catalogue
+
+
+ “Already there” is a success, not a failure — re-sending a sheet writes nothing.
+
+
+ {products.slice(0, 100).map((product) => (
+
+
+
+ {product.product_name}
+
+
+ {product.product_sku ?? product.image_id}
+ {product.sku_source === 'Internal' ? ' · SKU minted by the service' : ''}
+
+
+
+
+ ))}
+
+ {products.length > 100 ? (
+
+ Showing the first 100 of {products.length}.
+
+ ) : null}
+
+ ) : batch && isSettled(batch) ? (
+
+ The run reported no products for this file.
+
+ ) : 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) => (
+
+ {file.filename} could not be read — {file.detail ?? 'no reason given'}
+
+ ))}
+
+ {receipt.shelvedat ? (
+
+
+
+ {receipt.shelvedcount} priced and stocked at {receipt.locationname || 'this branch'} on{' '}
+ {formatWhen(receipt.shelvedat)}
+ {receipt.skippedcount > 0 ? `, ${receipt.skippedcount} left out` : ''}.
+
+
+ ) : null}
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ Stage {stage.index} of {total} — {stage.name}
+
+ {stage.rows_total ? (
+
+ {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.
+
+ ) : 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. */
+
+ {names.map((name, index) => (
+
+
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+/* ── 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);
+ if (Number.isNaN(parsed.getTime())) return '';
+ return parsed.toLocaleString(undefined, {
+ day: 'numeric',
+ month: 'short',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}