From 9ab5d4300a2f86467d9f379e00ff47cbdb10d6c5 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 16 Apr 2026 14:49:11 -0400 Subject: [PATCH] Add create PO page, remove old quick order builder from forecasting page, reorder sidebar, combine brands/vendors pages --- inventory-server/chat/export-chat-data.sh | 0 inventory-server/chat/verify-migration.js | 0 inventory-server/src/routes/products.js | 146 +++ inventory/src/App.tsx | 18 +- .../components/auth/FirstAccessiblePage.tsx | 2 +- inventory/src/components/auth/PERMISSIONS.md | 3 +- .../create-po/AddProductsDialog.tsx | 787 +++++++++++++ .../components/create-po/ConfirmationView.tsx | 57 + .../components/create-po/LineItemsTable.tsx | 354 ++++++ .../create-po/PoFloatingSelectionBar.tsx | 62 + .../create-po/ReviewMatchesDialog.tsx | 251 ++++ .../components/create-po/SupplierSelector.tsx | 64 ++ .../src/components/create-po/constants.ts | 13 + .../components/create-po/parseSpreadsheet.ts | 353 ++++++ .../create-po/resolveIdentifiers.ts | 137 +++ inventory/src/components/create-po/types.ts | 76 ++ .../forecasting/QuickOrderBuilder.tsx | 956 ---------------- .../src/components/forecasting/columns.tsx | 5 - .../src/components/layout/AppSidebar.tsx | 122 +- inventory/src/pages/Brands.tsx | 1015 +++++++++++------ inventory/src/pages/CreatePurchaseOrder.tsx | 318 ++++++ inventory/src/pages/Forecasting.tsx | 18 - inventory/src/pages/Vendors.tsx | 506 -------- inventory/src/services/apiv2.ts | 118 ++ inventory/src/utils/productUtils.ts | 13 + inventory/tsconfig.tsbuildinfo | 2 +- 26 files changed, 3506 insertions(+), 1890 deletions(-) mode change 100755 => 100644 inventory-server/chat/export-chat-data.sh mode change 100755 => 100644 inventory-server/chat/verify-migration.js create mode 100644 inventory/src/components/create-po/AddProductsDialog.tsx create mode 100644 inventory/src/components/create-po/ConfirmationView.tsx create mode 100644 inventory/src/components/create-po/LineItemsTable.tsx create mode 100644 inventory/src/components/create-po/PoFloatingSelectionBar.tsx create mode 100644 inventory/src/components/create-po/ReviewMatchesDialog.tsx create mode 100644 inventory/src/components/create-po/SupplierSelector.tsx create mode 100644 inventory/src/components/create-po/constants.ts create mode 100644 inventory/src/components/create-po/parseSpreadsheet.ts create mode 100644 inventory/src/components/create-po/resolveIdentifiers.ts create mode 100644 inventory/src/components/create-po/types.ts delete mode 100644 inventory/src/components/forecasting/QuickOrderBuilder.tsx create mode 100644 inventory/src/pages/CreatePurchaseOrder.tsx delete mode 100644 inventory/src/pages/Vendors.tsx diff --git a/inventory-server/chat/export-chat-data.sh b/inventory-server/chat/export-chat-data.sh old mode 100755 new mode 100644 diff --git a/inventory-server/chat/verify-migration.js b/inventory-server/chat/verify-migration.js old mode 100755 new mode 100644 diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index e9844ac..d9b1b67 100644 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -504,6 +504,152 @@ router.get('/search', async (req, res) => { } }); +// Batch lookup of product display data by pid list (used by Create PO page) +// Accepts ?pids=1,2,3 — comma-separated; de-duped server-side; capped at 500. +// Returns rows in the same order as the deduped input pids; missing pids are silently dropped. +router.get('/batch', async (req, res) => { + const pool = req.app.locals.pool; + const raw = req.query.pids; + if (!raw || typeof raw !== 'string') { + return res.status(400).json({ error: 'pids query parameter is required' }); + } + + const pids = Array.from(new Set( + raw.split(',') + .map(s => parseInt(s.trim(), 10)) + .filter(n => Number.isInteger(n) && n > 0) + )).slice(0, 500); + + if (pids.length === 0) { + return res.status(400).json({ error: 'No valid pids provided' }); + } + + try { + const { rows } = await pool.query(` + SELECT + p.pid, + p.title, + p.image_175 AS image_url, + p.barcode, + p.vendor_reference, + p.notions_reference, + p.notions_inv_count, + pm.current_stock, + p.baskets, + pm.on_order_qty, + p.total_sold, + pm.current_cost_price, + pm.date_last_sold, + pm.date_first_received, + p.moq + FROM products p + LEFT JOIN product_metrics pm ON pm.pid = p.pid + WHERE p.pid = ANY($1::bigint[]) + `, [pids]); + + // products.pid is BIGINT, which the pg driver returns as a STRING by + // default (to preserve precision for values > 2^53). Coerce to Number + // so the JSON response has numeric pids and Map lookups work. + const normalized = rows.map(r => ({ ...r, pid: Number(r.pid) })); + const byPid = new Map(normalized.map(r => [r.pid, r])); + // Preserve the requested order so the frontend can append rows in input order + const ordered = pids.map(pid => byPid.get(pid)).filter(Boolean); + res.json(ordered); + } catch (error) { + console.error('Error fetching batch products:', error); + res.status(500).json({ error: 'Failed to fetch products' }); + } +}); + +// Bulk resolve a list of identifiers (UPC / SKU / supplier # / notions # / pid) +// to candidate products in ONE query. Used by the Create PO paste/upload flow. +// Body: { identifiers: string[] } +// Response: { results: Array<{ identifier: string, candidates: Candidate[] }> } +// Results are returned in the same order as the input identifiers, with +// duplicates preserved (so the caller can pair results back to input rows +// positionally). +router.post('/resolve-identifiers', async (req, res) => { + const pool = req.app.locals.pool; + const body = req.body || {}; + const raw = Array.isArray(body.identifiers) ? body.identifiers : null; + + if (!raw) { + return res.status(400).json({ error: 'identifiers array is required' }); + } + + // Clean and cap. Cleaned keeps ORIGINAL order (duplicates preserved) so the + // response aligns with the caller's input rows positionally. + const cleaned = raw + .map(s => (typeof s === 'string' ? s.trim() : '')) + .filter(s => s.length > 0) + .slice(0, 1000); + + if (cleaned.length === 0) { + return res.json({ results: [] }); + } + + // Dedupe for the DB lookup, and split numeric-looking values off for a + // separate bigint equality check (so the pid index can be used). + const uniqueTextIds = Array.from(new Set(cleaned)); + const numericPids = Array.from(new Set( + uniqueTextIds + .filter(s => /^\d+$/.test(s) && s.length <= 18) // safe for Number() + .map(s => Number(s)) + .filter(n => Number.isSafeInteger(n) && n > 0) + )); + + try { + const { rows } = await pool.query(` + SELECT + p.pid, + p.title, + p.sku, + p.barcode, + p.vendor_reference, + p.notions_reference, + p.brand + FROM products p + WHERE p.sku = ANY($1::text[]) + OR p.barcode = ANY($1::text[]) + OR p.vendor_reference = ANY($1::text[]) + OR p.notions_reference = ANY($1::text[]) + OR p.pid = ANY($2::bigint[]) + `, [uniqueTextIds, numericPids]); + + // Normalize pid to Number once (products.pid is BIGINT → pg returns string) + const products = rows.map(r => ({ + pid: Number(r.pid), + title: r.title, + sku: r.sku, + barcode: r.barcode, + vendor_reference: r.vendor_reference, + notions_reference: r.notions_reference, + brand: r.brand, + })); + + // Group per-input-identifier. A product counts as a match for an + // identifier if any of its indexable fields equals the identifier string + // (or the pid matches when the identifier is numeric). The comparison is + // done in JS against the fetched products — cheap because the product + // count is bounded by the DB result set. + const results = cleaned.map(identifier => { + const candidates = products.filter(p => ( + p.sku === identifier || + p.barcode === identifier || + p.vendor_reference === identifier || + p.notions_reference === identifier || + String(p.pid) === identifier + )); + return { identifier, candidates }; + }); + + res.json({ results }); + } catch (error) { + console.error('Error resolving identifiers:', error); + res.status(500).json({ error: 'Failed to resolve identifiers' }); + } +}); + // Get a single product router.get('/:id', async (req, res) => { try { diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index 453ed9e..1fb0f1a 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -23,11 +23,11 @@ const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ defau const Forecasting = lazy(() => import('./pages/Forecasting')); const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator')); const HtsLookup = lazy(() => import('./pages/HtsLookup')); -const Vendors = lazy(() => import('./pages/Vendors')); -const Categories = lazy(() => import('./pages/Categories')); +const Categories = lazy(() => import('./pages/Categories')); const Brands = lazy(() => import('./pages/Brands')); const ProductLines = lazy(() => import('./pages/ProductLines')); const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders')); +const CreatePurchaseOrder = lazy(() => import('./pages/CreatePurchaseOrder')); const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard')); const Newsletter = lazy(() => import('./pages/Newsletter')); const RepeatOrders = lazy(() => import('./pages/RepeatOrders')); @@ -137,13 +137,6 @@ function App() { } /> - - }> - - - - } /> }> @@ -200,6 +193,13 @@ function App() { } /> + + }> + + + + } /> {/* Always loaded settings */} ; +} + +interface AddProductsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Pids that are already on the PO; used to dim/disable in search results. */ + existingPids: Set; + /** Called when the user has finalized a list of pids to add. */ + onAdd: (result: AddProductsResult) => void; +} + +interface QuickSearchResult { + pid: number; + title: string; + sku: string; + barcode: string; + brand: string; + line: string; + regular_price: number; + image_175: string | null; +} + +const SEARCH_LIMIT = 100; + +export function AddProductsDialog({ + open, + onOpenChange, + existingPids, + onAdd, +}: AddProductsDialogProps) { + // ----- Search state -------------------------------------------------------- + const [query, setQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [searchTotal, setSearchTotal] = useState(0); + const [isSearching, setIsSearching] = useState(false); + const [searched, setSearched] = useState(false); + + // ----- Parse state (from paste/upload) ------------------------------------ + const [table, setTable] = useState(null); + const [mapping, setMapping] = useState(null); + const [filename, setFilename] = useState(null); + + // ----- Review (ambiguous match) state -------------------------------------- + const [reviewOpen, setReviewOpen] = useState(false); + const [reviewResult, setReviewResult] = useState(null); + const [resolving, setResolving] = useState(false); + + const fileInputRef = useRef(null); + + // Mode is derived: if we have a parsed table, we're showing the preview; + // otherwise we're in search mode. + const inParseMode = table !== null; + + // Computed at the dialog level so the sticky footer's Add button shows an + // accurate count without having to plumb state out of ParsedPreview. + // applyMapping is pure, so ParsedPreview computing the same thing for its + // own summary line / row highlighting can't drift from this value. + const parseImportable = useMemo(() => { + if (!table || !mapping || mapping.identifierIdx < 0) { + return { count: 0, hasIdentifier: false }; + } + return { + count: applyMapping(table, mapping).length, + hasIdentifier: true, + }; + }, [table, mapping]); + + // ----- Reset everything (used by Start over button) ----------------------- + const resetAll = useCallback(() => { + setTable(null); + setMapping(null); + setFilename(null); + setQuery(""); + setSearchResults([]); + setSearchTotal(0); + setSearched(false); + }, []); + + // ----- Close handler that also resets ------------------------------------ + const handleClose = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) resetAll(); + onOpenChange(nextOpen); + }, + [onOpenChange, resetAll] + ); + + // ----- Search -------------------------------------------------------------- + const runSearch = useCallback(async () => { + if (!query.trim()) return; + setIsSearching(true); + setSearched(true); + try { + const res = await axios.get<{ results: QuickSearchResult[]; total: number }>( + "/api/products/search", + { params: { q: query } } + ); + setSearchResults(res.data.results ?? []); + setSearchTotal(res.data.total ?? 0); + } catch { + toast.error("Search failed"); + } finally { + setIsSearching(false); + } + }, [query]); + + const handleSelectSearchResult = useCallback( + async (result: QuickSearchResult) => { + // Look up full details to pull MOQ (to default qty sensibly) + try { + const res = await axios.get("/api/import/search-products", { + params: { pid: result.pid }, + }); + const full = (res.data ?? [])[0]; + const moq = full?.moq && full.moq > 0 ? full.moq : 1; + onAdd({ items: [{ pid: Number(result.pid), qty: moq }] }); + } catch { + // Fall back to qty=1 if the detail lookup fails — still adds the product + onAdd({ items: [{ pid: Number(result.pid), qty: 1 }] }); + } + handleClose(false); + }, + [onAdd, handleClose] + ); + + const handleLoadAllSearchResults = useCallback(async () => { + const pids = searchResults + .map((r) => Number(r.pid)) + .filter((pid) => !existingPids.has(pid)); + if (pids.length === 0) return; + + try { + const res = await axios.get("/api/import/search-products", { + params: { pid: pids.join(",") }, + }); + const items = (res.data ?? []).map((p) => ({ + pid: Number(p.pid), + qty: p.moq && p.moq > 0 ? p.moq : 1, + })); + onAdd({ items }); + handleClose(false); + } catch { + toast.error("Failed to load products"); + } + }, [searchResults, existingPids, onAdd, handleClose]); + + // ----- Paste interception -------------------------------------------------- + // If the clipboard content looks like tabular data (contains newlines or + // tabs), we hijack the paste and switch to parse mode instead of letting + // the text land in the search input. + const handlePaste = useCallback((e: React.ClipboardEvent) => { + const text = e.clipboardData.getData("text"); + if (!text || (!text.includes("\n") && !text.includes("\t"))) { + // Normal paste — let it through + return; + } + e.preventDefault(); + const parsed = parsePastedTable(text); + if (!parsed.headers.length || !parsed.rows.length) { + toast.error("No data detected in pasted content"); + return; + } + setTable(parsed); + setMapping(autoDetectColumns(parsed.headers, parsed.rows)); + setFilename(null); + setQuery(""); + }, []); + + // ----- File handling ------------------------------------------------------- + const handleFile = useCallback(async (file: File) => { + setFilename(file.name); + try { + const buffer = await file.arrayBuffer(); + const parsed = parseWorkbookFirstSheet(buffer); + if (!parsed.headers.length || !parsed.rows.length) { + toast.error("No data found in file"); + setFilename(null); + return; + } + setTable(parsed); + setMapping(autoDetectColumns(parsed.headers, parsed.rows)); + } catch (e) { + console.error(e); + toast.error("Could not parse file"); + setFilename(null); + } + }, []); + + const handleFileInputChange = useCallback( + (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f) void handleFile(f); + // Reset the input so selecting the same file again still fires onChange + e.target.value = ""; + }, + [handleFile] + ); + + // Full-dialog dropzone — the user can drop a file anywhere on the modal + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + noClick: true, + noKeyboard: true, + maxFiles: 1, + accept: { + "application/vnd.ms-excel": [".xls"], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"], + "text/csv": [".csv"], + }, + onDropAccepted: ([file]) => { + void handleFile(file); + }, + onDropRejected: (rejections) => { + const msg = rejections[0]?.errors[0]?.message || "Invalid file"; + toast.error(msg); + }, + }); + + // ----- Parse preview → resolve → review → onAdd pipeline ------------------- + const handleResolveAndAdd = useCallback(async () => { + if (!table || !mapping) return; + const rows = applyMapping(table, mapping); + if (rows.length === 0) { + toast.error("No valid rows to import"); + return; + } + setResolving(true); + try { + const result = await resolveIdentifiers(rows); + // Fast path: everything matched cleanly → skip the review dialog + if (result.ambiguous.length === 0 && result.unmatched.length === 0) { + onAdd({ items: result.matched.map((m) => ({ pid: m.pid, qty: m.qty })) }); + handleClose(false); + return; + } + setReviewResult(result); + setReviewOpen(true); + } catch (e) { + console.error(e); + toast.error("Failed to look up products"); + } finally { + setResolving(false); + } + }, [table, mapping, onAdd, handleClose]); + + const handleReviewConfirm = useCallback( + (resolved: Array<{ pid: number; qty: number }>) => { + setReviewOpen(false); + setReviewResult(null); + if (resolved.length === 0) { + toast.warning("No rows selected to add"); + return; + } + onAdd({ items: resolved }); + handleClose(false); + }, + [onAdd, handleClose] + ); + + return ( + <> + + + {/* Hidden file input driven by the Upload button */} + + {/* react-dropzone's input (for keyboard accessibility) */} + + + {/* Drag-over overlay */} + {isDragActive && ( +
+
+ +

