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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -20,6 +20,7 @@ import { addDays, addMonths } from "date-fns";
|
|||||||
import { DateRangePickerQuick } from "@/components/forecasting/DateRangePickerQuick";
|
import { DateRangePickerQuick } from "@/components/forecasting/DateRangePickerQuick";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
import { QuickOrderBuilder } from "@/components/forecasting/QuickOrderBuilder";
|
||||||
|
|
||||||
|
|
||||||
export default function Forecasting() {
|
export default function Forecasting() {
|
||||||
@@ -30,6 +31,39 @@ export default function Forecasting() {
|
|||||||
});
|
});
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [search, setSearch] = useState<string>("");
|
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) => {
|
const handleDateRangeChange = (range: DateRange | undefined) => {
|
||||||
@@ -149,7 +183,7 @@ export default function Forecasting() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-10">
|
<div className="container mx-auto py-10 space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Historical Sales</CardTitle>
|
<CardTitle>Historical Sales</CardTitle>
|
||||||
@@ -259,6 +293,17 @@ export default function Forecasting() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user