lat and long
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
@@ -8,7 +8,16 @@ import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet } from 'lucide-react';
|
||||
import { importSheetProducts, type SheetImportResult, type SheetProductRow } from '@/api/products';
|
||||
import type { SheetProductRow } from '@/api/products';
|
||||
import {
|
||||
isStorageFailure,
|
||||
pollJob,
|
||||
previewSheet,
|
||||
submitIngest,
|
||||
summarise,
|
||||
type IngestJob,
|
||||
type IngestPreview,
|
||||
} from '@/api/ingest';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { SheetDropzone } from '@/components/SheetDropzone';
|
||||
@@ -23,10 +32,9 @@ interface PreviewRow extends Record<string, unknown> {
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface SheetImportPanelProps {
|
||||
tenantid: number | undefined;
|
||||
locationid: number | undefined;
|
||||
}
|
||||
/* 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.
|
||||
@@ -40,21 +48,33 @@ export interface SheetImportPanelProps {
|
||||
* twice, so that is said out loud before the button rather than discovered
|
||||
* afterwards.
|
||||
*/
|
||||
export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps) {
|
||||
export function SheetImportPanel() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [result, setResult] = useState<SheetImportResult | 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 service's own dry run. Authoritative about what it will read. */
|
||||
const [dryRun, setDryRun] = useState<IngestPreview | null>(null);
|
||||
/** The job while it runs, and after it settles. */
|
||||
const [job, setJob] = useState<IngestJob | null>(null);
|
||||
const [isWorking, setIsWorking] = useState(false);
|
||||
|
||||
const hasTarget = Boolean(tenantid && locationid);
|
||||
|
||||
async function handleFile(next: File | File[] | null) {
|
||||
const chosen = Array.isArray(next) ? (next[0] ?? null) : next;
|
||||
setFile(chosen);
|
||||
setParsed(null);
|
||||
setParseError(null);
|
||||
setResult(null);
|
||||
setJob(null);
|
||||
setDryRun(null);
|
||||
if (!chosen) return;
|
||||
|
||||
try {
|
||||
@@ -64,21 +84,46 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!parsed || !tenantid || !locationid) return;
|
||||
setProgress({ done: 0, total: parsed.rows.length });
|
||||
/**
|
||||
* Ask the service what it would read, without writing anything.
|
||||
*
|
||||
* `/preview` parses the sheet and reports the column mapping and the first
|
||||
* rows. Worth doing every time: a header the service does not recognise is
|
||||
* dropped SILENTLY, so a price column it never saw looks identical to a
|
||||
* successful import until somebody opens the catalogue.
|
||||
*/
|
||||
async function handlePreview() {
|
||||
if (!file) return;
|
||||
setParseError(null);
|
||||
setIsWorking(true);
|
||||
try {
|
||||
const outcome = await importSheetProducts({
|
||||
tenantid,
|
||||
locationid,
|
||||
rows: parsed.rows,
|
||||
onProgress: (done, total) => setProgress({ done, total }),
|
||||
});
|
||||
setResult(outcome);
|
||||
setDryRun(await previewSheet(file));
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
} finally {
|
||||
setProgress(null);
|
||||
setIsWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit, then poll until it 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 preview table.
|
||||
*/
|
||||
async function handleImport() {
|
||||
if (!file) return;
|
||||
setParseError(null);
|
||||
setIsWorking(true);
|
||||
try {
|
||||
const submitted = await submitIngest({ file });
|
||||
setJob(submitted);
|
||||
setJob(await pollJob(submitted.job_id, setJob));
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
} finally {
|
||||
setIsWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,46 +190,134 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
quantity: row.quantity,
|
||||
}));
|
||||
|
||||
if (result) {
|
||||
const isClean = result.failures.length === 0;
|
||||
/* ── Finished ─────────────────────────────────────────────────────────── */
|
||||
|
||||
if (job && (job.status === 'done' || job.status === 'failed')) {
|
||||
const outcome = job.result;
|
||||
const broken = isStorageFailure(job);
|
||||
|
||||
return (
|
||||
<Card padding={4} variant="transparent">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
{isClean ? (
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
) : (
|
||||
{broken ? (
|
||||
<AlertTriangle size={22} style={{ color: 'var(--color-error, #d64545)' }} />
|
||||
) : (outcome?.error_count ?? 0) + (outcome?.rejected ?? 0) > 0 ? (
|
||||
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
||||
) : (
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
)}
|
||||
<Text type="large" weight="semibold">
|
||||
{result.created} product{result.created === 1 ? '' : 's'} imported
|
||||
{summarise(job)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Text type="body" color="secondary">
|
||||
{result.linked} linked to the store, {result.stocked} given opening stock.
|
||||
{isClean ? '' : ` ${result.failures.length} row(s) did not make it.`}
|
||||
</Text>
|
||||
{outcome ? (
|
||||
<>
|
||||
<Text type="body" color="secondary">
|
||||
{outcome.rows_total} sheet row{outcome.rows_total === 1 ? '' : 's'} became{' '}
|
||||
{outcome.products_built} product{outcome.products_built === 1 ? '' : 's'}
|
||||
{outcome.products_built > outcome.rows_total
|
||||
? ' — a cell listing several pack sizes becomes one product each.'
|
||||
: '.'}
|
||||
{outcome.brands.length > 0 ? ` Brands touched: ${outcome.brands.join(', ')}.` : ''}
|
||||
</Text>
|
||||
|
||||
{!isClean ? (
|
||||
<VStack gap={1}>
|
||||
{result.failures.slice(0, 10).map((failure, index) => (
|
||||
<HStack key={`${failure.row.productsku}-${index}`} gap={1} align="center">
|
||||
<Badge variant="error" label={failure.row.productsku || '—'} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{failure.reason}
|
||||
{/* Headers the service did not recognise.
|
||||
|
||||
High on the panel because they are dropped silently. A price
|
||||
column it never read looks exactly like a clean import. */}
|
||||
{outcome.unrecognised_columns.length > 0 ? (
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Ignored columns
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{outcome.unrecognised_columns.join(', ')} — the service does not recognise these
|
||||
headers, so nothing in them was read.
|
||||
</Text>
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{outcome.errors.length > 0 ? (
|
||||
<VStack gap={1}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{outcome.error_count} row{outcome.error_count === 1 ? '' : 's'} could not be
|
||||
imported
|
||||
</Text>
|
||||
{outcome.errors.slice(0, 10).map((failure) => (
|
||||
<HStack key={`e${failure.row}`} gap={1} align="center" wrap="wrap">
|
||||
<Badge variant="error" label={`Row ${failure.row}`} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{failure.product_name ? `${failure.product_name} — ` : ''}
|
||||
{failure.error}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
{outcome.error_count > outcome.errors.length ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{outcome.error_count - outcome.errors.length} more not listed — the service
|
||||
caps this list at 50.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{outcome.rejections.length > 0 ? (
|
||||
<VStack gap={1}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{outcome.rejected} built but rejected by validation
|
||||
</Text>
|
||||
{outcome.rejections.slice(0, 10).map((rejection, index) => (
|
||||
<HStack key={`r${index}`} gap={1} align="center" wrap="wrap">
|
||||
<Badge variant="error" label={rejection.size || '—'} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{rejection.product_name} — {rejection.reason}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{outcome.warnings.length > 0 ? (
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Corrections applied
|
||||
</Text>
|
||||
{outcome.warnings.slice(0, 10).map((warning, index) => (
|
||||
<Text key={`w${index}`} type="body" size="sm" color="secondary">
|
||||
{warning}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{/* The catalogue is not the shelf. Said here because the screen
|
||||
asks for a merchant and an outlet, which makes it look as
|
||||
though the upload put something in their shop. It did not. */}
|
||||
{!broken ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
|
||||
These are in the global catalogue. They are not yet on this outlet’s shelf —
|
||||
putting them there with a price and opening stock is a separate step, and it is
|
||||
not wired up yet.
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text type="body" color="secondary">
|
||||
{job.detail ?? 'The service reported no detail.'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<RawResponse payload={job} />
|
||||
|
||||
<HStack>
|
||||
<Button
|
||||
label="Import another file"
|
||||
label="Send another file"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setJob(null);
|
||||
setDryRun(null);
|
||||
setParsed(null);
|
||||
setFile(null);
|
||||
}}
|
||||
@@ -215,7 +348,6 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
<SheetDropzone
|
||||
file={file}
|
||||
onFile={handleFile}
|
||||
blockedReason={hasTarget ? undefined : 'Pick a merchant and outlet first'}
|
||||
/>
|
||||
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
|
||||
@@ -286,31 +418,81 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{progress ? (
|
||||
<ProgressBar
|
||||
label="Importing"
|
||||
value={progress.done}
|
||||
max={progress.total}
|
||||
hasValueLabel
|
||||
formatValueLabel={(value, max) => `${value} of ${max}`}
|
||||
/>
|
||||
{/* The service's dry run, once it has been asked for.
|
||||
|
||||
This is the authoritative answer to "what will it actually
|
||||
read", and the ignored-columns line is the reason to run it: an
|
||||
unrecognised header is dropped without a word. */}
|
||||
{dryRun ? (
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
What the service will read
|
||||
</Text>
|
||||
{Object.entries(dryRun.recognised_columns ?? {}).map(([field, header]) => (
|
||||
<Text key={field} type="body" size="xsm" color="secondary">
|
||||
{String(header)} → {field}
|
||||
</Text>
|
||||
))}
|
||||
{(dryRun.unrecognised_columns ?? []).length > 0 ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
||||
Ignored: {dryRun.unrecognised_columns.join(', ')} — nothing in these columns is
|
||||
read.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{/* Real progress, from the job.
|
||||
|
||||
`stage_index` of `total_stages` and `rows_done` of `rows_total`
|
||||
both come off each poll, so this is a position rather than a
|
||||
decoration. It falls back to indeterminate for the moment
|
||||
between submitting and the first reading. */}
|
||||
{job && job.status !== 'done' && job.status !== 'failed' ? (
|
||||
<VStack gap={0.5}>
|
||||
{job.rows_total > 0 ? (
|
||||
<ProgressBar
|
||||
label={job.stage_name || 'Working'}
|
||||
value={job.rows_done}
|
||||
max={job.rows_total}
|
||||
hasValueLabel
|
||||
formatValueLabel={(value, max) => `${value} of ${max} rows`}
|
||||
/>
|
||||
) : (
|
||||
<ProgressBar label={job.stage_name || 'Starting'} isIndeterminate />
|
||||
)}
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Stage {job.stage_index + 1} of {job.total_stages}
|
||||
{job.detail ? ` · ${job.detail}` : ''} — 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">
|
||||
Nothing dedupes on SKU — importing this file twice creates every product twice.
|
||||
Sent whole for parsing and enrichment. The table above is only what we could read
|
||||
locally — check with a dry run first.
|
||||
</Text>
|
||||
</HStack>
|
||||
<Button
|
||||
label={progress ? 'Importing…' : `Import ${parsed.rows.length} products`}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={Boolean(progress)}
|
||||
isDisabled={!hasTarget || parsed.rows.length === 0}
|
||||
onClick={handleImport}
|
||||
/>
|
||||
<HStack gap={1} align="center">
|
||||
<Button
|
||||
label="Dry run"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
isDisabled={isWorking || !file}
|
||||
onClick={handlePreview}
|
||||
/>
|
||||
<Button
|
||||
label={isWorking ? 'Working…' : 'Send to catalogue'}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={isWorking}
|
||||
isDisabled={isWorking || !file}
|
||||
onClick={handleImport}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
@@ -318,3 +500,65 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,30 +124,33 @@ export async function parseProductSheet(file: File): Promise<ParsedSheet> {
|
||||
const categoryid = toNumber(picked.categoryid) ?? 0;
|
||||
const subcategoryid = toNumber(picked.subcategoryid) ?? 0;
|
||||
|
||||
// Only the product name is required, and that is the ingest service's rule
|
||||
// rather than ours: everything else is optional and enriched when blank,
|
||||
// and even the brand is inferred from the name.
|
||||
//
|
||||
// This validation used to demand a SKU, a price, a cost and a numeric
|
||||
// category id, because the console itself was creating the products and
|
||||
// needed all four. It no longer creates anything — the service does — so
|
||||
// enforcing them here would reject sheets the service handles perfectly
|
||||
// well. A missing price is now a warning, not a refusal.
|
||||
let isValid = true;
|
||||
|
||||
if (!productname) {
|
||||
issues.push({ line, field: 'productname', message: 'Product name is missing' });
|
||||
isValid = false;
|
||||
}
|
||||
if (!productsku) {
|
||||
// Without a SKU the created product cannot be found again, and the
|
||||
// create endpoint does not return its new id — so the row would import
|
||||
// and then be impossible to link to the store.
|
||||
issues.push({ line, field: 'productsku', message: 'SKU is missing — the row cannot be linked to a store without one' });
|
||||
isValid = false;
|
||||
if (productname && !String(picked.productbrand ?? '').trim() && productname.split(/\s+/).length < 2) {
|
||||
// The service infers the brand from the name ("Britannia 50-50" →
|
||||
// Britannia) and rejects the row when it cannot. A single bare word is
|
||||
// the case where that is most likely to fail.
|
||||
issues.push({
|
||||
line,
|
||||
field: 'productbrand',
|
||||
message: 'No brand column and the name is one word — the service may not be able to infer a brand',
|
||||
});
|
||||
}
|
||||
if (retailprice === null || retailprice <= 0) {
|
||||
issues.push({ line, field: 'retailprice', message: 'Retail price is missing or not a number' });
|
||||
isValid = false;
|
||||
}
|
||||
if (productcost === null) {
|
||||
issues.push({ line, field: 'productcost', message: 'Cost is missing or not a number' });
|
||||
isValid = false;
|
||||
}
|
||||
if (categoryid <= 0) {
|
||||
issues.push({ line, field: 'categoryid', message: 'Category id is missing — the tenant has no mapping from a category name' });
|
||||
isValid = false;
|
||||
if (retailprice !== null && retailprice <= 0) {
|
||||
issues.push({ line, field: 'retailprice', message: 'Price is not a positive number' });
|
||||
}
|
||||
|
||||
if (!isValid) return;
|
||||
@@ -168,53 +171,72 @@ export async function parseProductSheet(file: File): Promise<ParsedSheet> {
|
||||
});
|
||||
});
|
||||
|
||||
// Duplicate SKUs inside one file are the user's own error and will produce
|
||||
// duplicate products, since nothing on the server dedupes.
|
||||
const seen = new Set<string>();
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (seen.has(row.productsku)) {
|
||||
issues.push({
|
||||
line: index + 2,
|
||||
field: 'productsku',
|
||||
message: `SKU ${row.productsku} appears more than once in this file`,
|
||||
});
|
||||
}
|
||||
seen.add(row.productsku);
|
||||
}
|
||||
// The duplicate-SKU check that used to be here is gone.
|
||||
//
|
||||
// It existed because nothing on the old server deduped, so two rows with one
|
||||
// SKU made two products. The ingest service upserts on `image_id` — within
|
||||
// the batch as well as against the table — so a repeated pack now collapses,
|
||||
// last one winning. Flagging it as an error would be warning about behaviour
|
||||
// that is now correct.
|
||||
|
||||
return { rows, issues, unmappedColumns, totalRows: raw.length };
|
||||
}
|
||||
|
||||
/** The template handed to a tenant, so the columns arrive named correctly. */
|
||||
/**
|
||||
* The template handed to a merchant.
|
||||
*
|
||||
* The header names are chosen to survive the INGEST SERVICE's column matcher,
|
||||
* not ours. It matches case-insensitively on keywords, and the old template
|
||||
* collided with it in three places — one of them silently destructive:
|
||||
*
|
||||
* `quantity` → their `size_variants` keyword list. An opening stock of
|
||||
* 40 would have been written into the global catalogue as a
|
||||
* pack size of "40", for every merchant to see.
|
||||
* `productcost` → "cost" is in their price list, so it competed with
|
||||
* `retailprice` for the same field.
|
||||
* `subcategoryid` → contains "category", competing with `categoryid`.
|
||||
*
|
||||
* And `taxpercent` and `unitvalue` matched nothing at all, so they were dropped
|
||||
* without a word — a header that matches nothing is silently ignored, which
|
||||
* looks exactly like a successful import until somebody opens the catalogue.
|
||||
*
|
||||
* So the columns are named in their vocabulary. `Opening Stock` no longer
|
||||
* contains "quantity"; `Pack Size` says what their `size_variants` actually
|
||||
* wants; the price columns are unambiguous.
|
||||
*/
|
||||
export async function downloadTemplate(): Promise<void> {
|
||||
const XLSX = await loadXlsx();
|
||||
const headers = [
|
||||
'productname',
|
||||
'productsku',
|
||||
'categoryid',
|
||||
'subcategoryid',
|
||||
'retailprice',
|
||||
'productcost',
|
||||
'taxpercent',
|
||||
'quantity',
|
||||
'productunit',
|
||||
'unitvalue',
|
||||
'productbrand',
|
||||
'productdesc',
|
||||
'Product Name',
|
||||
'Brand',
|
||||
'Pack Size',
|
||||
'Category',
|
||||
'MRP',
|
||||
'Product Description',
|
||||
'HSN Code',
|
||||
'Barcode (EAN)',
|
||||
'SKU',
|
||||
'Image URL',
|
||||
'Opening Stock',
|
||||
];
|
||||
const example = {
|
||||
productname: 'Aachi Chilli Powder 100g',
|
||||
productsku: 'AACHI-CHILLI-100',
|
||||
categoryid: 1,
|
||||
subcategoryid: 0,
|
||||
retailprice: 35,
|
||||
productcost: 28,
|
||||
taxpercent: 5,
|
||||
quantity: 24,
|
||||
productunit: 'g',
|
||||
unitvalue: '100',
|
||||
productbrand: 'Aachi',
|
||||
productdesc: '',
|
||||
'Product Name': 'Britannia Good Day Cashew Cookies',
|
||||
Brand: 'Britannia',
|
||||
// Their pack-size explosion is real and useful: three sizes in one cell
|
||||
// become three catalogue products.
|
||||
'Pack Size': '100g, 200g',
|
||||
Category: 'Biscuits',
|
||||
MRP: 50,
|
||||
'Product Description': 'Crunchy cashew cookies',
|
||||
'HSN Code': '1905',
|
||||
'Barcode (EAN)': '8901063142107',
|
||||
SKU: '',
|
||||
// Supplying this skips their image-search stage entirely for the row, which
|
||||
// is both faster and more reliable than letting it guess.
|
||||
'Image URL': '',
|
||||
// Read by nobody upstream — the ingest writes the catalogue, not stock.
|
||||
// It is here so one sheet can also drive the outlet inventory step.
|
||||
'Opening Stock': 24,
|
||||
};
|
||||
|
||||
const sheet = XLSX.utils.json_to_sheet([example], { header: headers });
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { useState } from 'react';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { PackageSearch, Upload } from 'lucide-react';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
|
||||
import { useTenantLocations, useTenants } from '@/queries/hooks';
|
||||
import { SheetImportPanel } from '../import/SheetImportPanel';
|
||||
|
||||
/**
|
||||
@@ -25,79 +19,18 @@ import { SheetImportPanel } from '../import/SheetImportPanel';
|
||||
* shop at a time is the merchant's own job, in Store Admin ▸ Inventory ▸
|
||||
* Catalogue.
|
||||
*
|
||||
* - **From spreadsheet** is writing, and it cannot work without a target —
|
||||
* the import endpoint rejects a row with no `tenantid` or `locationid`. So
|
||||
* the merchant and outlet pickers live in that mode, where the question is
|
||||
* actually being asked, rather than sitting above a grid that never uses
|
||||
* them.
|
||||
* - **Upload sheet** is writing, and it writes the GLOBAL catalogue — not one
|
||||
* merchant's shelf. The ingest service parses, enriches and stores rows in
|
||||
* the per-brand tables every merchant reads from, and it takes no tenant and
|
||||
* no outlet. This mode therefore asks for nothing but a file.
|
||||
*
|
||||
* The grid, the filters, the cards and the detail drawer are the shared
|
||||
* `CatalogueBrowser` — the same ones the Store Admin sees, because it is the
|
||||
* same catalogue.
|
||||
*/
|
||||
export function GlobalCataloguePage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const tenantid = Number(params.get('tenantid') ?? 0) || undefined;
|
||||
const locationid = Number(params.get('locationid') ?? 0) || undefined;
|
||||
|
||||
const [mode, setMode] = useState<'catalogue' | 'sheet'>('catalogue');
|
||||
|
||||
const { data: tenants } = useTenants();
|
||||
/**
|
||||
* The outlets of the chosen merchant, read separately and deliberately.
|
||||
*
|
||||
* `getalltenants` is `SELECT * FROM tenants` with no join to
|
||||
* `tenantlocations` (`tenantRepository.go:79-133`), so its `locationid` is the
|
||||
* struct's zero value on every row. Seeding the destination from it — which
|
||||
* this page used to do — meant the id was always 0 and every upload stayed
|
||||
* blocked behind "pick a merchant" for a merchant already picked.
|
||||
*/
|
||||
const locations = useTenantLocations(tenantid);
|
||||
|
||||
const tenantOptions = useMemo(() => {
|
||||
if (!tenants) return [];
|
||||
const seen = new Map<number, string>();
|
||||
for (const tenant of tenants as TenantInfo[]) {
|
||||
if (!seen.has(tenant.tenantid)) seen.set(tenant.tenantid, tenant.tenantname);
|
||||
}
|
||||
return [...seen.entries()].map(([id, name]) => ({ value: String(id), label: name }));
|
||||
}, [tenants]);
|
||||
|
||||
const locationOptions = useMemo(
|
||||
() =>
|
||||
(locations.data ?? []).map((outlet) => ({
|
||||
value: String(outlet.locationid),
|
||||
label: outlet.locationname || `Outlet ${outlet.locationid}`,
|
||||
})),
|
||||
[locations.data],
|
||||
);
|
||||
|
||||
function chooseTenant(value: string) {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('tenantid', value);
|
||||
// Cleared rather than carried: the outlets for this merchant have not
|
||||
// loaded yet, and keeping the previous one's would aim an upload at another
|
||||
// merchant's shop.
|
||||
next.delete('locationid');
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
function chooseLocation(value: string) {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('locationid', value);
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
/** Most merchants have one outlet; picking it from a list of one teaches nothing. */
|
||||
useEffect(() => {
|
||||
if (!tenantid || locationid) return;
|
||||
const first = locations.data?.[0];
|
||||
if (first) chooseLocation(String(first.locationid));
|
||||
// `params` is read through the setter, so it is not a dependency here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenantid, locationid, locations.data]);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
@@ -117,46 +50,19 @@ export function GlobalCataloguePage() {
|
||||
/>
|
||||
|
||||
{mode === 'sheet' ? (
|
||||
<VStack gap={2}>
|
||||
{/* Only here. An upload is written against one merchant and one
|
||||
outlet, and the endpoint refuses a row without both. */}
|
||||
<Card padding={0} variant="transparent">
|
||||
<VStack gap={2} padding={2}>
|
||||
<SectionHeader
|
||||
title="Uploading for"
|
||||
note="Every row is written against one merchant and one outlet."
|
||||
/>
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label="Merchant"
|
||||
options={tenantOptions}
|
||||
value={tenantid ? String(tenantid) : ''}
|
||||
onChange={chooseTenant}
|
||||
placeholder="Select merchant…"
|
||||
hasSearch
|
||||
/>
|
||||
<Selector
|
||||
label="Outlet"
|
||||
options={locationOptions}
|
||||
value={locationid ? String(locationid) : ''}
|
||||
onChange={chooseLocation}
|
||||
placeholder={
|
||||
!tenantid
|
||||
? 'Choose a merchant first…'
|
||||
: locations.isLoading
|
||||
? 'Loading outlets…'
|
||||
: locationOptions.length === 0
|
||||
? 'This merchant has no outlets'
|
||||
: 'Select outlet…'
|
||||
}
|
||||
isDisabled={!tenantid || locationOptions.length === 0}
|
||||
hasSearch={locationOptions.length > 6}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
|
||||
</VStack>
|
||||
/* The merchant and outlet pickers used to sit above this panel, and
|
||||
they described a flow that no longer exists.
|
||||
|
||||
The ingest endpoint writes the GLOBAL catalogue. It has no concept of
|
||||
a tenant or an outlet — putting a product on one shop's shelf with a
|
||||
price and opening stock is a separate call (`/api/upload/stores`,
|
||||
joined on `image_id`) that is not wired up yet. Two selectors saying
|
||||
the upload was "written against one merchant and one outlet" would
|
||||
have had someone pick a shop, upload, and then go looking for stock
|
||||
that was never going to arrive.
|
||||
|
||||
They come back with the inventory step, and mean something then. */
|
||||
<SheetImportPanel />
|
||||
) : (
|
||||
<CatalogueBrowser
|
||||
tenantid={undefined}
|
||||
|
||||
Reference in New Issue
Block a user