Drop file to parse

+
+
+ )} + + + Add products + + + {/* Primary input row — always visible */} +
+
+ {/* Wrapper has px-0.5 so the input's focus ring isn't clipped + by the dialog's rounded clipping region on the left edge */} +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + runSearch(); + } + }} + onPaste={handlePaste} + placeholder="Search by name, UPC, SKU, supplier # — or paste multi-line data" + disabled={inParseMode} + /> +
+ + +
+
+ + {/* Content area: parse preview OR search results OR empty hint. + `flex-1 overflow-auto min-h-0` lets this region shrink and + scroll independently while the sticky footer below stays put. + + isSearching is checked BEFORE the searched branch so that the + moment the user clicks Search (which flips both `searched` and + `isSearching` to true in the same render) we show a loading + state instead of flashing "No results" against the previous + empty results array while the network request is in flight. */} +
+ {inParseMode && table && mapping ? ( + + ) : isSearching ? ( +
+ + Searching… +
+ ) : searched ? ( + + ) : ( +
+ +
+ )} +
+ + {/* Sticky footer: only rendered in parse mode. Because it's a + flex-none sibling of the scrollable content area (not inside + it), it stays fixed at the bottom of the dialog regardless of + how tall the preview table grows. Search mode doesn't need a + footer since adding is click-per-row. */} + {inParseMode && ( +
+ +
+ )} +
+
+ + { + setReviewOpen(o); + if (!o) setReviewResult(null); + }} + result={reviewResult} + onConfirm={handleReviewConfirm} + /> + + ); +} + +// ============================================================================ +// SearchResultsTable — compact results list with click-to-add + load-all +// ============================================================================ + +function SearchResultsTable({ + results, + total, + loadedPids, + onSelect, + onLoadAll, +}: { + results: QuickSearchResult[]; + total: number; + loadedPids: Set; + onSelect: (result: QuickSearchResult) => void; + onLoadAll: () => void; +}) { + if (results.length === 0) { + return ( +
+ No results. Try a different search term. +
+ ); + } + + const isTruncated = total > SEARCH_LIMIT; + const unloadedCount = results.filter((r) => !loadedPids.has(Number(r.pid))).length; + + return ( +
+
+ + {isTruncated + ? `Showing ${SEARCH_LIMIT} of ${total} results` + : `${total} ${total === 1 ? "result" : "results"}`} + + {unloadedCount > 1 && ( + + )} +
+ +
+ + + + Product + Item # + UPC + Brand + Price + + + + {results.map((r) => { + const isLoaded = loadedPids.has(Number(r.pid)); + return ( + !isLoaded && onSelect(r)} + > + +
+ {isLoaded && ( + + )} + {r.title} +
+
+ {r.sku} + {r.barcode} + {r.brand} + + ${Number(r.regular_price).toFixed(2)} + +
+ ); + })} +
+
+
+
+ ); +} + +// ============================================================================ +// ParsedPreview — column-role mapping + data preview for paste/upload +// ============================================================================ + +function ParsedPreview({ + table, + mapping, + filename, + onMappingChange, + onReset, +}: { + table: ParsedTable; + mapping: DetectedMapping; + filename: string | null; + onMappingChange: (m: DetectedMapping) => void; + onReset: () => void; +}) { + // Precompute the exact set of row indices that will be imported with the + // current mapping. Used both for the aggregate count and for row-level + // "will be dropped" visual feedback. When no qty column is assigned, + // each row gets an implicit qty=1 — so the check only cares about the + // identifier cell being non-empty. + const importableIndices = useMemo(() => { + const set = new Set(); + if (mapping.identifierIdx < 0) return set; + const hasQtyCol = mapping.qtyIdx >= 0; + table.rows.forEach((row, idx) => { + const identifier = (row[mapping.identifierIdx] || "").trim(); + if (!identifier) return; + if (hasQtyCol) { + const qtyStr = (row[mapping.qtyIdx] || "").trim(); + const cleaned = qtyStr.replace(/[^0-9.-]/g, ""); + const qty = Math.round(Number(cleaned)); + if (!Number.isFinite(qty) || qty <= 0) return; + } + set.add(idx); + }); + return set; + }, [table, mapping]); + const importable = importableIndices.size; + const hasIdentifier = mapping.identifierIdx >= 0; + + const handleRoleChange = useCallback( + (colIdx: number, role: ColumnRole) => { + const newRoles = [...mapping.roles]; + newRoles[colIdx] = role; + // Enforce: at most one identifier and one qty column. Demote any + // previous holder of the role to "ignore". + if (role === "identifier" || role === "qty") { + for (let i = 0; i < newRoles.length; i++) { + if (i !== colIdx && newRoles[i] === role) newRoles[i] = "ignore"; + } + } + onMappingChange({ + identifierIdx: newRoles.findIndex((r) => r === "identifier"), + qtyIdx: newRoles.findIndex((r) => r === "qty"), + roles: newRoles, + }); + }, + [mapping, onMappingChange] + ); + + const previewRows = table.rows.slice(0, 10); + + // Single source of truth for per-role styling. The header cell, the + // data cells in that column, and the dropdown trigger all read from + // these helpers so visual drift is impossible. + const columnBg = (role: ColumnRole) => { + if (role === "identifier") return "bg-emerald-50"; + if (role === "qty") return "bg-sky-50"; + return ""; + }; + const headerBorder = (role: ColumnRole) => { + if (role === "identifier") return "border-b-2 border-emerald-500"; + if (role === "qty") return "border-b-2 border-sky-500"; + return "border-b border-border"; + }; + const triggerStyle = (role: ColumnRole) => { + if (role === "identifier") return "border-emerald-500 text-emerald-900 font-medium"; + if (role === "qty") return "border-sky-500 text-sky-900 font-medium"; + return "text-muted-foreground"; + }; + + return ( +
+
+ + {filename && ( + <> + + {filename} + · + + )} + + {importable}{" "} + of {table.rows.length} {table.rows.length === 1 ? "row" : "rows"}{" "} + will be imported + + + +
+ +
+
+ + + + {table.headers.map((h, i) => { + const role = mapping.roles[i] || "ignore"; + return ( + + ); + })} + + + + {previewRows.map((r, i) => { + const willImport = importableIndices.has(i); + return ( + + {r.map((cell, j) => { + const role = mapping.roles[j] || "ignore"; + return ( + + ); + })} + + ); + })} + +
+
+ {h || `Column ${i + 1}`} +
+ +
+ {cell || } +
+
+ {table.rows.length > 10 && ( +
+ …and {table.rows.length - 10} more rows +
+ )} +
+ + {!hasIdentifier && ( +
+ + Pick an identifier column above to continue. +
+ )} + {hasIdentifier && importable === 0 && ( +
+ + No rows have a valid identifier. +
+ )} +
+ ); +} diff --git a/inventory/src/components/create-po/ConfirmationView.tsx b/inventory/src/components/create-po/ConfirmationView.tsx new file mode 100644 index 0000000..06fc053 --- /dev/null +++ b/inventory/src/components/create-po/ConfirmationView.tsx @@ -0,0 +1,57 @@ +/** + * Post-submit success screen. + * + * Shows when the legacy backend has accepted the PO and returned a po_id. + * The single primary action is the external link to the legacy admin's PO + * editor; secondary action is "Create another" which resets the page. + */ + +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { CheckCircle2, ExternalLink, Plus } from "lucide-react"; + +interface ConfirmationViewProps { + poId: number; + itemCount: number; + onCreateAnother: () => void; +} + +export function ConfirmationView({ + poId, + itemCount, + onCreateAnother, +}: ConfirmationViewProps) { + const externalUrl = `https://backend.acherryontop.com/po/edit/${poId}`; + + return ( +
+ + +
+
+ +
+

Purchase order created

+

+ PO #{poId} with {itemCount} {itemCount === 1 ? "item" : "items"} has been + submitted to the backend. +

+ +
+ + +
+
+
+
+
+ ); +} diff --git a/inventory/src/components/create-po/LineItemsTable.tsx b/inventory/src/components/create-po/LineItemsTable.tsx new file mode 100644 index 0000000..b008575 --- /dev/null +++ b/inventory/src/components/create-po/LineItemsTable.tsx @@ -0,0 +1,354 @@ +/** + * Sortable, checkbox-selectable table of PO line items. + * + * Columns: checkbox · image · title · UPC · supplier#/notions# · shelf · + * basket · on-order · total sold · cost ea · last sold · first in · + * [notions inv (conditional)] · MOQ (editable, local) · qty (editable, + * highlighted on MOQ mismatch) · remove + * + * Sorting is local-only (no server round-trip), driven by clickable + * column headers. The Notions column toggles based on the supplier prop. + * MOQ is inline-editable and updates a per-row `moqOverride` field — + * never sent to the backend, never persisted. + */ + +import { useMemo, useState, useCallback } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { X as XIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { formatCurrency, formatNumber, formatDateShort } from "@/utils/productUtils"; +import type { PoLineItem } from "./types"; +import { NOTIONS_SUPPLIER_ID } from "./constants"; + +type SortKey = + | "title" + | "barcode" + | "supplier_ref" + | "current_stock" + | "baskets" + | "on_order_qty" + | "total_sold" + | "current_cost_price" + | "date_last_sold" + | "date_first_received" + | "notions_inv_count" + | "moq" + | "qty"; + +type SortDir = "asc" | "desc"; + +interface LineItemsTableProps { + items: PoLineItem[]; + selectedPids: Set; + supplierId: number | undefined; + onToggleSelect: (pid: number) => void; + onToggleSelectAll: (selectAll: boolean) => void; + onChangeQty: (pid: number, qty: number) => void; + onChangeMoqOverride: (pid: number, moq: number | undefined) => void; + onRemove: (pid: number) => void; +} + +/** Effective MOQ for a row, considering the user's local override. */ +function effectiveMoq(item: PoLineItem): number | null { + if (item.moqOverride != null) return item.moqOverride; + return item.moq; +} + +/** True if the row's qty isn't a multiple of its (effective) MOQ. */ +function isQtyMoqMismatch(item: PoLineItem): boolean { + const moq = effectiveMoq(item); + if (!moq || moq <= 0) return false; + if (item.qty <= 0) return false; + return item.qty % moq !== 0; +} + +function compareValues(a: unknown, b: unknown, dir: SortDir): number { + const sign = dir === "asc" ? 1 : -1; + // Push nulls/undefineds to the bottom regardless of direction + if (a == null && b == null) return 0; + if (a == null) return 1; + if (b == null) return -1; + if (typeof a === "number" && typeof b === "number") { + return (a - b) * sign; + } + // Date strings sort lexicographically as ISO; fall back to string compare + return String(a).localeCompare(String(b)) * sign; +} + +export function LineItemsTable({ + items, + selectedPids, + supplierId, + onToggleSelect, + onToggleSelectAll, + onChangeQty, + onChangeMoqOverride, + onRemove, +}: LineItemsTableProps) { + const [sortKey, setSortKey] = useState(null); + const [sortDir, setSortDir] = useState("asc"); + + const isNotions = Number(supplierId) === NOTIONS_SUPPLIER_ID; + + const sorted = useMemo(() => { + if (!sortKey) return items; + const accessors: Record unknown> = { + title: (i) => i.title?.toLowerCase(), + barcode: (i) => i.barcode, + supplier_ref: (i) => (isNotions ? i.notions_reference : i.vendor_reference), + current_stock: (i) => i.current_stock, + baskets: (i) => i.baskets, + on_order_qty: (i) => i.on_order_qty, + total_sold: (i) => i.total_sold, + current_cost_price: (i) => i.current_cost_price, + date_last_sold: (i) => i.date_last_sold, + date_first_received: (i) => i.date_first_received, + notions_inv_count: (i) => i.notions_inv_count, + moq: (i) => effectiveMoq(i), + qty: (i) => i.qty, + }; + const accessor = accessors[sortKey]; + return [...items].sort((a, b) => compareValues(accessor(a), accessor(b), sortDir)); + }, [items, sortKey, sortDir, isNotions]); + + const handleSort = useCallback( + (key: SortKey) => { + if (sortKey === key) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir("asc"); + } + }, + [sortKey] + ); + + const allSelected = + items.length > 0 && items.every((i) => selectedPids.has(i.pid)); + const someSelected = !allSelected && items.some((i) => selectedPids.has(i.pid)); + + if (items.length === 0) { + return ( +
+

No products added yet.

+
+ ); + } + + const SortableHead = ({ + label, + sortBy, + align = "left", + className, + }: { + label: string; + sortBy: SortKey; + align?: "left" | "right" | "center"; + className?: string; + }) => { + const isActive = sortKey === sortBy; + return ( + + + + ); + }; + + return ( +
+ + + + + onToggleSelectAll(Boolean(v))} + aria-label="Select all rows" + /> + + + Image + + + + + + + + + + + + {isNotions && ( + + )} + + + + + {sorted.map((item) => { + const isSelected = selectedPids.has(item.pid); + const moq = effectiveMoq(item); + const mismatch = isQtyMoqMismatch(item); + return ( + + + onToggleSelect(item.pid)} + aria-label={`Select ${item.title}`} + /> + + + { + const n = Math.max( + 0, + Math.round(Number(e.target.value.replace(/[^0-9]/g, "")) || 0) + ); + onChangeQty(item.pid, n); + }} + className={cn( + "w-14 h-8 text-center", + mismatch && + "border-amber-500 focus-visible:ring-amber-500 bg-amber-50" + )} + aria-label={`Quantity for ${item.title}`} + title={ + mismatch + ? `Not a multiple of MOQ (${moq})` + : undefined + } + /> + + + {item.image_url ? ( + {item.title} + ) : ( +
+ )} + + +
+ {item.title} +
+
+ + {item.barcode || "—"} + + + {(isNotions ? item.notions_reference : item.vendor_reference) || "—"} + + + { + const v = e.target.value.replace(/[^0-9]/g, ""); + if (v === "") { + onChangeMoqOverride(item.pid, 0); + } else { + const n = Math.max(0, Math.round(Number(v))); + onChangeMoqOverride(item.pid, Number.isFinite(n) ? n : 0); + } + }} + className="w-14 h-8 text-center text-xs" + aria-label={`MOQ for ${item.title}`} + /> + + + {item.current_stock != null ? formatNumber(item.current_stock) : "—"} + + + {item.baskets != null ? formatNumber(item.baskets) : "—"} + + + {item.on_order_qty != null ? formatNumber(item.on_order_qty) : "—"} + + + {item.total_sold != null ? formatNumber(item.total_sold) : "—"} + + + {item.current_cost_price != null + ? formatCurrency(item.current_cost_price) + : "—"} + + + {item.date_last_sold ? formatDateShort(item.date_last_sold) : "—"} + + + {item.date_first_received ? formatDateShort(item.date_first_received) : "—"} + + {isNotions && ( + + {item.notions_inv_count != null + ? formatNumber(item.notions_inv_count) + : "—"} + + )} + + + + + ); + })} + +
+
+ ); +} diff --git a/inventory/src/components/create-po/PoFloatingSelectionBar.tsx b/inventory/src/components/create-po/PoFloatingSelectionBar.tsx new file mode 100644 index 0000000..e4e32c7 --- /dev/null +++ b/inventory/src/components/create-po/PoFloatingSelectionBar.tsx @@ -0,0 +1,62 @@ +/** + * Floating action bar that appears when one or more PO line items are + * checkbox-selected. Provides bulk Remove + Clear selection. + * + * Pattern adapted from inventory/src/components/product-import/steps/ + * ValidationStep/components/FloatingSelectionBar.tsx — but stripped down: + * no zustand store, no template management, no delete confirmation + * dialog (Remove on the PO page is local-only and instantly reversible + * by adding the product again, so a confirm step would be friction). + */ + +import { memo } from "react"; +import { Button } from "@/components/ui/button"; +import { X, Trash2 } from "lucide-react"; + +interface PoFloatingSelectionBarProps { + selectedCount: number; + onClear: () => void; + onRemove: () => void; +} + +export const PoFloatingSelectionBar = memo(function PoFloatingSelectionBar({ + selectedCount, + onClear, + onRemove, +}: PoFloatingSelectionBarProps) { + if (selectedCount === 0) return null; + + return ( +
+
+
+
+ {selectedCount} selected +
+ +
+ +
+ + +
+
+ ); +}); diff --git a/inventory/src/components/create-po/ReviewMatchesDialog.tsx b/inventory/src/components/create-po/ReviewMatchesDialog.tsx new file mode 100644 index 0000000..0c19048 --- /dev/null +++ b/inventory/src/components/create-po/ReviewMatchesDialog.tsx @@ -0,0 +1,251 @@ +/** + * Review screen for the paste/upload flow. + * + * Shows the result of `resolveIdentifiers()`: + * - "matched" rows are summarized at the top (no action needed) + * - "ambiguous" rows show a radio-pick of candidates per row + * - "unmatched" rows are listed separately so the user knows what was + * dropped (no inline rescue — user can re-paste a corrected value) + * + * On confirm, the dialog returns the final pid+qty list (matched rows + * plus user-resolved ambiguous rows). Skipped ambiguous rows are + * dropped silently. + */ + +import { useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { CheckCircle2, AlertCircle, HelpCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { ResolveResult } from "./types"; + +interface ReviewMatchesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + result: ResolveResult | null; + onConfirm: (resolved: Array<{ pid: number; qty: number }>) => void; +} + +export function ReviewMatchesDialog({ + open, + onOpenChange, + result, + onConfirm, +}: ReviewMatchesDialogProps) { + // Per-ambiguous-row selected pid (keyed by row index in result.ambiguous) + const [picks, setPicks] = useState>({}); + + // Reset picks whenever a new result comes in + useEffect(() => { + setPicks({}); + }, [result]); + + if (!result) return null; + + const matchedCount = result.matched.length; + const ambiguousCount = result.ambiguous.length; + const unmatchedCount = result.unmatched.length; + + const handleConfirm = () => { + const out: Array<{ pid: number; qty: number }> = result.matched.map((m) => ({ + pid: m.pid, + qty: m.qty, + })); + result.ambiguous.forEach((row, idx) => { + const picked = picks[idx]; + if (picked) out.push({ pid: picked, qty: row.qty }); + }); + onConfirm(out); + }; + + // Skip review entirely if there's nothing ambiguous and nothing unmatched — + // the parent should ideally call onConfirm directly in that case, but we + // also short-circuit here as a safety net. + const reviewNeeded = ambiguousCount > 0 || unmatchedCount > 0; + + return ( + + + + Review imported rows + + {reviewNeeded + ? "Resolve ambiguous matches before adding products to your purchase order." + : "All rows matched successfully."} + + + + {/* Summary cards */} +
+
+
+ + Matched +
+
+ {matchedCount} +
+
+
0 + ? "bg-amber-50 border-amber-200" + : "bg-muted border-border" + )} + > +
0 ? "text-amber-800" : "text-muted-foreground" + )} + > + + Ambiguous +
+
0 ? "text-amber-900" : "text-muted-foreground" + )} + > + {ambiguousCount} +
+
+
0 + ? "bg-destructive/10 border-destructive/30" + : "bg-muted border-border" + )} + > +
0 ? "text-destructive" : "text-muted-foreground" + )} + > + + Unmatched +
+
0 ? "text-destructive" : "text-muted-foreground" + )} + > + {unmatchedCount} +
+
+
+ + + {ambiguousCount > 0 && ( +
+

Pick a product for each ambiguous row

+ {result.ambiguous.map((row, idx) => ( +
+
+
+
Pasted value
+
{row.identifier}
+
+
+
Qty
+
{row.qty}
+
+
+
+ {row.candidates.map((c) => { + const isPicked = picks[idx] === c.pid; + return ( + + ); + })} +
+
+ ))} +
+ )} + + {unmatchedCount > 0 && ( +
+

+ Unmatched (these will be skipped) +

+
+ {result.unmatched.map((row, idx) => ( +
+ {row.identifier} + qty {row.qty} +
+ ))} +
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/inventory/src/components/create-po/SupplierSelector.tsx b/inventory/src/components/create-po/SupplierSelector.tsx new file mode 100644 index 0000000..6df82c9 --- /dev/null +++ b/inventory/src/components/create-po/SupplierSelector.tsx @@ -0,0 +1,64 @@ +/** + * Supplier picker for the Create PO page. + * + * Reuses the existing ComboboxField from product-editor (the canonical + * combobox in this app). Loads the supplier list once via react-query + * using the shared `["field-options"]` queryKey, so the cache is shared + * with any other page that reads the same field options and the + * suppliers list will already be warm if visited elsewhere first. + */ + +import { useQuery } from "@tanstack/react-query"; +import { ComboboxField } from "@/components/product-editor/ComboboxField"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { FieldOption } from "@/components/product-editor/types"; + +interface FieldOptionsResponse { + suppliers?: FieldOption[]; +} + +export function SupplierSelector({ + value, + onChange, + disabled, +}: { + /** The selected supplier ID as a string (matches FieldOption.value type). */ + value: string | undefined; + onChange: (supplierId: string | undefined) => void; + disabled?: boolean; +}) { + const { data, isLoading, error } = useQuery({ + queryKey: ["field-options"], + queryFn: async (): Promise => { + const res = await fetch("/api/import/field-options"); + if (!res.ok) throw new Error("Failed to load suppliers"); + return res.json(); + }, + staleTime: 30 * 60 * 1000, // 30 min — matches the server-side cache TTL + }); + + const options = data?.suppliers ?? []; + + if (isLoading) { + return ; + } + + if (error) { + return ( +
+ Failed to load suppliers. Try refreshing the page. +
+ ); + } + + return ( + onChange(v || undefined)} + placeholder="Select a supplier…" + searchPlaceholder="Search suppliers…" + disabled={disabled} + /> + ); +} diff --git a/inventory/src/components/create-po/constants.ts b/inventory/src/components/create-po/constants.ts new file mode 100644 index 0000000..a456fda --- /dev/null +++ b/inventory/src/components/create-po/constants.ts @@ -0,0 +1,13 @@ +/** + * The Notions supplier has a fixed ID in the legacy backend, hardcoded in + * several places (see inventory-server/src/routes/products.js, import.js). + * When this supplier is selected, the Create PO page swaps "Supplier #" for + * "Notions #" and surfaces the per-product Notions inventory column. + */ +export const NOTIONS_SUPPLIER_ID = 92; + +/** Max pids the backend /api/products/batch endpoint accepts in one call. */ +export const BATCH_LOOKUP_MAX_PIDS = 200; + +/** Max search-result rows we'll attempt to resolve when bulk-adding by paste/upload. */ +export const RESOLVE_LOOKUP_MAX_ROWS = 1000; diff --git a/inventory/src/components/create-po/parseSpreadsheet.ts b/inventory/src/components/create-po/parseSpreadsheet.ts new file mode 100644 index 0000000..bac6877 --- /dev/null +++ b/inventory/src/components/create-po/parseSpreadsheet.ts @@ -0,0 +1,353 @@ +/** + * Spreadsheet parsing helpers for the Create PO page. + * + * The parsing helpers (parsePasted, detectDelimiter, autoMapHeaderNames, + * toIntOrUndefined) were originally adapted from a now-removed + * forecasting/QuickOrderBuilder.tsx prototype, scoped down to the + * 2-column (identifier, qty) PO use case. + * + * Two main entry points: + * - parsePastedTable(text) → headers + rows from a TSV/CSV string + * - parseWorkbookFirstSheet(buf) → headers + rows from an .xlsx/.xls/.csv file + * + * Plus a `autoDetectColumns(headers)` that picks the most likely identifier + * and qty column for the auto-detect path. Both PasteTab and UploadTab + * funnel through these helpers and end at the same RawIdentifierRow[] + * shape consumed by `resolveIdentifiers()`. + */ + +import * as XLSX from "xlsx"; +import type { RawIdentifierRow } from "./types"; + +// --- Header synonym lists ----------------------------------------------------- + +const IDENTIFIER_HEADER_SYNONYMS = [ + "upc", + "barcode", + "bar code", + "ean", + "jan", + "sku", + "item", + "item#", + "item number", + "item no", + "item_no", + "supplier #", + "supplier no", + "supplier_no", + "supplier number", + "notions #", + "notions no", + "notions_no", + "notions number", + "product code", + "code", + "id", + "pid", +]; + +const QTY_HEADER_SYNONYMS = [ + "qty", + "quantity", + "order qty", + "order quantity", + "amount", + "count", + "units", +]; + +function normalizeHeader(h: string): string { + return (h || "").trim().toLowerCase(); +} + +// --- Delimiter detection ------------------------------------------------------ + +function detectDelimiter(text: string): string { + // Heuristic: prefer tab, then comma, then semicolon. Sample first 5 lines. + const lines = text.split(/\r?\n/).slice(0, 5); + const counts: Record = { "\t": 0, ",": 0, ";": 0 }; + for (const line of lines) { + counts["\t"] += (line.match(/\t/g) || []).length; + counts[","] += (line.match(/,/g) || []).length; + counts[";"] += (line.match(/;/g) || []).length; + } + return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; +} + +// --- Pasted text parser ------------------------------------------------------- + +export interface ParsedTable { + headers: string[]; + rows: string[][]; + /** True when the first row appears to be a header (contains non-numeric strings). */ + hasHeader: boolean; +} + +/** + * Parse a pasted TSV/CSV string. Detects delimiter, splits cleanly, pads + * short rows to header width, and trims whitespace per cell. All cells are + * preserved as STRINGS — we never coerce to numbers here, because UPCs with + * leading zeros must survive parsing intact. + * + * Header detection: if the first row's cells are mostly non-numeric strings + * we treat it as a header and pull it out; otherwise we synthesize generic + * "Column 1", "Column 2" headers and treat all rows as data. This makes the + * paste flow forgiving: users can paste data with or without a header row. + */ +export function parsePastedTable(text: string): ParsedTable { + if (!text || !text.trim()) return { headers: [], rows: [], hasHeader: false }; + + const delimiter = detectDelimiter(text); + const lines = text + .split(/\r?\n/) + .map((l) => l.trimEnd()) // trim trailing whitespace but keep tabs as separators + .filter((l) => l.length > 0); + + if (lines.length === 0) return { headers: [], rows: [], hasHeader: false }; + + const split = (line: string) => line.split(delimiter).map((s) => s.trim()); + + const firstRow = split(lines[0]); + const restRows = lines.slice(1).map(split); + + // Header heuristic: at least one cell in the first row is non-numeric and + // not empty, AND there's more than one row of data (otherwise treat the + // single row as data). + const looksLikeHeader = + firstRow.length >= 2 && + firstRow.some((c) => c && !/^[0-9.\-+ ]+$/.test(c)); + + if (looksLikeHeader && restRows.length > 0) { + const headers = firstRow.map((h) => h || ""); + const rows = restRows.map((r) => { + while (r.length < headers.length) r.push(""); + return r; + }); + return { headers, rows, hasHeader: true }; + } + + // No header — synthesize column names and treat every line as data + const allRows = [firstRow, ...restRows]; + const maxCols = Math.max(...allRows.map((r) => r.length)); + const headers = Array.from({ length: maxCols }, (_, i) => `Column ${i + 1}`); + const rows = allRows.map((r) => { + while (r.length < maxCols) r.push(""); + return r; + }); + return { headers, rows, hasHeader: false }; +} + +// --- Workbook (xlsx/xls/csv) parser ------------------------------------------- + +/** + * Parse the first sheet of a workbook (read from an ArrayBuffer) into the + * same {headers, rows} shape as parsePastedTable. All cell values are + * coerced to STRINGS to keep UPCs intact (raw: false). Empty cells become + * empty strings. + */ +export function parseWorkbookFirstSheet(buffer: ArrayBuffer): ParsedTable { + // raw:false → use formatted text values (preserves leading zeros if cell + // is text-formatted in the source spreadsheet); cellDates:true → date + // cells become Date objects which we then stringify. + const wb = XLSX.read(buffer, { + type: "array", + cellDates: true, + raw: false, + codepage: 65001, + WTF: false, + }); + const sheetName = wb.SheetNames[0]; + if (!sheetName) return { headers: [], rows: [], hasHeader: false }; + const sheet = wb.Sheets[sheetName]; + + // header:1 returns rows as arrays; defval ensures missing cells become "" + const aoa = XLSX.utils.sheet_to_json(sheet, { + header: 1, + raw: false, + defval: "", + }); + + if (!aoa.length) return { headers: [], rows: [], hasHeader: false }; + + const stringify = (v: unknown): string => { + if (v === null || v === undefined) return ""; + if (v instanceof Date) return v.toISOString().slice(0, 10); + return String(v).trim(); + }; + + const allRows = aoa.map((row) => row.map(stringify)); + const firstRow = allRows[0]; + const restRows = allRows.slice(1).filter((r) => r.some((c) => c.length > 0)); + + const looksLikeHeader = + firstRow.length >= 2 && + firstRow.some((c) => c && !/^[0-9.\-+ ]+$/.test(c)); + + if (looksLikeHeader && restRows.length > 0) { + const headers = firstRow.map((h) => h || ""); + const rows = restRows.map((r) => { + while (r.length < headers.length) r.push(""); + return r; + }); + return { headers, rows, hasHeader: true }; + } + + const all = restRows.length > 0 ? [firstRow, ...restRows] : [firstRow]; + const maxCols = Math.max(...all.map((r) => r.length)); + const headers = Array.from({ length: maxCols }, (_, i) => `Column ${i + 1}`); + const rows = all.map((r) => { + while (r.length < maxCols) r.push(""); + return r; + }); + return { headers, rows, hasHeader: false }; +} + +// --- Auto-detect column roles ------------------------------------------------- + +export type ColumnRole = "identifier" | "qty" | "ignore"; + +export interface DetectedMapping { + /** Index of the column to treat as the product identifier (UPC/SKU/etc.) */ + identifierIdx: number; + /** Index of the column to treat as the quantity. */ + qtyIdx: number; + /** Per-column role assignment, indexed by header position. */ + roles: ColumnRole[]; +} + +/** + * Best-effort guess of which column is the identifier and which is the qty. + * + * Strategy: + * 1. If headers contain known synonyms, use them. + * 2. Otherwise inspect the first few data rows: a column where most cells + * look numeric AND values are small (<10000) is the qty; the other is + * the identifier. + * 3. Fall back to: column 0 = identifier, column 1 = qty. + */ +export function autoDetectColumns( + headers: string[], + rows: string[][] +): DetectedMapping { + const norm = headers.map(normalizeHeader); + + const findFirst = (syns: string[]): number => { + for (const s of syns) { + const idx = norm.findIndex((h) => h === s || (h && h.includes(s))); + if (idx >= 0) return idx; + } + return -1; + }; + + let identifierIdx = findFirst(IDENTIFIER_HEADER_SYNONYMS); + let qtyIdx = findFirst(QTY_HEADER_SYNONYMS); + + // If headers didn't tell us, look at the data + if (identifierIdx < 0 || qtyIdx < 0) { + const sample = rows.slice(0, 10); + const numCols = headers.length; + const numericLikeRatios: number[] = []; + const avgValues: number[] = []; + for (let c = 0; c < numCols; c++) { + let numericCount = 0; + let sum = 0; + let parsedCount = 0; + for (const row of sample) { + const cell = (row[c] || "").trim(); + if (!cell) continue; + // Treat as "numeric and small" if it parses to an int <= 10000 + if (/^\d+$/.test(cell)) { + const n = Number(cell); + if (Number.isFinite(n)) { + numericCount += 1; + if (n <= 10000) { + sum += n; + parsedCount += 1; + } + } + } + } + numericLikeRatios.push(sample.length > 0 ? numericCount / sample.length : 0); + avgValues.push(parsedCount > 0 ? sum / parsedCount : Infinity); + } + + if (qtyIdx < 0) { + // Best qty candidate: smallest avg value, prefer columns where >50% rows are numeric + let bestIdx = -1; + let bestAvg = Infinity; + for (let c = 0; c < numCols; c++) { + if (numericLikeRatios[c] >= 0.5 && avgValues[c] < bestAvg) { + bestAvg = avgValues[c]; + bestIdx = c; + } + } + qtyIdx = bestIdx; + } + + if (identifierIdx < 0) { + // Identifier = first column that isn't qty + for (let c = 0; c < numCols; c++) { + if (c !== qtyIdx) { + identifierIdx = c; + break; + } + } + } + } + + // Final fallback: identifier must point somewhere; qty is optional and + // stays unassigned (-1) when there's no other column to use. applyMapping + // defaults each row's qty to 1 when qtyIdx is -1 — so a single-column + // paste of UPCs now imports as "one of each" instead of trying to parse + // the UPC itself as a qty (which previously produced billion-unit rows). + if (identifierIdx < 0) identifierIdx = 0; + if (qtyIdx < 0) { + qtyIdx = headers.length > 1 && identifierIdx !== 1 ? 1 : -1; + } + + const roles: ColumnRole[] = headers.map((_, i) => { + if (i === identifierIdx) return "identifier"; + if (i === qtyIdx) return "qty"; + return "ignore"; + }); + + return { identifierIdx, qtyIdx, roles }; +} + +// --- Convert mapped rows → RawIdentifierRow[] -------------------------------- + +/** + * Apply a column mapping to the parsed table to produce raw identifier + * rows. Rows with an empty identifier are dropped. + * + * When `qtyIdx` is -1 (no qty column assigned), each row gets a default + * qty of 1 — this makes single-column pastes like "paste a list of UPCs, + * one per line" work without forcing users to add a qty column. When + * `qtyIdx` is set but a row has an invalid/non-positive qty value, that + * row is dropped. + */ +export function applyMapping( + table: ParsedTable, + mapping: DetectedMapping +): RawIdentifierRow[] { + const out: RawIdentifierRow[] = []; + const hasQtyColumn = mapping.qtyIdx >= 0; + for (const row of table.rows) { + const identifier = (row[mapping.identifierIdx] || "").trim(); + if (!identifier) continue; + + let qty = 1; + if (hasQtyColumn) { + const qtyStr = (row[mapping.qtyIdx] || "").trim(); + // Strip non-numeric chars from qty (commas, spaces, decimals) + const cleaned = qtyStr.replace(/[^0-9.-]/g, ""); + const parsed = Math.round(Number(cleaned)); + if (!Number.isFinite(parsed) || parsed <= 0) continue; + qty = parsed; + } + + out.push({ identifier, qty }); + } + return out; +} diff --git a/inventory/src/components/create-po/resolveIdentifiers.ts b/inventory/src/components/create-po/resolveIdentifiers.ts new file mode 100644 index 0000000..f8be4db --- /dev/null +++ b/inventory/src/components/create-po/resolveIdentifiers.ts @@ -0,0 +1,137 @@ +/** + * Identifier-resolution pipeline for the Create PO page. + * + * Takes a list of `RawIdentifierRow` (from paste/upload parsing) and looks + * them up in ONE backend call via `POST /api/products/resolve-identifiers`. + * The endpoint does the SQL work: a single query with indexed equality + * matches across sku, barcode, vendor_reference, notions_reference, and pid, + * returning candidates grouped per input identifier in input order. + * + * Each row resolves to one of: + * - matched → exactly one candidate, ready to add + * - ambiguous → multiple candidates; user must pick one in ReviewMatchesDialog + * - unmatched → zero candidates; surfaced so the user knows it was dropped + * + * Also exports `fetchBatchProducts`, which hydrates a list of pids into + * full PoLineItem rows via `GET /api/products/batch`. + */ + +import axios from "axios"; +import type { + RawIdentifierRow, + ResolveResult, + SearchCandidate, + PoLineItem, +} from "./types"; +import { BATCH_LOOKUP_MAX_PIDS } from "./constants"; + +interface ResolveApiCandidate { + pid: number | string; + title: string; + sku: string | null; + barcode: string | null; + vendor_reference: string | null; + notions_reference: string | null; + brand: string | null; +} + +interface ResolveApiResponse { + results: Array<{ + identifier: string; + candidates: ResolveApiCandidate[]; + }>; +} + +/** + * Coerces a candidate from the API into the frontend's SearchCandidate + * shape. pid may come back as a string if the backend ever regresses to + * un-normalized BIGINT — Number() is defensive. + */ +function toSearchCandidate(c: ResolveApiCandidate): SearchCandidate { + return { + pid: Number(c.pid), + title: c.title, + sku: c.sku, + barcode: c.barcode, + brand: c.brand, + }; +} + +export async function resolveIdentifiers( + rows: RawIdentifierRow[] +): Promise { + if (rows.length === 0) { + return { matched: [], ambiguous: [], unmatched: [] }; + } + + const identifiers = rows.map((r) => r.identifier); + + const res = await axios.post( + "/api/products/resolve-identifiers", + { identifiers } + ); + + const apiResults = res.data?.results ?? []; + const result: ResolveResult = { matched: [], ambiguous: [], unmatched: [] }; + + // The backend preserves input order and length, so we can zip by index. + // If the response is shorter than the input (backend truncation or error), + // missing rows are treated as unmatched so the user still sees them. + rows.forEach((row, i) => { + const apiRow = apiResults[i]; + const candidates = apiRow?.candidates ?? []; + + if (candidates.length === 0) { + result.unmatched.push({ identifier: row.identifier, qty: row.qty }); + } else if (candidates.length === 1) { + result.matched.push({ + pid: Number(candidates[0].pid), + qty: row.qty, + identifier: row.identifier, + }); + } else { + result.ambiguous.push({ + identifier: row.identifier, + qty: row.qty, + candidates: candidates.map(toSearchCandidate), + }); + } + }); + + return result; +} + +/** + * Fetch full product display data for a list of pids. Chunks the request + * to BATCH_LOOKUP_MAX_PIDS to stay under URL length limits even if the + * caller passes hundreds of pids. + * + * Returns a flat array of PoLineItem with `qty` set to the value passed in + * the `qtyByPid` map (default 1). + */ +export async function fetchBatchProducts( + pids: number[], + qtyByPid: Map = new Map() +): Promise { + if (pids.length === 0) return []; + + const uniqPids = Array.from(new Set(pids)); + const out: PoLineItem[] = []; + + for (let i = 0; i < uniqPids.length; i += BATCH_LOOKUP_MAX_PIDS) { + const chunk = uniqPids.slice(i, i + BATCH_LOOKUP_MAX_PIDS); + const res = await axios.get[]>( + "/api/products/batch", + { params: { pids: chunk.join(",") } } + ); + for (const row of res.data ?? []) { + // Defensive Number() coercion: the backend already returns pid as a + // Number, but if it ever regresses to a BIGINT string the numeric + // Set/Map lookups downstream would silently fail instead of crashing. + const pid = Number(row.pid); + out.push({ ...row, pid, qty: qtyByPid.get(pid) ?? 1 }); + } + } + + return out; +} diff --git a/inventory/src/components/create-po/types.ts b/inventory/src/components/create-po/types.ts new file mode 100644 index 0000000..b053a64 --- /dev/null +++ b/inventory/src/components/create-po/types.ts @@ -0,0 +1,76 @@ +/** + * Display shape for a single line item on the Create PO page. This is the + * exact response shape returned by GET /api/products/batch (snake_case). + * + * `qty` and `moqOverride` are local-only client state appended to the API + * shape; they are NOT returned by the backend. + */ +export interface PoLineItem { + pid: number; + title: string; + image_url: string | null; + barcode: string | null; + vendor_reference: string | null; + notions_reference: string | null; + notions_inv_count: number | null; + current_stock: number | null; + baskets: number | null; + on_order_qty: number | null; + total_sold: number | null; + current_cost_price: number | null; + date_last_sold: string | null; + date_first_received: string | null; + /** From the products table; may be null/0/inconsistent. The user can override locally. */ + moq: number | null; + + // --- Local-only client state --- + /** User-entered order quantity. */ + qty: number; + /** + * Local override of MOQ for this row. Undefined means "use the canonical + * `moq` value above". This is never sent to the backend; it only affects + * the qty-validation highlight on the row. + */ + moqOverride?: number; +} + +/** Wire shape sent to the legacy PHP endpoint. */ +export interface PoSubmitItem { + pid: number; + qty: number; +} + +/** + * Intermediate shape produced by paste/upload parsing — before identifier + * resolution against the products table. + */ +export interface RawIdentifierRow { + identifier: string; + qty: number; +} + +/** A single search result candidate from /api/products/search. */ +export interface SearchCandidate { + pid: number; + title: string; + sku: string | null; + barcode: string | null; + brand: string | null; +} + +/** + * The result of attempting to resolve a single raw row to a product: + * - matched → exactly one product found + * - ambiguous → multiple candidates; user must pick one + * - unmatched → zero candidates; user can manually fix or drop + */ +export type ResolveOutcome = + | { kind: "matched"; pid: number; qty: number; identifier: string } + | { kind: "ambiguous"; identifier: string; qty: number; candidates: SearchCandidate[] } + | { kind: "unmatched"; identifier: string; qty: number }; + +export interface ResolveResult { + matched: Array<{ pid: number; qty: number; identifier: string }>; + ambiguous: Array<{ identifier: string; qty: number; candidates: SearchCandidate[] }>; + unmatched: Array<{ identifier: string; qty: number }>; +} diff --git a/inventory/src/components/forecasting/QuickOrderBuilder.tsx b/inventory/src/components/forecasting/QuickOrderBuilder.tsx deleted file mode 100644 index 7bdbb2d..0000000 --- a/inventory/src/components/forecasting/QuickOrderBuilder.tsx +++ /dev/null @@ -1,956 +0,0 @@ -import { useEffect, useMemo, useRef, useState, useTransition, useCallback, memo } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Code } from "@/components/ui/code"; -import * as XLSX from "xlsx"; -import { toast } from "sonner"; -import { X as XIcon } from "lucide-react"; -import { useQuery } from "@tanstack/react-query"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Checkbox } from "@/components/ui/checkbox"; - -export interface CategorySummary { - category: string; - categoryPath: string; - avgTotalSold: number; - minSold: number; - maxSold: number; -} - -type ParsedRow = { - product: string; - sku?: string; - categoryHint?: string; - moq?: number; - upc?: string; -}; - -type OrderRow = ParsedRow & { - matchedCategoryPath?: string; - matchedCategoryName?: string; - baseSuggestion?: number; // from category avg - finalQty: number; // adjusted for MOQ -}; - -type HeaderMap = { - // Stores generated column ids like "col-0" instead of raw header text - product?: string; - sku?: string; - categoryHint?: string; - moq?: string; - upc?: string; -}; - -const PRODUCT_HEADER_SYNONYMS = [ - "product", - "name", - "title", - "description", - "item", - "item name", - "sku description", - "product name", -]; - -const SKU_HEADER_SYNONYMS = [ - "sku", - "item#", - "item number", - "supplier #", - "supplier no", - "supplier_no", - "product code", -]; - -const CATEGORY_HEADER_SYNONYMS = [ - "category", - "categories", - "line", - "collection", - "type", -]; - -const MOQ_HEADER_SYNONYMS = [ - "moq", - "min qty", - "min. order qty", - "min order qty", - "qty per unit", - "unit qty", - "inner pack", - "case pack", - "pack", -]; - -const UPC_HEADER_SYNONYMS = [ - "upc", - "barcode", - "bar code", - "ean", - "jan", - "upc code", -]; - -function normalizeHeader(h: string) { - return h.trim().toLowerCase(); -} - -function autoMapHeaderNames(headers: string[]): { product?: string; sku?: string; categoryHint?: string; moq?: string; upc?: string } { - const norm = headers.map((h) => normalizeHeader(h)); - const findFirst = (syns: string[]) => { - for (const s of syns) { - const idx = norm.findIndex((h) => h === s || h.includes(s)); - if (idx >= 0) return headers[idx]; - } - return undefined; - }; - return { - product: findFirst(PRODUCT_HEADER_SYNONYMS) || headers[0], - sku: findFirst(SKU_HEADER_SYNONYMS), - categoryHint: findFirst(CATEGORY_HEADER_SYNONYMS), - moq: findFirst(MOQ_HEADER_SYNONYMS), - upc: findFirst(UPC_HEADER_SYNONYMS), - }; -} - -function detectDelimiter(text: string): string { - // Very simple heuristic: prefer tab, then comma, then semicolon - const lines = text.split(/\r?\n/).slice(0, 5); - const counts = { "\t": 0, ",": 0, ";": 0 } as Record; - for (const line of lines) { - counts["\t"] += (line.match(/\t/g) || []).length; - counts[","] += (line.match(/,/g) || []).length; - counts[";"] += (line.match(/;/g) || []).length; - } - return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; -} - -function parsePasted(text: string): { headers: string[]; rows: string[][] } { - const delimiter = detectDelimiter(text); - const lines = text - .split(/\r?\n/) - .map((l) => l.trim()) - .filter(Boolean); - if (lines.length === 0) return { headers: [], rows: [] }; - const headers = lines[0].split(delimiter).map((s) => s.trim()); - const rows = lines.slice(1).map((l) => { - const parts = l.split(delimiter).map((s) => s.trim()); - // Preserve empty trailing columns by padding to headers length - while (parts.length < headers.length) parts.push(""); - return parts; - }); - return { headers, rows }; -} - -function toIntOrUndefined(v: any): number | undefined { - if (v === null || v === undefined) return undefined; - const n = Number(String(v).replace(/[^0-9.-]/g, "")); - return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined; -} - -function scoreCategoryMatch(catText: string, name: string, hint?: string): number { - const base = catText.toLowerCase(); - const tokens = (name || "") - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter((t) => t.length >= 3); - let score = 0; - for (const t of tokens) { - if (base.includes(t)) score += 2; - } - if (hint) { - const h = hint.toLowerCase(); - if (base.includes(h)) score += 5; - } - return score; -} - -function suggestFromCategory(avgTotalSold?: number, scalePct: number = 100): number { - const scaled = (avgTotalSold || 0) * (isFinite(scalePct) ? scalePct : 100) / 100; - const base = Math.max(1, Math.round(scaled)); - return base; -} - -function applyMOQ(qty: number, moq?: number): number { - if (!moq || moq <= 1) return Math.max(0, qty); - if (qty <= 0) return 0; - const mult = Math.ceil(qty / moq); - return mult * moq; -} - -export function QuickOrderBuilder({ - categories, - brand, -}: { - categories: CategorySummary[]; - brand?: string; -}) { - const fileInputRef = useRef(null); - - const [pasted, setPasted] = useState(""); - const [headers, setHeaders] = useState([]); - const [rawRows, setRawRows] = useState([]); - const [headerMap, setHeaderMap] = useState({}); - const [orderRows, setOrderRows] = useState([]); - const [showJson, setShowJson] = useState(false); - const [selectedSupplierId, setSelectedSupplierId] = useState(undefined); - const [scalePct, setScalePct] = useState(100); - const [scaleInput, setScaleInput] = useState("100"); - const [showExcludedOnly, setShowExcludedOnly] = useState(false); - const [parsed, setParsed] = useState(false); - const [showMapping, setShowMapping] = useState(false); - const [, startTransition] = useTransition(); - const [initialCategories, setInitialCategories] = useState(null); - - // Local storage draft persistence - const DRAFT_KEY = "quickOrderBuilderDraft"; - const restoringRef = useRef(false); - - // Load suppliers from existing endpoint used elsewhere in the app - const { data: fieldOptions } = useQuery({ - queryKey: ["field-options"], - queryFn: async () => { - const res = await fetch("/api/import/field-options"); - if (!res.ok) throw new Error("Failed to load field options"); - return res.json(); - }, - }); - const supplierOptions: { label: string; value: string }[] = fieldOptions?.suppliers || []; - - // Default supplier to the brand name if an exact label match exists - useEffect(() => { - if (!supplierOptions?.length) return; - if (selectedSupplierId) return; - if (brand) { - const match = supplierOptions.find((s) => s.label?.toLowerCase?.() === brand.toLowerCase()); - if (match) setSelectedSupplierId(String(match.value)); - } - }, [supplierOptions, brand, selectedSupplierId]); - - // Restore draft on mount - useEffect(() => { - try { - const raw = localStorage.getItem(DRAFT_KEY); - if (!raw) return; - const draft = JSON.parse(raw); - restoringRef.current = true; - setPasted(draft.pasted ?? ""); - setHeaders(Array.isArray(draft.headers) ? draft.headers : []); - setRawRows(Array.isArray(draft.rawRows) ? draft.rawRows : []); - setHeaderMap(draft.headerMap ?? {}); - setOrderRows(Array.isArray(draft.orderRows) ? draft.orderRows : []); - setSelectedSupplierId(draft.selectedSupplierId ?? undefined); - const restoredScale = typeof draft.scalePct === 'number' ? draft.scalePct : 100; - setScalePct(restoredScale); - setScaleInput(String(restoredScale)); - setParsed(Array.isArray(draft.headers) && draft.headers.length > 0); - setShowMapping(!(Array.isArray(draft.orderRows) && draft.orderRows.length > 0)); - if (Array.isArray(draft.categoriesSnapshot)) { - setInitialCategories(draft.categoriesSnapshot); - } - // brand is passed via props; we don't override it here - } catch (e) { - console.warn("Failed to restore draft", e); - } finally { - // Defer toggling off to next tick to allow state batching - setTimeout(() => { restoringRef.current = false; }, 0); - } - }, []); - - // Save draft on changes - useEffect(() => { - if (restoringRef.current) return; - const draft = { - pasted, - headers, - rawRows, - headerMap, - orderRows, - selectedSupplierId, - scalePct, - brand, - categoriesSnapshot: categories, - }; - try { - localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); - } catch (e) { - // ignore storage quota errors silently - } - }, [pasted, headers, rawRows, headerMap, orderRows, selectedSupplierId, scalePct, brand]); - - // Debounce scale input -> numeric scalePct - useEffect(() => { - const handle = setTimeout(() => { - const v = Math.max(1, Math.min(500, Math.round(Number(scaleInput) || 0))); - setScalePct(v); - }, 500); - return () => clearTimeout(handle); - }, [scaleInput]); - - const effectiveCategories = (categories && categories.length > 0) ? categories : (initialCategories || []); - - const categoryOptions = useMemo(() => { - const arr = (effectiveCategories || []) - .map((c) => ({ - value: c.categoryPath || c.category, - label: c.categoryPath ? `${c.category} — ${c.categoryPath}` : c.category, - })) - .filter((o) => !!o.value && String(o.value).trim() !== ""); - // dedupe by value to avoid duplicate Select values - const dedup = new Map(); - for (const o of arr) { - if (!dedup.has(o.value)) dedup.set(o.value, o.label); - } - return Array.from(dedup.entries()).map(([value, label]) => ({ value, label })); - }, [effectiveCategories]); - - const categoryByKey = useMemo(() => { - const map = new Map(); - for (const c of effectiveCategories || []) { - map.set(c.categoryPath || c.category, c); - } - return map; - }, [effectiveCategories]); - - // Build header option list with generated ids so values are never empty and keys are unique - const headerOptions = useMemo( - () => headers.map((h, i) => ({ id: `col-${i}`, index: i, label: h && h.trim() ? h : `Column ${i + 1}` })), - [headers] - ); - const idToIndex = useMemo(() => new Map(headerOptions.map((o) => [o.id, o.index])), [headerOptions]); - - function headerNameToId(name?: string): string | undefined { - if (!name) return undefined; - const idx = headers.findIndex((h) => h === name); - return idx >= 0 ? `col-${idx}` : undefined; - } - - function handleFileChange(e: React.ChangeEvent) { - const f = e.target.files?.[0]; - if (!f) return; - const reader = new FileReader(); - const ext = f.name.split(".").pop()?.toLowerCase(); - - reader.onload = () => { - try { - let wb: XLSX.WorkBook | null = null; - if (ext === "xlsx" || ext === "xls") { - const data = new Uint8Array(reader.result as ArrayBuffer); - wb = XLSX.read(data, { type: "array" }); - } else if (ext === "csv" || ext === "tsv") { - const text = reader.result as string; - wb = XLSX.read(text, { type: "string" }); - } else { - // Try naive string read - const text = reader.result as string; - wb = XLSX.read(text, { type: "string" }); - } - if (!wb) throw new Error("Unable to parse file"); - const sheet = wb.Sheets[wb.SheetNames[0]]; - const rows: any[][] = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" }); - if (!rows.length) throw new Error("Empty file"); - const hdrs = (rows[0] as string[]).map((h) => String(h || "").trim()); - const body = rows.slice(1).map((r) => (r as any[]).map((v) => String(v ?? "").trim())); - // Build mapping based on detected names -> ids - const mappedNames = autoMapHeaderNames(hdrs); - const mappedIds: HeaderMap = { - product: headerNameToId(mappedNames.product) ?? (hdrs.length > 0 ? `col-0` : undefined), - sku: headerNameToId(mappedNames.sku), - categoryHint: headerNameToId(mappedNames.categoryHint), - moq: headerNameToId(mappedNames.moq), - upc: headerNameToId(mappedNames.upc), - }; - setHeaders(hdrs); - setRawRows(body); - setHeaderMap(mappedIds); - setPasted(""); - setParsed(true); - setShowMapping(true); - toast.success("File parsed"); - } catch (err) { - console.error(err); - toast.error("Could not parse file"); - } - }; - - if (ext === "xlsx" || ext === "xls") { - reader.readAsArrayBuffer(f); - } else { - reader.readAsText(f); - } - } - - function handlePasteParse() { - try { - const { headers: hdrs, rows } = parsePasted(pasted); - if (!hdrs.length || !rows.length) { - toast.error("No data detected"); - return; - } - const mappedNames = autoMapHeaderNames(hdrs); - const mappedIds: HeaderMap = { - product: headerNameToId(mappedNames.product) ?? (hdrs.length > 0 ? `col-0` : undefined), - sku: headerNameToId(mappedNames.sku), - categoryHint: headerNameToId(mappedNames.categoryHint), - moq: headerNameToId(mappedNames.moq), - upc: headerNameToId(mappedNames.upc), - }; - setHeaders(hdrs); - setRawRows(rows); - setHeaderMap(mappedIds); - setParsed(true); - setShowMapping(true); - toast.success("Pasted data parsed"); - } catch (e) { - console.error(e); - toast.error("Paste parse failed"); - } - } - - function buildParsedRows(): ParsedRow[] { - if (!headers.length || !rawRows.length) return []; - const idx = (id?: string) => (id ? idToIndex.get(id) ?? -1 : -1); - const iProduct = idx(headerMap.product); - const iSku = idx(headerMap.sku); - const iCat = idx(headerMap.categoryHint); - const iMoq = idx(headerMap.moq); - const iUpc = idx(headerMap.upc); - const out: ParsedRow[] = []; - for (const r of rawRows) { - const product = String(iProduct >= 0 ? r[iProduct] ?? "" : "").trim(); - const upc = iUpc >= 0 ? String(r[iUpc] ?? "") : undefined; - if (!product && !(upc && upc.trim())) continue; - const sku = iSku >= 0 ? String(r[iSku] ?? "") : undefined; - const categoryHint = iCat >= 0 ? String(r[iCat] ?? "") : undefined; - const moq = iMoq >= 0 ? toIntOrUndefined(r[iMoq]) : undefined; - out.push({ product, sku, categoryHint, moq, upc }); - } - return out; - } - - function matchCategory(row: ParsedRow): { key?: string; name?: string } { - if (!categories?.length) return {}; - let bestKey: string | undefined; - let bestName: string | undefined; - let bestScore = -1; - for (const c of categories) { - const key = c.categoryPath || c.category; - const text = `${c.category} ${c.categoryPath || ""}`; - const s = scoreCategoryMatch(text, row.product, row.categoryHint); - if (s > bestScore) { - bestScore = s; - bestKey = key; - bestName = c.category; - } - } - return bestScore > 0 ? { key: bestKey, name: bestName } : {}; - } - - function buildOrderRows() { - const parsed = buildParsedRows(); - if (!parsed.length) { - toast.error("Nothing to process"); - return; - } - const next: OrderRow[] = parsed.map((r) => { - const m = matchCategory(r); - const cat = m.key ? categoryByKey.get(m.key) : undefined; - const base = suggestFromCategory(cat?.avgTotalSold, scalePct); - const finalQty = applyMOQ(base, r.moq); - return { - ...r, - matchedCategoryPath: m.key, - matchedCategoryName: m.name, - baseSuggestion: base, - finalQty, - }; - }); - setOrderRows(next); - setShowMapping(false); - } - - // Re-apply scaling dynamically to suggested rows - useEffect(() => { - if (!orderRows.length) return; - startTransition(() => { - setOrderRows((rows) => - rows.map((row) => { - const cat = row.matchedCategoryPath ? categoryByKey.get(row.matchedCategoryPath) : undefined; - if (!cat) return row; // nothing to scale when no category - const prevAuto = applyMOQ(row.baseSuggestion || 0, row.moq); - const nextBase = suggestFromCategory(cat.avgTotalSold, scalePct); - const nextAuto = applyMOQ(nextBase, row.moq); - const isAuto = row.finalQty === prevAuto; - return { - ...row, - baseSuggestion: nextBase, - finalQty: isAuto ? nextAuto : row.finalQty, - }; - }) - ); - }); - }, [scalePct, categoryByKey]); - - // After categories load (e.g. after refresh), recompute base suggestions - useEffect(() => { - if (!orderRows.length) return; - startTransition(() => { - setOrderRows((rows) => - rows.map((row) => { - const cat = row.matchedCategoryPath ? categoryByKey.get(row.matchedCategoryPath) : undefined; - if (!cat) return row; - const nextBase = suggestFromCategory(cat.avgTotalSold, scalePct); - const nextAuto = applyMOQ(nextBase, row.moq); - const prevAuto = applyMOQ(row.baseSuggestion || 0, row.moq); - const isAuto = row.finalQty === prevAuto || !row.baseSuggestion; // treat empty base as auto - return { - ...row, - baseSuggestion: nextBase, - finalQty: isAuto ? nextAuto : row.finalQty, - }; - }) - ); - }); - }, [categoryByKey]); - - const changeCategory = useCallback((idx: number, newKey?: string) => { - setOrderRows((rows) => { - const copy = [...rows]; - const row = { ...copy[idx] }; - row.matchedCategoryPath = newKey; - if (newKey) { - const cat = categoryByKey.get(newKey); - row.matchedCategoryName = cat?.category; - row.baseSuggestion = suggestFromCategory(cat?.avgTotalSold, scalePct); - row.finalQty = applyMOQ(row.baseSuggestion || 0, row.moq); - } else { - row.matchedCategoryName = undefined; - row.baseSuggestion = undefined; - row.finalQty = row.moq ? row.moq : 0; - } - copy[idx] = row; - return copy; - }); - }, [categoryByKey, scalePct]); - - const changeQty = useCallback((idx: number, value: string) => { - const n = Number(value); - startTransition(() => setOrderRows((rows) => { - const copy = [...rows]; - const row = { ...copy[idx] }; - const raw = Number.isFinite(n) ? Math.round(n) : 0; - row.finalQty = raw; // do not enforce MOQ on manual edits - copy[idx] = row; - return copy; - })); - }, []); - - const removeRow = useCallback((idx: number) => { - setOrderRows((rows) => rows.filter((_, i) => i !== idx)); - }, []); - - const visibleRows = useMemo(() => ( - showExcludedOnly - ? orderRows.filter((r) => !(r.finalQty > 0 && r.upc && r.upc.trim())) - : orderRows - ), [orderRows, showExcludedOnly]); - - const OrderRowsTable = useMemo(() => memo(function OrderRowsTableInner({ - rows, - }: { rows: OrderRow[] }) { - return ( -
- - - - Product - SKU - UPC - Category - Avg Sold - MOQ - Order Qty - Actions - - - - {rows.map((r, idx) => { - const cat = r.matchedCategoryPath ? categoryByKey.get(r.matchedCategoryPath) : undefined; - const isExcluded = !(r.finalQty > 0 && r.upc && r.upc.trim()); - return ( - - -
{r.product}
-
- {r.sku || ""} - {r.upc || ""} - - - - {cat?.avgTotalSold?.toFixed?.(2) ?? "-"} - {r.moq ?? "-"} - - changeQty(idx, e.target.value)} - inputMode="numeric" - /> - - - - -
- ); - })} -
-
-
- ); - }), [categoryByKey, categoryOptions, changeCategory, changeQty, removeRow]); - - const exportJson = useMemo(() => { - const items = orderRows - .filter((r) => (r.finalQty || 0) > 0 && !!(r.upc && r.upc.trim())) - .map((r) => ({ upc: r.upc!, quantity: r.finalQty })); - return { - supplierId: selectedSupplierId ?? null, - generatedAt: new Date().toISOString(), - itemCount: items.length, - items, - }; - }, [orderRows, selectedSupplierId]); - - const canProcess = headers.length > 0 && rawRows.length > 0; - - return ( - - - Quick Order Builder - - - {/* Supplier + Clear */} -
-
-
Supplier
- -
- -
- - {!parsed && ( - <> -
- - or paste below -
- -