Files
daily_console_web/src/features/uploads/UploadsPanel.tsx
2026-09-08 12:03:23 +05:30

642 lines
24 KiB
TypeScript

/**
* 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 { 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 { FileSpreadsheet, RefreshCw, UploadCloud } from 'lucide-react';
import { errorMessage } from '@/api/client';
import {
fetchBatch,
isDismissed,
isSettled,
ACCEPTED_EXTENSIONS,
productsOf,
resolveBatch,
releasedRunId,
type IngestBatch,
type IngestProduct,
} from '@/api/ingest';
import { uploadsApi, type UploadReceipt } from '@/api/uploads';
import type { DateRange } from '@/api/insights';
import type { SheetProductRow } from '@/api/products';
import { readStoredRows } from './shelveRows';
import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet';
import { shelveBatch } from '@/features/nearle-admin/import/shelve';
import { SheetDropzone } from '@/components/SheetDropzone';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { UploadDetailDrawer } from './UploadDetailDrawer';
import {
matchesDate,
matchesSearch,
matchesTab,
stateOf,
totalsOf,
STATE_COLOUR,
STATE_LABEL,
type UploadTab,
} from './uploadState';
import './uploads.css';
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 date range, owned by the page header.
*
* Every page in the console filters by date from the same control in the same
* place; a second pair of date boxes inside one page's own toolbar meant this
* screen alone asked the question twice, in a different shape, a few pixels
* lower down.
*/
range?: DateRange;
/** Clears the header's date filter, offered by the empty state. */
onClearRange?: () => void;
/**
* Opens this workspace's upload flow.
*
* Passed in rather than decided here: the three workspaces upload to two
* different places — a merchant's own product list, or the global catalogue —
* and a workspace with no upload route gets no button rather than a dead one.
*/
onUpload?: () => void;
}
/** 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,
range,
onClearRange,
onUpload,
}: UploadsPanelProps) {
const [receipts, setReceipts] = useState<UploadReceipt[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [live, setLive] = useState<Record<string, LiveReading>>({});
const [detail, setDetail] = useState<string | null>(null);
const [tab, setTab] = useState<UploadTab>('all');
const [search, setSearch] = useState('');
const from = range?.fromdate ?? '';
const to = range?.todate ?? '';
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]);
const showTenant = !tenantid;
/* ── The toolbar's state ──────────────────────────────────────────────── */
const shown = useMemo(() => {
return rows.filter((receipt) => {
const state = stateOf(receipt, live[receipt.batchid]?.batch);
return (
matchesTab(state, tab) && matchesSearch(receipt, search) && matchesDate(receipt, from, to)
);
});
}, [rows, live, tab, search, from, to]);
/*
Counts ignore the TAB but respect every other filter.
Ignoring the tab is the point — a tab reporting the size of its own filtered
view would always equal the list under it and could never tell you there is a
failure worth looking at. Respecting the search and the date is equally the
point, and was missed: with a date range that excluded everything, the list
said "Nothing matches" while the tab beside it said two, and the two
contradicted each other on the same screen.
*/
const counts = useMemo(() => {
const scoped = rows.filter(
(receipt) => matchesSearch(receipt, search) && matchesDate(receipt, from, to),
);
const out = { all: scoped.length, done: 0, failed: 0, running: 0 };
for (const receipt of scoped) {
const state = stateOf(receipt, live[receipt.batchid]?.batch);
if (matchesTab(state, 'done')) out.done += 1;
if (matchesTab(state, 'failed')) out.failed += 1;
if (matchesTab(state, 'running')) out.running += 1;
}
return out;
}, [rows, live, search, from, to]);
const open = shown.find((receipt) => receipt.batchid === detail) ?? null;
const paged = usePaged(shown, { resetKey: `${tab}|${search}|${from}|${to}` });
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}>
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
<div className="up-toolbar">
<div className="up-tabs" role="tablist" aria-label="Filter uploads">
{TABS.map((entry) => (
<button
key={entry.id}
type="button"
role="tab"
className="up-tab"
aria-selected={tab === entry.id}
data-active={tab === entry.id}
onClick={() => setTab(entry.id)}
>
{entry.label}
<span>{counts[entry.id]}</span>
</button>
))}
</div>
<div className="up-toolbar-spacer" />
<input
className="up-search"
type="search"
value={search}
placeholder="Search by file name…"
aria-label="Search by file name"
onChange={(event) => setSearch(event.target.value)}
/>
<Button
label={isRefreshing ? 'Checking…' : 'Check again'}
variant="secondary"
size="sm"
isLoading={isRefreshing}
isDisabled={isRefreshing}
icon={<RefreshCw size={13} />}
onClick={() => void refreshLive(rows)}
/>
{onUpload ? (
<Button
label="Upload spreadsheet"
variant="primary"
size="sm"
icon={<UploadCloud size={14} />}
onClick={onUpload}
/>
) : null}
</div>
{/* ── The list ─────────────────────────────────────────────────────── */}
{shown.length === 0 ? (
<Card padding={4} variant="transparent">
<VStack gap={1} align="start">
<Text type="label" weight="semibold">
Nothing matches
</Text>
{/*
The date range is named as the likely cause, with the way out.
This page opens on the month like every other, and a receipt log's
most-wanted row is often older than that — an empty list that does
not say which filter emptied it sends people to the refresh button.
*/}
<Text type="body" size="sm" color="secondary">
{rows.length > 0 && (from || to)
? `${rows.length} upload${rows.length === 1 ? ' is' : 's are'} outside the selected date range.`
: 'No upload matches this tab, search or date range.'}
</Text>
{onClearRange && (from || to) ? (
<Button
label="Clear date filter"
variant="secondary"
size="sm"
onClick={onClearRange}
/>
) : null}
</VStack>
</Card>
) : (
<Card padding={0} elevation="low">
<div className="table-scroll">
<table className="up-table">
<thead>
<tr>
<th>File name</th>
<th>Uploaded on</th>
<th>Rows</th>
<th>Status</th>
<th />
</tr>
</thead>
<tbody>
{paged.rows.map((receipt) => {
const batch = live[receipt.batchid]?.batch;
const state = stateOf(receipt, batch);
const totals = totalsOf(receipt, batch);
const colour = STATE_COLOUR[state];
return (
<tr key={receipt.batchid}>
<td>
<div className="up-file">
<span className="up-file-icon" style={{ color: colour }}>
<FileSpreadsheet size={17} />
</span>
<span>
<span className="up-file-name">
{receipt.filename || 'Spreadsheet'}
</span>
<span className="up-file-sub">
{totals.rows} row{totals.rows === 1 ? '' : 's'}
{totals.rejected > 0 ? ` · ${totals.rejected} rejected` : ''}
</span>
</span>
</div>
</td>
<td>
<div className="up-when">{formatWhen(receipt.created)}</div>
<div className="up-who">
{[
showTenant ? receipt.tenantname : null,
receipt.locationname,
receipt.uploadedname,
]
.filter(Boolean)
.join(' · ')}
</div>
</td>
<td className="num">{totals.rows || '—'}</td>
<td>
<span
className="up-pill"
style={{
color: colour,
background: `color-mix(in oklab, ${colour} 12%, transparent)`,
}}
>
{STATE_LABEL[state]}
</span>
{/* Shelving is a separate claim from ingestion and is
stated separately — a catalogue product carries no
price and no stock. */}
{receipt.shelvedat ? (
<div className="up-who">{receipt.shelvedcount} on the shelf</div>
) : null}
</td>
<td className="right">
<Button
label="View details"
variant="ghost"
size="sm"
onClick={() => setDetail(receipt.batchid)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TablePager paged={paged} label="uploads" />
</Card>
)}
{open ? (
<UploadDetailDrawer
receipt={open}
batch={live[open.batchid]?.batch}
products={live[open.batchid]?.products ?? []}
showTenant={showTenant}
onClose={() => setDetail(null)}
{...(canShelveHere(open, live[open.batchid]?.batch)
? {
extra: (
<ShelveAction
receipt={open}
batch={live[open.batchid]!.batch}
onDone={() => void load()}
/>
),
}
: {})}
/>
) : null}
</VStack>
);
}
/** The tabs, and the order they read in. */
const TABS: { id: UploadTab; label: string }[] = [
{ id: 'all', label: 'All uploads' },
{ id: 'done', label: 'Successful' },
{ id: 'failed', label: 'Failed' },
{ id: 'running', label: 'In progress' },
];
/**
* Can this upload still be finished from here?
*
* Needs a live batch — shelving reads the run's product list — and needs the run
* to have settled without having been shelved already.
*/
function canShelveHere(receipt: UploadReceipt, batch: IngestBatch | undefined): boolean {
if (!batch || receipt.shelvedat) return false;
return isSettled(batch) && batch.status !== 'failed' && !isDismissed(batch);
}
/**
* Puts a finished upload's products on the branch's shelf, from the receipt.
*
* The step the ingest service cannot do, and the one that was previously
* strandable. Their pipeline writes the GLOBAL catalogue — shared by every
* merchant, carrying no price and no stock — so the sheet's own figures are the
* only source for both, and shelving is where the two meet.
*
* Two paths, because two kinds of receipt exist:
*
* - **The sheet was stored** (every upload from now on): press the button.
* - **It was not** (uploads filed before the column existed): the prices and
* opening stock are genuinely gone from every system that holds anything, so
* the file has to be handed over again. It is parsed here, used immediately,
* and attached to the receipt so nobody has to find it a second time.
*/
function ShelveAction({
receipt,
batch,
onDone,
}: {
receipt: UploadReceipt;
batch: IngestBatch;
onDone: () => void;
}) {
const [isWorking, setIsWorking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
/** The file re-supplied for a receipt whose sheet was never stored. */
const [rescueFile, setRescueFile] = useState<File | null>(null);
// Empty, "[]" and absent all mean the same thing here: there is nothing to
// price these products from.
const storedRows = useMemo(() => readStoredRows(receipt.sheetrows), [receipt.sheetrows]);
async function run(rows: readonly SheetProductRow[]) {
setError(null);
setNote(null);
setIsWorking(true);
try {
const result = await shelveBatch(
batch,
rows,
// No category: `shelveBatch` classifies every row itself. The receipt
// still carries the column an older upload wrote, and it is deliberately
// not read — one id applied to a whole sheet is what filed a shop's
// entire range under a single aisle.
{ tenantid: receipt.tenantid, locationid: receipt.locationid },
receipt.filename || undefined,
);
await uploadsApi.markShelved({
batchid: receipt.batchid,
shelved: result.shelved,
skipped: result.skipped,
});
setNote(
[
`${result.shelved} priced, shelved and stocked.`,
result.skipped > 0 ? `${result.skipped} were left out.` : "",
// Named, because "left out" alone sends someone looking at their
// spreadsheet for a problem that is in the catalogue.
result.failedBrands.length > 0
? `The catalogue could not be read for: ${result.failedBrands.join(", ")}.`
: "",
]
.filter(Boolean)
.join(" "),
);
onDone();
} catch (cause) {
setError(errorMessage(cause));
} finally {
setIsWorking(false);
}
}
async function handleFile(next: File | null) {
setRescueFile(next);
const chosen = next;
if (!chosen) return;
setError(null);
try {
const parsed = await parseProductSheet(chosen);
// Attached before shelving, not after. If the shelving fails halfway the
// rows are still on the receipt, so the retry does not need the file
// again — which is the whole problem this path exists to solve.
await uploadsApi.attachSheet({
batchid: receipt.batchid,
sheetrows: JSON.stringify(parsed.rows),
});
await run(parsed.rows);
} catch (cause) {
setError(errorMessage(cause));
}
}
return (
<VStack gap={1}>
{storedRows.length > 0 ? (
<>
<Text type="body" size="sm" color="secondary">
These are in the shared catalogue but carry no price and no stock yet. This puts them on{' '}
{receipt.locationname || 'the branch'} with the price and opening stock from the sheet.
</Text>
<HStack>
<Button
label={isWorking ? 'Working…' : 'Put on the shelf'}
variant="primary"
size="sm"
isLoading={isWorking}
isDisabled={isWorking}
onClick={() => void run(storedRows)}
/>
</HStack>
</>
) : (
<VStack gap={1}>
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
The products reached the catalogue, but the sheet&rsquo;s prices and opening stock were
not kept with this upload — so nothing can be put on the shelf without it. Choose the
same spreadsheet again to finish.
</Text>
<SheetDropzone file={rescueFile} accept={ACCEPTED_EXTENSIONS.join(',')} onFile={handleFile} />
</VStack>
)}
{note ? (
<Text type="body" size="sm" style={{ color: 'var(--color-success, #10b981)' }}>
{note}
</Text>
) : null}
{error ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{error}
</Text>
) : null}
</VStack>
);
}
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',
});
}