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} + + +