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 { downloadTemplate, parseProductSheet, type ParsedSheet, } from '@/features/nearle-admin/import/parseProductSheet'; interface PreviewRow extends Record { 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(null); const [parsed, setParsed] = useState(null); const [parseError, setParseError] = useState(null); const [progress, setProgress] = useState<{ done: number; total: number } | null>(null); const [result, setResult] = useState(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[] = [ { key: 'productname', header: 'Product', width: { type: 'proportional', value: 3 }, renderCell: (row) => ( {row.productname} {row.productsku} ), }, { key: 'categoryid', header: 'Category', align: 'end', width: { type: 'pixel', value: 100 }, renderCell: (row) => {row.categoryid}, }, { key: 'retailprice', header: 'Retail', align: 'end', width: { type: 'pixel', value: 100 }, renderCell: (row) => ( ₹{row.retailprice} ), }, { key: 'productcost', header: 'Cost', align: 'end', width: { type: 'pixel', value: 100 }, renderCell: (row) => ( ₹{row.productcost} ), }, { key: 'quantity', header: 'Opening stock', align: 'end', width: { type: 'pixel', value: 130 }, renderCell: (row) => {row.quantity}, }, ]; const preview: PreviewRow[] = (parsed?.rows ?? []).slice(0, 25).map((row: SheetProductRow) => ({ productname: row.productname, productsku: row.productsku, categoryid: row.categoryid, retailprice: row.retailprice, productcost: row.productcost, quantity: row.quantity, })); if (result) { const isClean = result.failures.length === 0; return ( {isClean ? ( ) : ( )} {result.created} product{result.created === 1 ? '' : 's'} imported {result.linked} linked to the store, {result.stocked} given opening stock. {isClean ? '' : ` ${result.failures.length} row(s) did not make it.`} {!isClean ? ( {result.failures.slice(0, 10).map((failure, index) => ( {failure.reason} ))} ) : null}