diff --git a/inventory/src/components/forecasting/QuickOrderBuilder.tsx b/inventory/src/components/forecasting/QuickOrderBuilder.tsx new file mode 100644 index 0000000..9397ed9 --- /dev/null +++ b/inventory/src/components/forecasting/QuickOrderBuilder.tsx @@ -0,0 +1,956 @@ +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 [isPending, 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 +
+ +