350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
import { useState } from 'react';
|
|
/**
|
|
* A merchant's OWN product list — not the global catalogue.
|
|
*
|
|
* This is the browser-side importer that used to serve both consoles. Nearle
|
|
* Admin's Upload sheet now hands the file to the ingest service instead, which
|
|
* writes the per-brand tables every merchant reads from. That is a
|
|
* platform-operator action, and it must not be what happens when a shopkeeper
|
|
* uploads their own price list: one merchant's spreadsheet would land in front
|
|
* of all of them.
|
|
*
|
|
* So this path stays for the Store Admin. It creates products under that
|
|
* tenant, lists them at that outlet and gives them opening stock — scoped to
|
|
* the shop, exactly as before.
|
|
*/
|
|
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, Download, FileSpreadsheet } from 'lucide-react';
|
|
import { importSheetProducts, type SheetImportResult, type SheetProductRow } from '@/api/products';
|
|
import { errorMessage } from '@/api/client';
|
|
import { SectionHeader } from '@/components/SectionHeader';
|
|
import { SheetDropzone } from '@/components/SheetDropzone';
|
|
import { TablePager } from '@/components/TablePager';
|
|
import { usePaged } from '@/components/usePaged';
|
|
import {
|
|
downloadTemplate,
|
|
parseProductSheet,
|
|
type ParsedSheet,
|
|
} from '@/features/nearle-admin/import/parseProductSheet';
|
|
|
|
interface PreviewRow extends Record<string, unknown> {
|
|
productname: string;
|
|
productsku: string;
|
|
categoryid: number;
|
|
retailprice: number;
|
|
productcost: number;
|
|
quantity: number;
|
|
}
|
|
|
|
export interface TenantSheetImportPanelProps {
|
|
tenantid: number | undefined;
|
|
locationid: number | undefined;
|
|
}
|
|
|
|
/**
|
|
* 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 function TenantSheetImportPanel({ tenantid, locationid }: TenantSheetImportPanelProps) {
|
|
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);
|
|
|
|
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);
|
|
if (!chosen) return;
|
|
|
|
try {
|
|
setParsed(await parseProductSheet(chosen));
|
|
} catch (cause) {
|
|
setParseError(errorMessage(cause));
|
|
}
|
|
}
|
|
|
|
async function handleImport() {
|
|
if (!parsed || !tenantid || !locationid) return;
|
|
setProgress({ done: 0, total: parsed.rows.length });
|
|
try {
|
|
const outcome = await importSheetProducts({
|
|
tenantid,
|
|
locationid,
|
|
rows: parsed.rows,
|
|
onProgress: (done, total) => setProgress({ done, total }),
|
|
});
|
|
setResult(outcome);
|
|
} catch (cause) {
|
|
setParseError(errorMessage(cause));
|
|
} finally {
|
|
setProgress(null);
|
|
}
|
|
}
|
|
|
|
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>
|
|
),
|
|
},
|
|
{
|
|
key: 'categoryid',
|
|
header: 'Category',
|
|
align: 'end',
|
|
width: { type: 'pixel', value: 100 },
|
|
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.categoryid}</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,
|
|
categoryid: row.categoryid,
|
|
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 });
|
|
|
|
if (result) {
|
|
const isClean = result.failures.length === 0;
|
|
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)' }} />
|
|
) : (
|
|
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
|
)}
|
|
<Text type="large" weight="semibold">
|
|
{result.created} product{result.created === 1 ? '' : 's'} imported
|
|
</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>
|
|
|
|
{!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}
|
|
</Text>
|
|
</HStack>
|
|
))}
|
|
</VStack>
|
|
) : null}
|
|
|
|
<HStack>
|
|
<Button
|
|
label="Import another file"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setResult(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()}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<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)' }}>
|
|
Required columns: productname, productsku, categoryid, retailprice, productcost. The
|
|
catalogue's category names do not map to a tenant's own ids, so categoryid must be in
|
|
the file.
|
|
</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}
|
|
|
|
{progress ? (
|
|
<ProgressBar
|
|
label="Importing"
|
|
value={progress.done}
|
|
max={progress.total}
|
|
hasValueLabel
|
|
formatValueLabel={(value, max) => `${value} of ${max}`}
|
|
/>
|
|
) : 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.
|
|
</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>
|
|
</VStack>
|
|
</Card>
|
|
) : null}
|
|
</VStack>
|
|
);
|
|
}
|