Add in initial PO creation feature

This commit is contained in:
2025-09-03 12:15:20 -04:00
parent d35c7dd6cf
commit 9e7aac836e
2 changed files with 1004 additions and 3 deletions

View File

@@ -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<string, number>;
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<HTMLInputElement | null>(null);
const [pasted, setPasted] = useState("");
const [headers, setHeaders] = useState<string[]>([]);
const [rawRows, setRawRows] = useState<string[][]>([]);
const [headerMap, setHeaderMap] = useState<HeaderMap>({});
const [orderRows, setOrderRows] = useState<OrderRow[]>([]);
const [showJson, setShowJson] = useState(false);
const [selectedSupplierId, setSelectedSupplierId] = useState<string | undefined>(undefined);
const [scalePct, setScalePct] = useState<number>(100);
const [scaleInput, setScaleInput] = useState<string>("100");
const [showExcludedOnly, setShowExcludedOnly] = useState<boolean>(false);
const [parsed, setParsed] = useState<boolean>(false);
const [showMapping, setShowMapping] = useState<boolean>(false);
const [isPending, startTransition] = useTransition();
const [initialCategories, setInitialCategories] = useState<CategorySummary[] | null>(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<string, string>();
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<string, CategorySummary>();
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<HTMLInputElement>) {
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 (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>SKU</TableHead>
<TableHead>UPC</TableHead>
<TableHead>Category</TableHead>
<TableHead className="text-right">Avg Sold</TableHead>
<TableHead className="text-right">MOQ</TableHead>
<TableHead className="text-right">Order Qty</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r, idx) => {
const cat = r.matchedCategoryPath ? categoryByKey.get(r.matchedCategoryPath) : undefined;
const isExcluded = !(r.finalQty > 0 && r.upc && r.upc.trim());
return (
<TableRow key={`${r.product || r.upc || 'row'}-${idx}`} className={isExcluded ? 'bg-destructive/10' : undefined}>
<TableCell>
<div className="font-medium">{r.product}</div>
</TableCell>
<TableCell className="whitespace-nowrap">{r.sku || ""}</TableCell>
<TableCell className="whitespace-nowrap">{r.upc || ""}</TableCell>
<TableCell className="min-w-[280px]">
<Select
value={r.matchedCategoryPath ?? "__none"}
onValueChange={(v) => changeCategory(idx, v === "__none" ? undefined : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent className="max-h-[320px]">
<SelectItem value="__none">Unmatched</SelectItem>
{categoryOptions.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right">{cat?.avgTotalSold?.toFixed?.(2) ?? "-"}</TableCell>
<TableCell className="text-right">{r.moq ?? "-"}</TableCell>
<TableCell className="text-right">
<Input
className="w-24 text-right"
value={Number.isFinite(r.finalQty) ? r.finalQty : 0}
onChange={(e) => changeQty(idx, e.target.value)}
inputMode="numeric"
/>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => removeRow(idx)} aria-label="Remove row">
<XIcon className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}), [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 (
<Card>
<CardHeader>
<CardTitle>Quick Order Builder</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Supplier + Clear */}
<div className="flex flex-wrap items-end gap-3">
<div className="max-w-sm">
<div className="text-sm font-medium mb-1">Supplier</div>
<Select
value={selectedSupplierId ?? "__none"}
onValueChange={(v) => setSelectedSupplierId(v === "__none" ? undefined : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select supplier" />
</SelectTrigger>
<SelectContent className="max-h-[320px]">
<SelectItem value="__none">Select supplier</SelectItem>
{supplierOptions.map((s) => (
<SelectItem key={String(s.value)} value={String(s.value)}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="destructive"
onClick={() => {
setPasted("");
setHeaders([]);
setRawRows([]);
setHeaderMap({});
setOrderRows([]);
setShowJson(false);
setSelectedSupplierId(undefined);
setScalePct(100);
setScaleInput("100");
setParsed(false);
setShowMapping(false);
try { localStorage.removeItem(DRAFT_KEY); } catch {}
toast.message("Draft cleared");
}}
>
Clear Draft
</Button>
</div>
{!parsed && (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv,.tsv,.txt"
onChange={handleFileChange}
className="max-w-sm"
/>
<span className="text-muted-foreground text-sm">or paste below</span>
</div>
<Textarea
placeholder="Paste rows (with a header): Product, SKU, Category, MOQ..."
value={pasted}
onChange={(e) => setPasted(e.target.value)}
className="min-h-[120px]"
/>
<div className="flex gap-2">
<Button variant="outline" onClick={handlePasteParse} disabled={!pasted.trim()}>
Parse Pasted Data
</Button>
</div>
</>
)}
{headers.length > 0 && showMapping && (
<div className="space-y-3">
<div className="text-sm font-medium">Map Columns</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
<div>
<div className="text-xs text-muted-foreground mb-1">Product (recommended)</div>
<Select
value={headerMap.product}
onValueChange={(v) => setHeaderMap((m) => ({ ...m, product: v }))}
>
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
<SelectContent>
{headerOptions.map((o) => {
const ci = idToIndex.get(o.id)!;
const samples = rawRows
.map((r) => String((r && r[ci]) ?? "").trim())
.filter((v) => v.length > 0)
.slice(0, 3);
return (
<SelectItem key={o.id} value={o.id}>
<div className="flex flex-col text-left">
<span>{o.label}</span>
{samples.length > 0 && (
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">UPC / Barcode (recommended)</div>
<Select
value={headerMap.upc ?? "__none"}
onValueChange={(v) => setHeaderMap((m) => ({ ...m, upc: v === "__none" ? undefined : v }))}
>
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">None</SelectItem>
{headerOptions.map((o) => {
const ci = idToIndex.get(o.id)!;
const samples = rawRows
.map((r) => String((r && r[ci]) ?? "").trim())
.filter((v) => v.length > 0)
.slice(0, 3);
return (
<SelectItem key={o.id} value={o.id}>
<div className="flex flex-col text-left">
<span>{o.label}</span>
{samples.length > 0 && (
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">SKU (optional)</div>
<Select
value={headerMap.sku ?? "__none"}
onValueChange={(v) => setHeaderMap((m) => ({ ...m, sku: v === "__none" ? undefined : v }))}
>
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">None</SelectItem>
{headerOptions.map((o) => {
const ci = idToIndex.get(o.id)!;
const samples = rawRows
.map((r) => String((r && r[ci]) ?? "").trim())
.filter((v) => v.length > 0)
.slice(0, 3);
return (
<SelectItem key={o.id} value={o.id}>
<div className="flex flex-col text-left">
<span>{o.label}</span>
{samples.length > 0 && (
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">Category Hint (optional)</div>
<Select
value={headerMap.categoryHint ?? "__none"}
onValueChange={(v) => setHeaderMap((m) => ({ ...m, categoryHint: v === "__none" ? undefined : v }))}
>
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">None</SelectItem>
{headerOptions.map((o) => {
const ci = idToIndex.get(o.id)!;
const samples = rawRows
.map((r) => String((r && r[ci]) ?? "").trim())
.filter((v) => v.length > 0)
.slice(0, 3);
return (
<SelectItem key={o.id} value={o.id}>
<div className="flex flex-col text-left">
<span>{o.label}</span>
{samples.length > 0 && (
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">MOQ (optional)</div>
<Select
value={headerMap.moq ?? "__none"}
onValueChange={(v) => setHeaderMap((m) => ({ ...m, moq: v === "__none" ? undefined : v }))}
>
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">None</SelectItem>
{headerOptions.map((o) => {
const ci = idToIndex.get(o.id)!;
const samples = rawRows
.map((r) => String((r && r[ci]) ?? "").trim())
.filter((v) => v.length > 0)
.slice(0, 3);
return (
<SelectItem key={o.id} value={o.id}>
<div className="flex flex-col text-left">
<span>{o.label}</span>
{samples.length > 0 && (
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap items-end gap-3">
<Button onClick={buildOrderRows} disabled={!canProcess || (!headerMap.product && !headerMap.upc)}>
Build Suggestions
</Button>
</div>
</div>
)}
{orderRows.length > 0 && (
<div className="space-y-3">
{/* Controls for existing suggestions */}
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex items-end gap-3">
<div>
<div className="text-xs text-muted-foreground mb-1">Scale suggestions (%)</div>
<Input
type="number"
className="w-28"
value={scaleInput}
onChange={(e) => setScaleInput(e.target.value)}
/>
</div>
<div className="flex items-center gap-2 pb-2">
<Checkbox id="excludedOnly" checked={showExcludedOnly} onCheckedChange={(v) => setShowExcludedOnly(!!v)} />
<label htmlFor="excludedOnly" className="text-sm">Show excluded only</label>
</div>
</div>
<div className="pb-2">
<Button variant="outline" size="sm" onClick={() => setShowMapping((v) => !v)}>
{showMapping ? 'Hide Mapping' : 'Edit Mapping'}
</Button>
</div>
</div>
<OrderRowsTable rows={visibleRows} />
{/* Exclusion alert if some rows won't be exported */}
{(() => {
const excluded = orderRows.filter((r) => !(r.finalQty > 0 && r.upc && r.upc.trim()));
if (excluded.length === 0) return null;
const missingUpc = excluded.filter((r) => !r.upc || !r.upc.trim()).length;
const zeroQty = excluded.filter((r) => !(r.finalQty > 0)).length;
return (
<Alert variant="destructive">
<AlertTitle>Some rows will not be included</AlertTitle>
<AlertDescription>
<div className="text-sm">
{excluded.length} row{excluded.length !== 1 ? "s" : ""} excluded from JSON
<ul className="list-disc ml-5">
{missingUpc > 0 && <li>{missingUpc} missing UPC</li>}
{zeroQty > 0 && <li>{zeroQty} with zero quantity</li>}
</ul>
</div>
</AlertDescription>
</Alert>
);
})()}
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowJson((s) => !s)}>
{showJson ? "Hide" : "Preview"} JSON
</Button>
<Button
onClick={() => {
setShowJson(true);
navigator.clipboard?.writeText(JSON.stringify(exportJson, null, 2)).then(
() => toast.success("JSON copied"),
() => toast.message("JSON ready (copy failed)")
).finally(() => {
try { localStorage.removeItem(DRAFT_KEY); } catch {}
});
}}
>
Copy JSON
</Button>
</div>
{showJson && (
<Code className="p-4 w-full rounded-md border whitespace-pre-wrap">
{JSON.stringify(exportJson, null, 2)}
</Code>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,7 +1,7 @@
import { useState, useMemo, Fragment } from "react";
import { useEffect, useState, useMemo, Fragment } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
flexRender,
@@ -20,6 +20,7 @@ import { addDays, addMonths } from "date-fns";
import { DateRangePickerQuick } from "@/components/forecasting/DateRangePickerQuick";
import { Input } from "@/components/ui/input";
import { X } from "lucide-react";
import { QuickOrderBuilder } from "@/components/forecasting/QuickOrderBuilder";
export default function Forecasting() {
@@ -30,6 +31,39 @@ export default function Forecasting() {
});
const [sorting, setSorting] = useState<SortingState>([]);
const [search, setSearch] = useState<string>("");
const FILTERS_KEY = "forecastingFilters";
const queryClient = useQueryClient();
// Restore saved brand and date range on first mount
useEffect(() => {
try {
const raw = localStorage.getItem(FILTERS_KEY);
if (!raw) return;
const saved = JSON.parse(raw);
if (typeof saved.brand === 'string') setSelectedBrand(saved.brand);
if (saved.from && saved.to) {
const from = new Date(saved.from);
const to = new Date(saved.to);
if (!isNaN(from.getTime()) && !isNaN(to.getTime())) {
setDateRange({ from, to });
}
}
// Force a refetch once state settles
setTimeout(() => {
try { queryClient.invalidateQueries({ queryKey: ["forecast"] }); } catch {}
}, 0);
} catch {}
}, []);
// Persist brand and date range
useEffect(() => {
try {
localStorage.setItem(
FILTERS_KEY,
JSON.stringify({ brand: selectedBrand, from: dateRange.from?.toISOString(), to: dateRange.to?.toISOString() })
);
} catch {}
}, [selectedBrand, dateRange]);
const handleDateRangeChange = (range: DateRange | undefined) => {
@@ -149,7 +183,7 @@ export default function Forecasting() {
});
return (
<div className="container mx-auto py-10">
<div className="container mx-auto py-10 space-y-6">
<Card>
<CardHeader>
<CardTitle>Historical Sales</CardTitle>
@@ -259,6 +293,17 @@ export default function Forecasting() {
)}
</CardContent>
</Card>
{/* Quick Order Builder */}
<QuickOrderBuilder
brand={selectedBrand}
categories={(displayData || []).map((c: any) => ({
category: c.category,
categoryPath: c.categoryPath,
avgTotalSold: c.avgTotalSold,
minSold: c.minSold,
maxSold: c.maxSold,
}))}
/>
</div>
);
}