updated on shelf
This commit is contained in:
@@ -42,6 +42,7 @@ import {
|
||||
isAwaitingReview,
|
||||
isDismissed,
|
||||
isSettled,
|
||||
ACCEPTED_EXTENSIONS,
|
||||
isStuckOnMissingRunner,
|
||||
productsOf,
|
||||
resolveBatch,
|
||||
@@ -50,6 +51,11 @@ import {
|
||||
type IngestProduct,
|
||||
} from '@/api/ingest';
|
||||
import { daysUntilExpiry, uploadsApi, type UploadReceipt } from '@/api/uploads';
|
||||
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 { SectionHeader } from '@/components/SectionHeader';
|
||||
|
||||
export interface UploadsPanelProps {
|
||||
@@ -226,6 +232,9 @@ export function UploadsPanel({ tenantid, locationid }: UploadsPanelProps) {
|
||||
onToggle={() =>
|
||||
setExpanded((current) => (current === receipt.batchid ? null : receipt.batchid))
|
||||
}
|
||||
/* Re-reads the receipts so the row flips to "on the shelf" without a
|
||||
manual refresh — the write has already happened server-side. */
|
||||
onShelved={() => void load()}
|
||||
showTenant={!tenantid}
|
||||
/>
|
||||
))}
|
||||
@@ -241,12 +250,14 @@ function UploadRow({
|
||||
live,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onShelved,
|
||||
showTenant,
|
||||
}: {
|
||||
receipt: UploadReceipt;
|
||||
live: LiveReading | undefined;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onShelved: () => void;
|
||||
showTenant: boolean;
|
||||
}) {
|
||||
const batch = live?.batch;
|
||||
@@ -302,6 +313,17 @@ function UploadRow({
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* Finishing the job, from here.
|
||||
The step this button runs used to exist only in the tab that did the
|
||||
upload, which meant it was normally impossible: the drop waits for
|
||||
the ingest service's admin and the run then takes minutes, so by the
|
||||
time there is anything to shelve that tab is closed. The products
|
||||
then sit in the shared catalogue, unpriced and unstocked, and no
|
||||
screen offered a way to finish. */}
|
||||
{state.canShelve && !receipt.shelvedat && batch ? (
|
||||
<ShelveAction receipt={receipt} batch={batch} onDone={onShelved} />
|
||||
) : null}
|
||||
|
||||
{/* 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. */}
|
||||
@@ -336,6 +358,141 @@ function UploadRow({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
{
|
||||
tenantid: receipt.tenantid,
|
||||
locationid: receipt.locationid,
|
||||
// The category chosen at upload time, kept on the receipt precisely
|
||||
// so this step does not have to guess it later. A product filed
|
||||
// outside the one the app browses is invisible to shoppers.
|
||||
categoryid: receipt.categoryid,
|
||||
},
|
||||
receipt.filename || undefined,
|
||||
);
|
||||
await uploadsApi.markShelved({
|
||||
batchid: receipt.batchid,
|
||||
shelved: result.shelved,
|
||||
skipped: result.skipped,
|
||||
});
|
||||
setNote(
|
||||
result.skipped > 0
|
||||
? `${result.shelved} on the shelf. ${result.skipped} were left out — the sheet did not price them.`
|
||||
: `${result.shelved} priced, shelved and stocked.`,
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The detail: stages while it runs, products once it has ───────────────── */
|
||||
|
||||
function UploadDetail({ receipt, live }: { receipt: UploadReceipt; live: LiveReading | undefined }) {
|
||||
|
||||
Reference in New Issue
Block a user