990 lines
41 KiB
TypeScript
990 lines
41 KiB
TypeScript
import { useEffect, useMemo, useRef, 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 { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
|
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
|
import { Text } from '@astryxdesign/core/Text';
|
|
import { VStack } from '@astryxdesign/core/VStack';
|
|
import { AlertTriangle, CheckCircle2, Clock, Download, FileSpreadsheet } from 'lucide-react';
|
|
import type { SheetProductRow } from '@/api/products';
|
|
import {
|
|
ACCEPTED_EXTENSIONS,
|
|
isAwaitingReview,
|
|
isDismissed,
|
|
isIncomplete,
|
|
isSettled,
|
|
pollBatch,
|
|
productsOf,
|
|
progressOf,
|
|
submitBatch,
|
|
summarise,
|
|
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 {
|
|
ImportScope,
|
|
describeMissingTarget,
|
|
isTargetComplete,
|
|
type ImportTarget,
|
|
} from './ImportScope';
|
|
import { type StockPlan } from './openingStock';
|
|
import { shelveBatch } from './shelve';
|
|
import { SectionHeader } from '@/components/SectionHeader';
|
|
import { SheetDropzone } from '@/components/SheetDropzone';
|
|
import { downloadTemplate, parseProductSheet, type ParsedSheet } from './parseProductSheet';
|
|
import { TablePager } from '@/components/TablePager';
|
|
import { usePaged } from '@/components/usePaged';
|
|
|
|
interface PreviewRow extends Record<string, unknown> {
|
|
productname: string;
|
|
productsku: string;
|
|
category: string;
|
|
retailprice: number;
|
|
productcost: number;
|
|
quantity: number;
|
|
}
|
|
|
|
/* No props. The ingest writes the global catalogue and takes no tenant and no
|
|
outlet — see the note in `GlobalCataloguePage`. They return with the
|
|
inventory step. */
|
|
|
|
/**
|
|
* The spreadsheet import path.
|
|
*
|
|
* Staged rather than one-shot — upload, parse, show what is wrong, then commit —
|
|
* because the underlying calls are N creates with no transaction and no batch,
|
|
* so a row that fails on the server fails alone and has to be recoverable.
|
|
*
|
|
* It is also not idempotent, and cannot be made so from the client: nothing on
|
|
* the server dedupes on SKU. Re-uploading the same file creates the products
|
|
* twice, so that is said out loud before the button rather than discovered
|
|
* afterwards.
|
|
*/
|
|
export interface SheetImportPanelProps {
|
|
/**
|
|
* Fixed by the session for a store login; absent for a Nearle Admin, who is
|
|
* asked instead.
|
|
*
|
|
* The ingest itself takes no tenant — it writes the shared global catalogue —
|
|
* but the step AFTER it does. Pricing each product and putting its opening
|
|
* stock on a shelf is per tenant and per branch, and a sheet applied to the
|
|
* wrong branch loads a shelf nobody is standing at.
|
|
*/
|
|
tenantid?: number;
|
|
locationid?: number;
|
|
}
|
|
|
|
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);
|
|
const [shelved, setShelved] = useState<{ count: number; skipped: number } | null>(null);
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
|
const [parseError, setParseError] = useState<string | null>(null);
|
|
/**
|
|
* Non-null while the ingest service is working.
|
|
*
|
|
* Not a percentage. The old importer ran N creates from the browser and could
|
|
* count them; this is one request to a service that scrapes, calls an LLM and
|
|
* fetches images before it answers, and it reports nothing along the way. A
|
|
* bar that invented a position would be lying, so it says what is happening
|
|
* and how many rows are in flight instead.
|
|
*/
|
|
/** 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);
|
|
/**
|
|
* The live watch on the current batch.
|
|
*
|
|
* Held so it can be called off — when a new file replaces this one, when the
|
|
* panel resets, and when the drawer closes. Without that, closing the drawer
|
|
* mid-run left a poll reading a batch nothing was rendering.
|
|
*/
|
|
const watcher = useRef<AbortController | null>(null);
|
|
|
|
useEffect(() => () => watcher.current?.abort(), []);
|
|
|
|
/**
|
|
* Follows a batch until it finishes, updating the steps as it goes.
|
|
*
|
|
* NOT awaited by the caller, and that is the point. The upload is over once
|
|
* the service has the file; what follows is a wait — often on their admin to
|
|
* release the drop — and holding the submit handler open for it would keep
|
|
* the button spinning for hours. Every reading lands through `setBatch`, so
|
|
* the panel re-renders on each one.
|
|
*/
|
|
function watch(batchId: string) {
|
|
watcher.current?.abort();
|
|
const controller = new AbortController();
|
|
watcher.current = controller;
|
|
void pollBatch(batchId, setBatch, controller.signal)
|
|
.then((settled) => {
|
|
if (!controller.signal.aborted) setBatch(settled);
|
|
})
|
|
.catch(() => {
|
|
/* Aborted, or the service stopped answering. The last reading stays on
|
|
screen with its batch id, which is what the operator comes back
|
|
with — inventing an error over a poll that was cancelled would
|
|
report a failure that did not happen. */
|
|
});
|
|
}
|
|
/** 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) {
|
|
const chosen = Array.isArray(next) ? (next[0] ?? null) : next;
|
|
setFile(chosen);
|
|
setParsed(null);
|
|
setParseError(null);
|
|
// A new file is a new upload, so stop watching the old one first.
|
|
watcher.current?.abort();
|
|
setBatch(null);
|
|
setDropId(null);
|
|
setReceiptError(null);
|
|
if (!chosen) return;
|
|
|
|
try {
|
|
setParsed(await parseProductSheet(chosen));
|
|
} catch (cause) {
|
|
setParseError(errorMessage(cause));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Submit, then poll until the batch settles.
|
|
*
|
|
* The FILE goes up, not the parsed rows — the service does its own parsing
|
|
* and enrichment and can only do that from the original document. The local
|
|
* parse still runs, but only to fill the table above.
|
|
*
|
|
* There is no dry run any more. The batch endpoint has no `/preview` — a POST
|
|
* to it answers 405, because the path matches the GET that reads a batch by
|
|
* id. Only the older `/api/admin/store-catalog` route offers one, and it is
|
|
* gated on the `admin` role while the console's key is an `uploader`. So the
|
|
* local parse above is the only look before sending, and it reads the sheet
|
|
* with our rules rather than the service's.
|
|
*/
|
|
async function handleImport() {
|
|
if (!file) return;
|
|
setParseError(null);
|
|
setIsWorking(true);
|
|
try {
|
|
// 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.
|
|
//
|
|
// 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,
|
|
// Nothing picks a category any more, so the receipt's column is
|
|
// written 0 and never read back: `shelveBatch` classifies each row.
|
|
categoryid: 0,
|
|
batchid: submitted.batch_id,
|
|
filename: file.name,
|
|
sender: submitted.submitted_by ?? '',
|
|
uploadedby: user?.userid ?? 0,
|
|
uploadedname: user?.name ?? '',
|
|
rowcount: parsed?.rows.length ?? 0,
|
|
// The sheet itself, so the shelving step can be run later from the
|
|
// Uploads page. The prices and opening stock exist nowhere else —
|
|
// the ingest service holds a catalogue every merchant shares, which
|
|
// carries neither — and this browser is otherwise their only copy.
|
|
sheetrows: JSON.stringify(parsed?.rows ?? []),
|
|
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.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// The steps from here on arrive by themselves — see `watch`.
|
|
watch(submitted.batch_id);
|
|
} catch (cause) {
|
|
setParseError(errorMessage(cause));
|
|
} finally {
|
|
setIsWorking(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Puts the run's products on the shelf, with the sheet's own price and
|
|
* opening stock.
|
|
*
|
|
* This is the half the ingest service cannot do. It writes the GLOBAL
|
|
* catalogue, which is shared by every merchant and therefore holds no price
|
|
* and no stock; the sheet carries both, and they have travelled no further
|
|
* than this browser. Joining them is what turns "the products exist" into
|
|
* "the shop can sell them".
|
|
*
|
|
* One `importcatalogueproduct` call per batch, not per product: it takes an
|
|
* array, and each element writes the product row, the outlet row and the
|
|
* stock ledger entry together.
|
|
*/
|
|
async function handleShelve() {
|
|
if (!batch || !parsed || !isTargetComplete(target)) return;
|
|
setShelving(null);
|
|
setIsWorking(true);
|
|
try {
|
|
// One implementation, shared with the Uploads page — see `shelve.ts`.
|
|
// This step used to live only here, in a closure over this page's state,
|
|
// which meant it could not be run once the tab was gone. It normally is
|
|
// gone: the drop waits for their admin, and the run then takes minutes.
|
|
const result = await shelveBatch(batch, parsed.rows, target, file?.name);
|
|
setPlan(result.plan);
|
|
setShelved({ count: result.shelved, skipped: result.skipped });
|
|
|
|
// 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: result.shelved,
|
|
skipped: result.skipped,
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
} catch (cause) {
|
|
setShelving(errorMessage(cause));
|
|
} finally {
|
|
setIsWorking(false);
|
|
}
|
|
}
|
|
|
|
const previewColumns: TableColumn<PreviewRow>[] = [
|
|
{
|
|
key: 'productname',
|
|
header: 'Product',
|
|
width: { type: 'proportional', value: 3 },
|
|
renderCell: (row) => (
|
|
<VStack gap={0}>
|
|
<Text type="label" size="sm" weight="semibold">
|
|
{row.productname}
|
|
</Text>
|
|
<Text type="body" size="xsm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
|
|
{row.productsku}
|
|
</Text>
|
|
</VStack>
|
|
),
|
|
},
|
|
/* The category, shown because nobody chooses it any more.
|
|
It is worked out per row before the upload, so this column is the one
|
|
place the operator can see what each product will be filed under while
|
|
the file is still theirs to correct. */
|
|
{
|
|
key: 'category',
|
|
header: 'Category',
|
|
width: { type: 'proportional', value: 2 },
|
|
renderCell: (row) => (
|
|
<Text type="body" size="sm" color={row.category ? undefined : 'secondary'}>
|
|
{row.category || 'General'}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
key: 'retailprice',
|
|
header: 'Retail',
|
|
align: 'end',
|
|
width: { type: 'pixel', value: 100 },
|
|
renderCell: (row) => (
|
|
<Text type="label" size="sm" hasTabularNumbers>
|
|
₹{row.retailprice}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
key: 'productcost',
|
|
header: 'Cost',
|
|
align: 'end',
|
|
width: { type: 'pixel', value: 100 },
|
|
renderCell: (row) => (
|
|
<Text type="body" size="sm" color="secondary" hasTabularNumbers>
|
|
₹{row.productcost}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
key: 'quantity',
|
|
header: 'Opening stock',
|
|
align: 'end',
|
|
width: { type: 'pixel', value: 130 },
|
|
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.quantity}</Text>,
|
|
},
|
|
];
|
|
|
|
/* The whole sheet, paged — it used to be cut at 25 with nothing saying so,
|
|
unlike the issues list above, which says '…and N more'. A bad row on line
|
|
300 was unreachable in the one screen meant for checking the parse. */
|
|
const preview: PreviewRow[] = (parsed?.rows ?? []).map((row: SheetProductRow) => ({
|
|
productname: row.productname,
|
|
productsku: row.productsku,
|
|
category: row.category ?? '',
|
|
retailprice: row.retailprice,
|
|
productcost: row.productcost,
|
|
quantity: row.quantity,
|
|
}));
|
|
|
|
// A new file is a new sheet, so the pager starts over.
|
|
const previewPaged = usePaged(preview, { resetKey: preview.length });
|
|
|
|
/* ── Finished ─────────────────────────────────────────────────────────── */
|
|
|
|
if (batch && (isSettled(batch) || isAwaitingReview(batch) || isDismissed(batch))) {
|
|
const { totals } = batch;
|
|
const broken = isIncomplete(batch);
|
|
/* Every file the service refused, with the reason it gave for each. A file
|
|
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">
|
|
<VStack gap={3}>
|
|
<HStack align="center" gap={1.5}>
|
|
{isAwaitingReview(batch) ? (
|
|
<Clock size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
|
) : batch.status === 'failed' ? (
|
|
<AlertTriangle size={22} style={{ color: 'var(--color-error, #d64545)' }} />
|
|
) : broken ? (
|
|
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
|
) : (
|
|
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
|
)}
|
|
<Text type="large" weight="semibold">
|
|
{summarise(batch)}
|
|
</Text>
|
|
</HStack>
|
|
|
|
<Text type="body" size="sm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
|
|
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
|
|
— the precise misreading this whole status exists to prevent. */
|
|
<Text type="body" color="secondary">
|
|
The file reached the service and is queued for review. Nothing is in the catalogue
|
|
yet. Keep the batch id above — it is how this upload is found once an admin releases
|
|
it.
|
|
</Text>
|
|
) : totals ? (
|
|
<Text type="body" color="secondary">
|
|
{totals.rows_total} sheet row{totals.rows_total === 1 ? '' : 's'} became{' '}
|
|
{totals.products_built} product{totals.products_built === 1 ? '' : 's'}
|
|
{totals.products_built > totals.rows_total
|
|
? ' — a cell listing several pack sizes becomes one product each.'
|
|
: '.'}
|
|
</Text>
|
|
) : null}
|
|
|
|
{/* 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">
|
|
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: 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 ? (
|
|
<VStack gap={1}>
|
|
<Text type="label" size="sm" weight="semibold">
|
|
{refused.length} file{refused.length === 1 ? '' : 's'} could not be read
|
|
</Text>
|
|
{refused.map((entry) => (
|
|
<HStack key={entry.index} gap={1} align="center" wrap="wrap">
|
|
<Badge variant="error" label={entry.filename} />
|
|
<Text type="body" size="sm" color="secondary">
|
|
{entry.detail ?? 'No reason given.'}
|
|
</Text>
|
|
</HStack>
|
|
))}
|
|
</VStack>
|
|
) : null}
|
|
|
|
{/* Headers the service did not recognise, per file.
|
|
|
|
High on the panel because they are dropped silently: a price
|
|
column it never read looks exactly like a clean import until
|
|
somebody opens the catalogue and finds everything unpriced. */}
|
|
{batch.files.map((entry) => {
|
|
const ignored = entry.result?.unrecognised_columns ?? [];
|
|
if (ignored.length === 0) return null;
|
|
return (
|
|
<VStack key={`u${entry.index}`} gap={0.5}>
|
|
<Text type="label" size="sm" weight="semibold">
|
|
Ignored columns in {entry.filename}
|
|
</Text>
|
|
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
|
{ignored.join(', ')} — nothing in these columns was read.
|
|
</Text>
|
|
</VStack>
|
|
);
|
|
})}
|
|
|
|
{/* Rows built but not stored. The counts populate either way, so
|
|
reading them without checking this reports an import that never
|
|
landed. */}
|
|
{batch.files.map((entry) =>
|
|
entry.result?.storage_error ? (
|
|
<Text key={`s${entry.index}`} type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
|
{entry.filename}: rows were built but could not be stored — {entry.result.storage_error}
|
|
</Text>
|
|
) : null,
|
|
)}
|
|
|
|
{(totals?.rejected ?? 0) > 0 ? (
|
|
<Text type="body" size="sm" color="secondary">
|
|
{totals.rejected} product{totals.rejected === 1 ? '' : 's'} were built and then
|
|
refused by the validation gate.
|
|
</Text>
|
|
) : null}
|
|
|
|
{batch.status === 'interrupted' ? (
|
|
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
|
This batch does not restart on its own. Ask an admin on the ingest service to resume
|
|
it — re-uploading would run the files that already landed a second time.
|
|
</Text>
|
|
) : null}
|
|
|
|
{/* Putting them on the shelf.
|
|
|
|
The run wrote the GLOBAL catalogue, which is shared by every
|
|
merchant and holds no price and no stock. The sheet carries both
|
|
and has gone no further than this browser, so this step is the only
|
|
place the two can meet. Until it runs, the products exist and no
|
|
shop can sell them. */}
|
|
{batch.status !== 'failed' && !isAwaitingReview(batch) && !isDismissed(batch) ? (
|
|
<VStack gap={1.5}>
|
|
{shelved ? (
|
|
<VStack gap={0.5}>
|
|
<Text type="label" size="sm" weight="semibold">
|
|
{shelved.count} product{shelved.count === 1 ? '' : 's'} priced and stocked
|
|
</Text>
|
|
<Text type="body" size="sm" color="secondary">
|
|
They are in this merchant’s catalogue, on the chosen branch’s shelf,
|
|
with their opening stock recorded — so they are on sale in the app now.
|
|
{shelved.skipped > 0
|
|
? ` ${shelved.skipped} were left out; see below for which and why.`
|
|
: ''}
|
|
</Text>
|
|
</VStack>
|
|
) : (
|
|
<>
|
|
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
|
|
These are in the global catalogue, which every merchant shares — so they carry
|
|
no price and no stock yet. This step adds them to the chosen branch with the
|
|
price and opening stock from your sheet.
|
|
</Text>
|
|
<HStack>
|
|
<Button
|
|
label={isWorking ? 'Working…' : 'Add to this branch with opening stock'}
|
|
variant="primary"
|
|
isLoading={isWorking}
|
|
isDisabled={isWorking || !isTargetComplete(target) || !parsed}
|
|
onClick={handleShelve}
|
|
/>
|
|
</HStack>
|
|
{describeMissingTarget(target) ? (
|
|
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
|
{describeMissingTarget(target)}
|
|
</Text>
|
|
) : null}
|
|
</>
|
|
)}
|
|
|
|
{shelving ? (
|
|
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
|
{shelving}
|
|
</Text>
|
|
) : null}
|
|
|
|
{/* What was left out, and why. Each of these is a product the
|
|
merchant expects to have and will not, so none of them is
|
|
allowed to be silent. */}
|
|
{plan && plan.unmatched.length > 0 ? (
|
|
<VStack gap={0.5}>
|
|
<Text type="label" size="sm" weight="semibold">
|
|
{plan.unmatched.length} product{plan.unmatched.length === 1 ? '' : 's'} the
|
|
sheet does not price
|
|
</Text>
|
|
<Text type="body" size="xsm" color="secondary">
|
|
A cell listing several pack sizes becomes one product each, so the run can
|
|
produce more products than the sheet has rows. These reached the global
|
|
catalogue with no price and no stock:{' '}
|
|
{plan.unmatched.slice(0, 8).map((entry) => entry.product_name).join(', ')}
|
|
{plan.unmatched.length > 8 ? ` and ${plan.unmatched.length - 8} more` : ''}.
|
|
</Text>
|
|
</VStack>
|
|
) : null}
|
|
|
|
{plan && plan.missing.length > 0 ? (
|
|
<VStack gap={0.5}>
|
|
<Text type="label" size="sm" weight="semibold">
|
|
{plan.missing.length} sheet row{plan.missing.length === 1 ? '' : 's'} produced
|
|
no product
|
|
</Text>
|
|
<Text type="body" size="xsm" color="secondary">
|
|
{plan.missing.slice(0, 8).map((row) => row.productname).join(', ')}
|
|
{plan.missing.length > 8 ? ` and ${plan.missing.length - 8} more` : ''} — the
|
|
run rejected or never saw these.
|
|
</Text>
|
|
</VStack>
|
|
) : null}
|
|
|
|
{plan && plan.matched.some((entry) => entry.basis === 'name') ? (
|
|
<Text type="body" size="xsm" color="secondary">
|
|
{plan.matched.filter((entry) => entry.basis === 'name').length} product
|
|
{plan.matched.filter((entry) => entry.basis === 'name').length === 1 ? '' : 's'}{' '}
|
|
were matched on their name rather than a SKU, because those sheet rows left the
|
|
SKU blank. Add a SKU column to make the match exact.
|
|
</Text>
|
|
) : null}
|
|
</VStack>
|
|
) : null}
|
|
|
|
<RawResponse payload={batch} />
|
|
|
|
<HStack>
|
|
<Button
|
|
label="Send another file"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
watcher.current?.abort();
|
|
setBatch(null);
|
|
setParsed(null);
|
|
setFile(null);
|
|
}}
|
|
/>
|
|
</HStack>
|
|
</VStack>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<VStack gap={2}>
|
|
<Card padding={0} variant="transparent">
|
|
<VStack gap={2} padding={3}>
|
|
<SectionHeader
|
|
title="Upload the tenant's product list"
|
|
action={
|
|
<Button
|
|
label="Download template"
|
|
variant="secondary"
|
|
size="sm"
|
|
icon={<Download size={14} />}
|
|
onClick={() => void downloadTemplate()}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
{/* Who this sheet is for.
|
|
A Nearle Admin is asked; a store login already answered by signing
|
|
in, so it renders nothing for them. */}
|
|
<ImportScope value={target} onChange={setTarget} isTenantFixed={isTenantFixed} />
|
|
|
|
{/* The service reads .tsv as well, and the dropzone's own default
|
|
list did not offer it — a file the picker refuses never reaches
|
|
the code that would have accepted it. One list, exported by the
|
|
client, so the two cannot drift. */}
|
|
<SheetDropzone
|
|
file={file}
|
|
onFile={handleFile}
|
|
accept={ACCEPTED_EXTENSIONS.join(',')}
|
|
/>
|
|
|
|
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
|
|
Only a product-name column is required. Everything else is optional: a price, a cost
|
|
and an opening stock are used when present, and a SKU makes the match back to your rows
|
|
exact rather than by name.
|
|
<br />
|
|
You are not asked for a category, and a categoryid column is ignored if you include
|
|
one. Each product is filed by the platform's own classification — the sheet's category
|
|
column if it has one, then the product registry, then its name — into one of the 31
|
|
categories the customer app browses, so a mixed sheet no longer lands in a single aisle.
|
|
</Text>
|
|
|
|
{parseError ? (
|
|
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
|
{parseError}
|
|
</Text>
|
|
) : null}
|
|
</VStack>
|
|
</Card>
|
|
|
|
{parsed ? (
|
|
<Card padding={0} variant="transparent">
|
|
<VStack gap={2} padding={3}>
|
|
<SectionHeader
|
|
title="What is in the file"
|
|
note={`${parsed.totalRows} rows read · ${parsed.rows.length} ready · ${parsed.issues.length} to fix`}
|
|
/>
|
|
|
|
{parsed.unmappedColumns.length > 0 ? (
|
|
<HStack gap={1} wrap="wrap" align="center">
|
|
<Text type="body" size="sm" color="secondary">
|
|
Ignored columns:
|
|
</Text>
|
|
{parsed.unmappedColumns.map((column) => (
|
|
<Badge key={column} variant="neutral" label={column} />
|
|
))}
|
|
</HStack>
|
|
) : null}
|
|
|
|
{parsed.issues.length > 0 ? (
|
|
<VStack
|
|
gap={0.5}
|
|
padding={2}
|
|
style={{ background: 'var(--color-warning-muted, #fdf6e3)', borderRadius: 12 }}
|
|
>
|
|
<HStack align="center" gap={1}>
|
|
<AlertTriangle size={15} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
|
<Text type="label" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
|
{parsed.issues.length} row{parsed.issues.length === 1 ? '' : 's'} will be skipped
|
|
</Text>
|
|
</HStack>
|
|
{parsed.issues.slice(0, 8).map((issue, index) => (
|
|
<Text key={index} type="body" size="xsm" color="secondary">
|
|
Row {issue.line} · {issue.field}: {issue.message}
|
|
</Text>
|
|
))}
|
|
{parsed.issues.length > 8 ? (
|
|
<Text type="body" size="xsm" color="secondary">
|
|
…and {parsed.issues.length - 8} more.
|
|
</Text>
|
|
) : null}
|
|
</VStack>
|
|
) : null}
|
|
|
|
{preview.length > 0 ? (
|
|
<>
|
|
<Table<PreviewRow>
|
|
data={previewPaged.rows}
|
|
columns={previewColumns}
|
|
idKey="productsku"
|
|
density="compact"
|
|
dividers="rows"
|
|
/>
|
|
<TablePager paged={previewPaged} label="sheet rows" />
|
|
</>
|
|
) : null}
|
|
|
|
{/* Real progress, from the batch.
|
|
|
|
Two positions come off each poll and they answer different
|
|
questions: files done of files total is how far the batch has
|
|
got, and the running file's stage of eleven is what it is doing
|
|
now. Shown together because a single number cannot say both. It
|
|
falls back to indeterminate for the moment between submitting
|
|
and the first reading. */}
|
|
{batch && !isSettled(batch) ? (
|
|
<VStack gap={0.5}>
|
|
{(() => {
|
|
const { done, total } = progressOf(batch);
|
|
const running = batch.files.find((entry) => entry.status === 'running');
|
|
return total > 0 ? (
|
|
<ProgressBar
|
|
label={running?.stage_name || batch.current_file || 'Working'}
|
|
value={done}
|
|
max={total}
|
|
hasValueLabel
|
|
formatValueLabel={(value, max) => `${value} of ${max} files`}
|
|
/>
|
|
) : (
|
|
<ProgressBar label="Starting" isIndeterminate />
|
|
);
|
|
})()}
|
|
<Text type="body" size="xsm" color="secondary">
|
|
{(() => {
|
|
const running = batch.files.find((entry) => entry.status === 'running');
|
|
if (!running) {
|
|
return batch.status === 'queued'
|
|
? 'Queued — the service runs one batch at a time. Keep this tab open.'
|
|
: 'Working — keep this tab open.';
|
|
}
|
|
const stage =
|
|
running.stage_index !== undefined && running.total_stages
|
|
? `Stage ${running.stage_index + 1} of ${running.total_stages}`
|
|
: 'Running';
|
|
const rows =
|
|
running.rows_total && running.rows_done !== undefined
|
|
? ` · ${running.rows_done} of ${running.rows_total} rows`
|
|
: '';
|
|
return `${running.filename} — ${stage}${rows}. Keep this tab open.`;
|
|
})()}
|
|
</Text>
|
|
</VStack>
|
|
) : null}
|
|
|
|
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
|
<HStack align="center" gap={1}>
|
|
<FileSpreadsheet size={15} style={{ color: 'var(--color-slate-400)' }} />
|
|
<Text type="body" size="xsm" color="secondary">
|
|
Sent whole for parsing and enrichment. The table above is only what we could read
|
|
locally — the service applies its own column rules.
|
|
</Text>
|
|
</HStack>
|
|
<HStack gap={1} align="center">
|
|
<Button
|
|
label={isWorking ? 'Working…' : 'Send to catalogue'}
|
|
variant="primary"
|
|
size="lg"
|
|
isLoading={isWorking}
|
|
isDisabled={isWorking || !file}
|
|
onClick={handleImport}
|
|
/>
|
|
</HStack>
|
|
</HStack>
|
|
</VStack>
|
|
</Card>
|
|
) : null}
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
|
|
/**
|
|
* What the ingest service actually replied.
|
|
*
|
|
* Collapsed, so it is not in the way, and copyable in one click — the point is
|
|
* to get the real payload out of a browser and in front of someone who can turn
|
|
* it into types. Scaffolding: it goes when `api/ingest.ts` stops guessing.
|
|
*/
|
|
function RawResponse({ payload }: { payload: unknown }) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const text = useMemo(() => {
|
|
try {
|
|
return JSON.stringify(payload, null, 2);
|
|
} catch {
|
|
return String(payload);
|
|
}
|
|
}, [payload]);
|
|
|
|
return (
|
|
<VStack gap={1}>
|
|
<HStack gap={1} align="center" wrap="wrap">
|
|
<Button
|
|
label={isOpen ? 'Hide the raw response' : 'Show the raw response'}
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setIsOpen((open) => !open)}
|
|
/>
|
|
{isOpen ? (
|
|
<Button
|
|
label="Copy"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => void navigator.clipboard?.writeText(text)}
|
|
/>
|
|
) : null}
|
|
</HStack>
|
|
|
|
{isOpen ? (
|
|
<pre
|
|
style={{
|
|
margin: 0,
|
|
padding: 14,
|
|
borderRadius: 12,
|
|
border: '1px solid var(--color-line)',
|
|
background: 'var(--color-surface-subtle)',
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: 12,
|
|
lineHeight: 1.6,
|
|
maxHeight: 320,
|
|
overflow: 'auto',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
color: 'var(--color-ink-2)',
|
|
}}
|
|
>
|
|
{text}
|
|
</pre>
|
|
) : null}
|
|
</VStack>
|
|
);
|
|
}
|