agent conformation
This commit is contained in:
@@ -11,6 +11,7 @@ const NAV: readonly NavEntry[] = [
|
||||
{ to: '/nearle/stores', label: 'Stores' },
|
||||
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
|
||||
{ to: '/nearle/catalogue', label: 'Global catalogue' },
|
||||
{ to: '/nearle/uploads', label: 'Uploads' },
|
||||
];
|
||||
|
||||
export function NearleAdminShell() {
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
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 { catalogueApi } from '@/api/catalogue';
|
||||
import { productsApi } from '@/api/products';
|
||||
import {
|
||||
@@ -78,6 +81,13 @@ export interface SheetImportPanelProps {
|
||||
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);
|
||||
@@ -96,7 +106,35 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
*/
|
||||
/** 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);
|
||||
/** 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) {
|
||||
@@ -105,6 +143,8 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
setParsed(null);
|
||||
setParseError(null);
|
||||
setBatch(null);
|
||||
setDropId(null);
|
||||
setReceiptError(null);
|
||||
if (!chosen) return;
|
||||
|
||||
try {
|
||||
@@ -136,8 +176,56 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
// 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.
|
||||
const submitted = await submitBatch({ files: [file] });
|
||||
//
|
||||
// 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,
|
||||
categoryid: target.categoryid,
|
||||
batchid: submitted.batch_id,
|
||||
filename: file.name,
|
||||
sender: submitted.submitted_by ?? '',
|
||||
uploadedby: user?.userid ?? 0,
|
||||
uploadedname: user?.name ?? '',
|
||||
rowcount: parsed?.rows.length ?? 0,
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setBatch(await pollBatch(submitted.batch_id, setBatch));
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
@@ -192,6 +280,32 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
|
||||
if (requests.length > 0) await productsApi.importFromCatalogue(requests);
|
||||
setShelved({ count: requests.length, skipped: unresolved.length + unpriced.length });
|
||||
|
||||
// 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: requests.length,
|
||||
skipped: unresolved.length + unpriced.length,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (cause) {
|
||||
setShelving(errorMessage(cause));
|
||||
} finally {
|
||||
@@ -267,6 +381,11 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
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">
|
||||
@@ -290,6 +409,26 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
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
|
||||
@@ -309,9 +448,9 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* The way back into the catalogue. `brands` is the only identity the
|
||||
batch returns — it reports counts, not product ids — so it is what
|
||||
tells an operator where to go and look for what just arrived. */}
|
||||
{/* 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">
|
||||
@@ -325,6 +464,66 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
</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 ? (
|
||||
|
||||
23
src/features/nearle-admin/pages/UploadsPage.tsx
Normal file
23
src/features/nearle-admin/pages/UploadsPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { UploadsPanel } from '@/features/uploads/UploadsPanel';
|
||||
|
||||
/**
|
||||
* Every spreadsheet on the platform, whoever sent it.
|
||||
*
|
||||
* No tenant scope, which is the whole difference from the two store versions:
|
||||
* the Nearle Admin is who chases the catalogue team when a drop sits unreviewed,
|
||||
* and they cannot do that from one merchant at a time. The panel shows the
|
||||
* merchant name on each row when it is not given one.
|
||||
*/
|
||||
export function UploadsPage() {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Uploads"
|
||||
description="Spreadsheets sent to the catalogue service, across every merchant — what each one added, and whether it reached a shelf."
|
||||
/>
|
||||
<UploadsPanel />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user