agent conformation

This commit is contained in:
2026-08-31 12:32:09 +05:30
parent 7e3571f90e
commit bd133e7cc6
14 changed files with 1480 additions and 7 deletions

View File

@@ -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. */}
<Route path="onboard/branch" element={<Navigate to="/nearle/stores" replace />} />
<Route path="catalogue" element={<GlobalCataloguePage />} />
<Route path="uploads" element={<NearleUploadsPage />} />
{/* 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`. */}
<Route path="users" element={<UsersPage />} />
<Route path="terminals" element={<TerminalsPage />} />
<Route path="uploads" element={<AdminUploadsPage />} />
{/* 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() {
<Route path="customers" element={<StoreCustomersPage />} />
<Route path="terminals" element={<TerminalsPage />} />
<Route path="staff" element={<StoreStaffPage />} />
<Route path="uploads" element={<StoreUploadsPage />} />
<Route path="account" element={<StoreAccountPage />} />
<Route path="*" element={<Navigate to="/store/console" replace />} />
</Route>

View File

@@ -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(
/\/+$/,

View File

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

View File

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

100
src/api/uploads.test.ts Normal file
View File

@@ -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> = {}): 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');
});

191
src/api/uploads.ts Normal file
View File

@@ -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<UploadReceipt>(`${WEB}/uploads/record`, body),
list: (query: UploadQuery = {}) =>
api.list<UploadReceipt>(`${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<unknown>(`${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<unknown>(`${WEB}/uploads/shelved`, body),
};

View File

@@ -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() {

View File

@@ -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<Partial<ImportTarget>>({ 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<StockPlan | null>(null);
const [shelving, setShelving] = useState<string | null>(null);
@@ -96,7 +106,35 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
*/
/** The batch while it runs, and after it settles. */
const [batch, setBatch] = useState<IngestBatch | null>(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<string | null>(null);
const [isWorking, setIsWorking] = useState(false);
/** Set when the upload succeeded but its receipt could not be filed. */
const [receiptError, setReceiptError] = useState<string | null>(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 (
<Card padding={4} variant="transparent">
@@ -290,6 +409,26 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
Batch {batch.batch_id} · {batch.status}
</Text>
{/* 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 ? (
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
{receiptError}
</Text>
) : 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) ? (
<Text type="body" size="sm" color="secondary">
This upload is saved under Uploads, so it can be checked again later without this
page.
</Text>
) : 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
</Text>
) : 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 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
@@ -325,6 +464,66 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
</VStack>
) : 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 ? (
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
{confirmed.length} product{confirmed.length === 1 ? '' : 's'} in the catalogue
</Text>
<Text type="body" size="xsm" color="secondary">
&ldquo;Already there&rdquo; is a success — the product exists and this sheet had
nothing to add to it.
</Text>
<VStack gap={0.5}>
{confirmed.slice(0, 50).map((product) => (
<HStack key={product.image_id} gap={1} align="center" justify="between" wrap="wrap">
<VStack gap={0}>
<Text type="body" size="sm">
{product.product_name}
</Text>
<Text
type="body"
size="xsm"
color="secondary"
style={{ fontFamily: 'var(--font-mono)' }}
>
{product.product_sku ?? product.image_id}
</Text>
</VStack>
<Badge
variant={
product.disposition === 'inserted'
? 'success'
: product.disposition === 'backfilled'
? 'warning'
: 'neutral'
}
label={
product.disposition === 'inserted'
? 'Added'
: product.disposition === 'backfilled'
? 'Filled in'
: 'Already there'
}
/>
</HStack>
))}
</VStack>
{confirmed.length > 50 ? (
<Text type="body" size="xsm" color="secondary">
Showing the first 50 of {confirmed.length}. The full list is on the Uploads page.
</Text>
) : null}
</VStack>
) : null}
{/* Named individually rather than counted. "1 of 2 files failed" does
not tell you which one to resend. */}
{refused.length > 0 ? (

View File

@@ -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 (
<VStack gap={3}>
<PageHeader
title="Uploads"
description="Spreadsheets sent to the catalogue service, across every merchant — what each one added, and whether it reached a shelf."
/>
<UploadsPanel />
</VStack>
);
}

View File

@@ -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: <Monitor size={13} />,
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: <FileSpreadsheet size={13} />,
note: 'Spreadsheets sent to the catalogue',
},
];
export function StoreAdminShell() {

View File

@@ -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 (
<VStack gap={3}>
<PageHeader
title="Uploads"
description="Spreadsheets sent to the catalogue service — what each one added, and whether it reached a shelf."
/>
<UploadsPanel
tenantid={user?.tenantid ?? 0}
{...(selected === null ? {} : { locationid: selected })}
/>
</VStack>
);
}

View File

@@ -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: <UserCog size={13} />,
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: <FileSpreadsheet size={13} />,
note: 'Spreadsheets sent to the catalogue',
},
{
to: '/store/account',
label: 'My account',

View File

@@ -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 (
<VStack gap={3}>
<PageHeader
title="Uploads"
description="Spreadsheets sent to the catalogue service from this branch — what each one added, and whether it reached the shelf."
/>
<UploadsPanel tenantid={user?.tenantid ?? 0} locationid={locationid} />
</VStack>
);
}

View File

@@ -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<UploadReceipt[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [live, setLive] = useState<Record<string, LiveReading>>({});
const [expanded, setExpanded] = useState<string | null>(null);
const [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<string, LiveReading> = {};
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 (
<Card padding={4} variant="transparent">
<VStack gap={1.5}>
<Text type="body" style={{ color: 'var(--color-error, #d64545)' }}>
{error}
</Text>
<HStack>
<Button label="Try again" variant="secondary" onClick={() => void load()} />
</HStack>
</VStack>
</Card>
);
}
if (!receipts) {
return (
<Card padding={4} variant="transparent">
<Text type="body" color="secondary">
Loading uploads…
</Text>
</Card>
);
}
if (rows.length === 0) {
return (
<Card padding={4} variant="transparent">
<VStack gap={0.5}>
<Text type="label" weight="semibold">
No spreadsheets have been uploaded yet
</Text>
<Text type="body" size="sm" color="secondary">
When a sheet is sent to the catalogue service, its receipt appears here — including
while it waits for their admin to release it.
</Text>
</VStack>
</Card>
);
}
return (
<VStack gap={2}>
<HStack align="center" justify="between" wrap="wrap" gap={1}>
<SectionHeader
title="Uploads"
note={`${rows.length} upload${rows.length === 1 ? "" : "s"}`}
/>
<Button
label={isRefreshing ? 'Checking…' : 'Check again'}
variant="secondary"
size="sm"
isLoading={isRefreshing}
isDisabled={isRefreshing}
icon={<RefreshCw size={13} />}
onClick={() => void refreshLive(rows)}
/>
</HStack>
<VStack gap={1.5}>
{rows.map((receipt) => (
<UploadRow
key={receipt.batchid}
receipt={receipt}
live={live[receipt.batchid]}
isExpanded={expanded === receipt.batchid}
onToggle={() =>
setExpanded((current) => (current === receipt.batchid ? null : receipt.batchid))
}
showTenant={!tenantid}
/>
))}
</VStack>
</VStack>
);
}
/* ── 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 (
<Card padding={3} variant="transparent">
<VStack gap={1.5}>
<HStack align="start" justify="between" gap={2} wrap="wrap">
<HStack align="start" gap={1.5}>
<state.Icon size={20} style={{ color: state.colour, flexShrink: 0, marginTop: 2 }} />
<VStack gap={0.5}>
<Text type="label" weight="semibold">
{receipt.filename || 'Spreadsheet'}
</Text>
<Text type="body" size="sm" color="secondary">
{state.line}
</Text>
<Text type="body" size="xsm" color="secondary">
{[
showTenant ? receipt.tenantname : null,
receipt.locationname,
receipt.uploadedname,
formatWhen(receipt.created),
]
.filter(Boolean)
.join(' · ')}
</Text>
</VStack>
</HStack>
<HStack gap={1} align="center">
{/* The shelving half, stated separately and always. A product in the
global catalogue carries no price and no stock — "added" and
"on sale here" are two different claims, and running them
together would tell a shopkeeper they can sell something no
customer can buy. */}
{receipt.shelvedat ? (
<Badge
variant="success"
label={`${receipt.shelvedcount} on the shelf`}
/>
) : state.canShelve ? (
<Badge variant="warning" label="Not on a shelf yet" />
) : null}
<Button
label={isExpanded ? 'Hide' : 'Details'}
variant="ghost"
size="sm"
onClick={onToggle}
/>
</HStack>
</HStack>
{/* The seven-day clock. Their service deletes a drop nobody acts on,
and the remedy — asking an admin to release it — only works before
the deadline, so it cannot wait to be discovered. */}
{expiresIn !== null ? (
<Text
type="body"
size="sm"
style={{
color: expiresIn <= 2 ? 'var(--color-error, #d64545)' : 'var(--color-warning, #b7860b)',
}}
>
{expiresIn === 0
? 'This drop has passed the seven-day review window and may already have been deleted. Re-upload it.'
: `Still waiting for review. The catalogue service deletes an unreviewed drop after seven days — ${expiresIn} left.`}
</Text>
) : null}
{/* A batch handed to an orchestrator that is not deployed. Every other
signal — queued, stage 0, an empty bar — is identical to waiting
one's turn, so the difference has to be said out loud. */}
{batch && isStuckOnMissingRunner(batch) ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
This batch was staged for the Dagster orchestrator, which is not running in production —
it will wait indefinitely rather than fail. Ask the catalogue team to resume it onto the
in-process worker.
</Text>
) : null}
{isExpanded ? <UploadDetail receipt={receipt} live={live} /> : null}
</VStack>
</Card>
);
}
/* ── The detail: stages while it runs, products once it has ───────────────── */
function UploadDetail({ receipt, live }: { receipt: UploadReceipt; live: LiveReading | undefined }) {
const batch = live?.batch;
const products = live?.products ?? [];
return (
<VStack gap={2} style={{ borderTop: '1px solid var(--color-border, #e5e5e5)', paddingTop: 12 }}>
<HStack gap={2} wrap="wrap">
<Meta label="Batch" value={receipt.batchid} mono />
{receipt.runid ? <Meta label="Run" value={receipt.runid} mono /> : null}
<Meta label="Sheet rows" value={String(receipt.rowcount || '—')} />
<Meta label="Sent as" value={receipt.sender || '—'} />
</HStack>
{/* The pipeline, drawn from the service's own stage list rather than a
hardcoded one — so this cannot drift when they add or rename a stage. */}
{batch && !isSettled(batch) && !isAwaitingReview(batch) ? (
<StageTimeline batch={batch} />
) : null}
{batch?.brands?.length ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Brands touched
</Text>
<HStack gap={1} wrap="wrap">
{batch.brands.map((brand) => (
<Badge key={brand} variant="neutral" label={brand} />
))}
</HStack>
</VStack>
) : null}
{/* The confirmation itself: the products, one line each, with what the
run did to every one of them. */}
{products.length > 0 ? (
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
{products.length} product{products.length === 1 ? '' : 's'} in the catalogue
</Text>
<Text type="body" size="xsm" color="secondary">
“Already there” is a success, not a failure — re-sending a sheet writes nothing.
</Text>
<VStack gap={0.5}>
{products.slice(0, 100).map((product) => (
<HStack
key={product.image_id}
gap={1}
align="center"
justify="between"
wrap="wrap"
>
<VStack gap={0}>
<Text type="body" size="sm">
{product.product_name}
</Text>
<Text
type="body"
size="xsm"
color="secondary"
style={{ fontFamily: 'var(--font-mono)' }}
>
{product.product_sku ?? product.image_id}
{product.sku_source === 'Internal' ? ' · SKU minted by the service' : ''}
</Text>
</VStack>
<Badge {...dispositionBadge(product.disposition)} />
</HStack>
))}
</VStack>
{products.length > 100 ? (
<Text type="body" size="xsm" color="secondary">
Showing the first 100 of {products.length}.
</Text>
) : null}
</VStack>
) : batch && isSettled(batch) ? (
<Text type="body" size="sm" color="secondary">
The run reported no products for this file.
</Text>
) : null}
{/* Files the service refused, named rather than counted — "1 of 2 failed"
does not say which one to resend. */}
{(batch?.files ?? [])
.filter((file) => file.status === 'failed')
.map((file) => (
<Text
key={file.index}
type="body"
size="sm"
style={{ color: 'var(--color-error, #d64545)' }}
>
{file.filename} could not be read — {file.detail ?? 'no reason given'}
</Text>
))}
{receipt.shelvedat ? (
<HStack gap={1} align="center">
<PackageCheck size={16} style={{ color: 'var(--color-success, #10b981)' }} />
<Text type="body" size="sm" color="secondary">
{receipt.shelvedcount} priced and stocked at {receipt.locationname || 'this branch'} on{' '}
{formatWhen(receipt.shelvedat)}
{receipt.skippedcount > 0 ? `, ${receipt.skippedcount} left out` : ''}.
</Text>
</HStack>
) : null}
</VStack>
);
}
/**
* The eleven stages, with the one running now.
*
* `stage_names` is read from the response rather than hardcoded, deliberately:
* the service serves it so a client cannot drift out of step when a stage is
* added or renamed. When a build does not send it, the file's own timeline is
* the fallback and the list is simply shorter.
*/
function StageTimeline({ batch }: { batch: IngestBatch }) {
const file = batch.files?.[0];
if (!file) return null;
const stage = currentStage(file);
const names = batch.stage_names ?? [];
const total = file.total_stages ?? names.length ?? 11;
if (!stage) return null;
return (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Stage {stage.index} of {total} — {stage.name}
</Text>
{stage.rows_total ? (
<Text type="body" size="xsm" color="secondary">
{stage.rows_done ?? 0} of {stage.rows_total} rows. Image search and barcode enrichment
reach the network and are much the slowest — minutes here is normal.
</Text>
) : null}
{names.length > 0 ? (
/* Numbered pips rather than eleven spelled-out rows. The names are long
enough ("Brand Resolution & FSSAI Licence Mapping") that listing them
all would bury the one fact anybody wants, which is how far along it
is; the name is on the line above and on hover. `title` goes on the
wrapper because Badge takes no tooltip of its own. */
<HStack gap={0.5} wrap="wrap">
{names.map((name, index) => (
<span key={name} title={`${index + 1}. ${name}`}>
<Badge
variant={
index + 1 < stage.index
? 'success'
: index + 1 === stage.index
? 'warning'
: 'neutral'
}
label={String(index + 1)}
/>
</span>
))}
</HStack>
) : null}
</VStack>
);
}
/* ── Wording ──────────────────────────────────────────────────────────────── */
function dispositionBadge(disposition: IngestProduct['disposition']) {
if (disposition === 'inserted') return { variant: 'success' as const, label: 'Added' };
if (disposition === 'backfilled') return { variant: 'warning' as const, label: 'Filled in' };
return { variant: 'neutral' as const, label: 'Already there' };
}
/**
* One line saying where this upload stands.
*
* The live reading wins when we have one; the cached receipt answers when the
* service could not be reached, which is the case the whole receipt exists for.
* Every branch below is a state somebody can actually be in, and none of them
* is allowed to render as a bare status word — "retired" and "partial" mean
* nothing to a shopkeeper.
*/
function describeState(receipt: UploadReceipt, batch: IngestBatch | undefined) {
const warning = 'var(--color-warning, #b7860b)';
const error = 'var(--color-error, #d64545)';
const success = 'var(--color-success, #10b981)';
if (batch) {
if (isDismissed(batch)) {
const reason = (batch.files ?? []).map((file) => file.detail).find(Boolean);
return {
Icon: XCircle,
colour: error,
canShelve: false,
line: reason
? `Declined by the catalogue service: ${reason}`
: 'Declined by the catalogue service. Nothing was imported.',
};
}
if (isAwaitingReview(batch)) {
return {
Icon: Clock,
colour: warning,
canShelve: false,
line: 'Waiting for the catalogue service to review it. Nothing has run yet.',
};
}
if (!isSettled(batch)) {
return {
Icon: Clock,
colour: warning,
canShelve: false,
line: 'Running in the catalogue pipeline now.',
};
}
if (batch.status === 'failed') {
return { Icon: XCircle, colour: error, canShelve: false, line: 'No file could be ingested.' };
}
if (batch.status === 'interrupted') {
return {
Icon: AlertTriangle,
colour: warning,
canShelve: true,
line: 'A restart cut this run short. It does not resume on its own — ask the catalogue team.',
};
}
const totals = batch.totals;
return {
Icon: batch.status === 'partial' ? AlertTriangle : CheckCircle2,
colour: batch.status === 'partial' ? warning : success,
canShelve: true,
line: countLine(
totals?.inserted ?? 0,
totals?.backfilled ?? 0,
totals?.skipped_existing ?? 0,
totals?.rejected ?? 0,
),
};
}
// No live reading. Everything below is the cached receipt talking.
if (receipt.laststatus === 'pending') {
return {
Icon: Clock,
colour: warning,
canShelve: false,
line: 'Waiting for the catalogue service to review it. Nothing has run yet.',
};
}
if (receipt.laststatus === 'done' || receipt.laststatus === 'partial') {
return {
Icon: CheckCircle2,
colour: success,
canShelve: true,
line: countLine(receipt.inserted, receipt.backfilled, receipt.skipped, receipt.rejected),
};
}
return {
Icon: AlertTriangle,
colour: warning,
canShelve: true,
// Named as unreachable rather than dressed up. A drop past its seven-day
// window is genuinely gone from their side, and the receipt is all that is
// left of it — saying "loading" forever would hide that.
line: `Last known: ${receipt.laststatus}. The catalogue service could not be reached for a fresh reading.`,
};
}
function countLine(inserted: number, backfilled: number, skipped: number, rejected: number): string {
const parts = [`${inserted} added to the catalogue`];
if (backfilled > 0) parts.push(`${backfilled} filled in`);
if (skipped > 0) parts.push(`${skipped} already there`);
if (rejected > 0) parts.push(`${rejected} rejected`);
return parts.join(' · ');
}
function formatWhen(value: string | null): string {
if (!value) return '';
const parsed = new Date(value);
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 (
<VStack gap={0}>
<Text type="body" size="xsm" color="secondary">
{label}
</Text>
<Text
type="body"
size="sm"
{...(mono ? { style: { fontFamily: 'var(--font-mono)' } } : {})}
>
{value}
</Text>
</VStack>
);
}