updated on shelf

This commit is contained in:
2026-08-31 14:58:55 +05:30
parent bd133e7cc6
commit b4e77fd6ea
6 changed files with 356 additions and 32 deletions

View File

@@ -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&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>
);
}
/* ── The detail: stages while it runs, products once it has ───────────────── */
function UploadDetail({ receipt, live }: { receipt: UploadReceipt; live: LiveReading | undefined }) {