Add in initial PO creation feature
This commit is contained in:
956
inventory/src/components/forecasting/QuickOrderBuilder.tsx
Normal file
956
inventory/src/components/forecasting/QuickOrderBuilder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user