Files
daily_console_web/src/features/store-admin/TenantSheetImportPanel.tsx
2026-09-07 11:00:44 +05:30

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>
);
}