agent conformation
This commit is contained in:
@@ -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