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

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