// src/pages/DashboardPage/BillingPage/InventoryImportDialog.tsx // Invoicing charge-catalog epic, Feature C / sub-task C4: the CSV/JSON inventory Import wizard. Sits // beside InventoryCatalog.tsx (which owns the SKU column + SKU input, C2) and drives a five-step flow: // upload map columns preview (validated + New/Update-by-SKU) import (batched upsert) done. // // The PURE parse/map/resolve logic lives in./inventoryImport.ts (C3, unit-tested); this file only adds // the async layer the pure core deliberately leaves out - the SKU lookup (findChargeItemDefinitionBySku) // that tags a valid row Update-vs-New, and the batched create/update upsert. Both the lookups and the // upserts are run through `runBatched` at a concurrency cap of 5 so a large import can't hammer the API. // // House conventions: zero WordPress (Radix ui/* + Tailwind + lucide only); no Cancel button (the dialog X // closes - the wizard's Back buttons navigate steps, they don't cancel); money crosses centsdisplay only // via money.ts (formatMoney) - priceCents already comes resolved from resolveImportRows. The item resource // is built ONLY through buildInventoryChargeItemDefinition, carrying the found CID's id+url on an Update so // the PUT targets the existing item instead of forking a duplicate (the established CID id/url lesson). // // Mounted/unmounted by the parent's `importOpen` state (like InventoryFormDialog), so local wizard state // always starts fresh - no reset effect needed. On close, onClose(didImport) tells the parent whether an // import succeeded so it can refresh the list (invalidatePaginatorContext + Paginator refresh live there - // a full refetch is cleaner than piecemeal actions.addItem, since a bulk import can span pages). import { useState, type ChangeEvent, type CSSProperties } from 'react'; import { UploadCloud, AlertCircle, Loader2, CheckCircle2 } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '../../../components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../../components/ui/select'; import { useChargeItemDefinitionServiceContext } from '../../../services'; import { parseImportFile, suggestMapping, resolveImportRows, type ImportTable, type ImportMapping, type InventoryFieldKey, type ResolvedImportRow, } from './inventoryImport'; import { buildInventoryChargeItemDefinition } from '../../../config/chargeItemDefinition'; import { formatMoney } from '../../../utils/money'; type WizardStep = 'upload' | 'map' | 'preview' | 'importing' | 'done'; // A resolved row tagged with its upsert action. `update` carries the matched CID's id+url so the upsert // PUTs the existing resource; `new` creates; `skip` is an invalid row (never imported). type PreviewRow = ResolvedImportRow & { action: 'new' | 'update' | 'skip'; existingId?: string; existingUrl?: string; }; // Radix Select forbids an empty-string item value, so "unmapped" is a sentinel rather than ''. const NONE_VALUE = '__none__'; // The four mappable fields, in display order. name + price are required (a row can't import without // them); sku + taxable are optional and get a "- None -" choice. const FIELDS: { key: InventoryFieldKey; label: string; required: boolean }[] = [ { key: 'name', label: 'Name', required: true }, { key: 'sku', label: 'SKU', required: false }, { key: 'price', label: 'Price', required: true }, { key: 'taxable', label: 'Taxable', required: false }, ]; // Run async thunks over `items` with at most `limit` in flight - a tiny hand-rolled concurrency pool (no // new dependency). Each worker pulls the next index off a shared cursor until the list is drained; the // per-item `fn` owns its own try/catch so one failure never rejects the shared Promise.all. const runBatched = async (items: T[], limit: number, fn: (item: T) => Promise): Promise => { let i = 0; const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { while (i < items.length) { const idx = i++; await fn(items[idx]); } }); await Promise.all(workers); }; const InventoryImportDialog = ({ onClose, }: { // `didImport` is true when at least one item was created or updated - the parent uses it to decide // whether to refresh the list. onClose: (didImport: boolean) => void; }) => { const { findChargeItemDefinitionBySku, createChargeItemDefinition, updateChargeItemDefinition } = useChargeItemDefinitionServiceContext(); const [step, setStep] = useState('upload'); const [parseError, setParseError] = useState(); const [table, setTable] = useState(null); const [mapping, setMapping] = useState(null); const [previewRows, setPreviewRows] = useState([]); const [previewLoading, setPreviewLoading] = useState(false); const [progress, setProgress] = useState({ done: 0, total: 0 }); const [result, setResult] = useState<{ created: number; updated: number; failed: number } | null>(null); const [importSucceeded, setImportSucceeded] = useState(false); const newCount = previewRows.filter((r) => r.action === 'new').length; const updateCount = previewRows.filter((r) => r.action === 'update').length; const skipCount = previewRows.filter((r) => r.action === 'skip').length; const validCount = newCount + updateCount; // The dialog X / overlay / Esc all route here. Never close mid-import (a half-run batch would leave // the list in an ambiguous state); otherwise tell the parent whether anything landed. const requestClose = () => { if (step === 'importing') return; onClose(importSucceeded); }; // Upload step: read the picked file, parse it (surfacing a friendly error on failure), seed the // suggested mapping, and advance. Clearing the input value lets the user re-pick the same file after // a parse error (a same-value change wouldn't otherwise fire onChange). const handleFile = async (event: ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ''; if (!file) { return; } setParseError(undefined); let text: string; try { text = await file.text(); } catch { setParseError('Could not read the file.'); return; } let parsed: ImportTable; try { parsed = parseImportFile(text, file.name); } catch (err) { setParseError(err instanceof Error ? err.message : 'Could not parse the file.'); return; } if (parsed.headers.length === 0) { setParseError('No columns were found in the file.'); return; } setTable(parsed); setMapping(suggestMapping(parsed.headers)); setStep('map'); }; const setField = (key: InventoryFieldKey, value: string) => { setMapping((prev) => (prev ? { ...prev, [key]: value === NONE_VALUE ? null : value } : prev)); }; // Preview step: resolve every row, then look up a matching CID for each valid row that carries a SKU // (batched, cap 5) to tag it Update; rows without a SKU (or with no match) stay New. A lookup that // throws leaves the row as New rather than aborting the whole preview. const goToPreview = async () => { if (!table || !mapping) { return; } const rows: PreviewRow[] = resolveImportRows(table, mapping).map((r) => ({ ...r, action: r.valid ? 'new' : 'skip', })); setPreviewRows(rows); setStep('preview'); const targets = rows .map((row, idx) => ({ row, idx })) .filter(({ row }) => row.valid && Boolean(row.sku)); if (targets.length === 0) { return; } setPreviewLoading(true); try { await runBatched(targets, 5, async ({ row, idx }) => { try { const found = await findChargeItemDefinitionBySku(row.sku as string); if (found) { rows[idx] = { ...rows[idx], action: 'update', existingId: found.id, existingUrl: found.url }; } } catch { // Lookup failed — treat as New (can't confirm a match). SKU uniqueness is soft anyway. } }); setPreviewRows([...rows]); } finally { setPreviewLoading(false); } }; // Import step: upsert every non-skip row, batched (cap 5), tracking a live progress count. Each row's // failure is caught and counted - it never aborts the batch. Update carries the matched id+url; // New omits both so the builder mints a fresh url. const runImport = async () => { const rows = previewRows.filter((r) => r.action !== 'skip'); setStep('importing'); setProgress({ done: 0, total: rows.length }); let created = 0; let updated = 0; let failed = 0; await runBatched(rows, 5, async (row) => { try { if (row.action === 'update') { await updateChargeItemDefinition( buildInventoryChargeItemDefinition({ id: row.existingId, url: row.existingUrl, title: row.name, priceCents: row.priceCents, taxable: row.taxable, sku: row.sku, }), ); updated++; } else { await createChargeItemDefinition( buildInventoryChargeItemDefinition({ title: row.name, priceCents: row.priceCents, taxable: row.taxable, sku: row.sku, }), ); created++; } } catch (err) { console.error('Failed to import inventory row:', row.name, err); failed++; } finally { setProgress((prev) => ({ ...prev, done: prev.done + 1 })); } }); setResult({ created, updated, failed }); setImportSucceeded(created + updated > 0); setStep('done'); }; const canContinueMapping = Boolean(mapping?.name) && Boolean(mapping?.price); return ( { if (!next) requestClose(); }} > Import inventory {step === 'upload' && 'Upload a CSV or JSON file of inventory items.'} {step === 'map' && 'Match your file’s columns to inventory fields.'} {step === 'preview' && 'Review what will be imported. Items are matched by SKU.'} {step === 'importing' && 'Importing your items…'} {step === 'done' && 'Import complete.'} {step === 'upload' && (
{parseError && (

{parseError}

)}
)} {step === 'map' && mapping && table && (
{FIELDS.map(({ key, label, required }) => (
))} {!canContinueMapping && (

Map both Name and{' '} Price to continue.

)}
)} {step === 'preview' && (
{validCount} to import — {newCount} new, {updateCount} update, {skipCount} skipped {previewLoading && }
{previewRows.map((row, idx) => ( ))}
Name SKU Price Taxable Status
{row.name || '—'} {row.sku ?? '—'} {formatMoney(row.priceCents)} {row.taxable ? 'Taxable' : 'Non-taxable'} {row.action === 'skip' ? ( Skipped: {row.error} ) : row.action === 'update' ? ( Update ) : ( New )}
)} {step === 'importing' && (
Importing {progress.done} / {progress.total}…
)} {step === 'done' && result && (
Imported: {result.created} created, {result.updated} updated, {result.failed} failed
)}
); }; export default InventoryImportDialog;