agent conformation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
/\/+$/,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
100
src/api/uploads.test.ts
Normal 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
191
src/api/uploads.ts
Normal 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),
|
||||
};
|
||||
@@ -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() {
|
||||
|
||||
@@ -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">
|
||||
“Already there” 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 ? (
|
||||
|
||||
23
src/features/nearle-admin/pages/UploadsPage.tsx
Normal file
23
src/features/nearle-admin/pages/UploadsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
35
src/features/store-admin/pages/UploadsPage.tsx
Normal file
35
src/features/store-admin/pages/UploadsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
29
src/features/store-user/pages/StoreUploadsPage.tsx
Normal file
29
src/features/store-user/pages/StoreUploadsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
644
src/features/uploads/UploadsPanel.tsx
Normal file
644
src/features/uploads/UploadsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user