685 lines
26 KiB
TypeScript
685 lines
26 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, 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 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[]) => {
|
|
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 }));
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (receipts && receipts.length > 0) void refreshLive(receipts);
|
|
}, [receipts, refreshLive]);
|
|
|
|
/** Receipts that could still change. A finished, shelved one never will. */
|
|
const moving = useMemo(
|
|
() => (receipts ?? []).filter((receipt) => !(receipt.laststatus === 'done' && receipt.shelvedat)),
|
|
[receipts],
|
|
);
|
|
|
|
/**
|
|
* Keeps the moving rows current without anyone reloading the page.
|
|
*
|
|
* The list used to be read once, when the page loaded. Everything after that
|
|
* — their admin releasing a drop, the run finishing, products reaching the
|
|
* shelf — arrived only if the operator reloaded the page, which is exactly
|
|
* what a status page exists to avoid. The "Check again" button that used to
|
|
* paper over it is gone with the same change.
|
|
*
|
|
* Fifteen seconds, and only while something is unfinished: a page of forty
|
|
* settled receipts makes no requests at all. It also pauses entirely while
|
|
* the tab is in the background and takes a reading the moment it comes
|
|
* forward, so a tab left open all afternoon costs nothing and is up to date
|
|
* by the time it is looked at.
|
|
*/
|
|
useEffect(() => {
|
|
if (moving.length === 0) return;
|
|
|
|
let stopped = false;
|
|
let timer: ReturnType<typeof setTimeout>;
|
|
|
|
const read = () => {
|
|
if (stopped || document.visibilityState !== 'visible') return;
|
|
void refreshLive(moving);
|
|
};
|
|
const loop = () => {
|
|
read();
|
|
timer = setTimeout(loop, 15000);
|
|
};
|
|
timer = setTimeout(loop, 15000);
|
|
|
|
// Coming back to the tab is worth a reading straight away — that is the
|
|
// moment somebody wants the answer.
|
|
const onVisible = () => read();
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
|
|
return () => {
|
|
stopped = true;
|
|
clearTimeout(timer);
|
|
document.removeEventListener('visibilitychange', onVisible);
|
|
};
|
|
}, [moving, 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)}
|
|
/>
|
|
{/* "Check again" stood here. It is gone because the page reads the
|
|
moving rows by itself now — every fifteen seconds while something is
|
|
unfinished, and again the moment the tab comes forward. A refresh
|
|
button beside a list that refreshes itself is a button that never
|
|
changes anything, and pressing it to find out whether it did is
|
|
worse than not having it. */}
|
|
{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’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',
|
|
});
|
|
}
|