Add create PO page, remove old quick order builder from forecasting page, reorder sidebar, combine brands/vendors pages

This commit is contained in:
2026-04-16 14:49:11 -04:00
parent 338f829eb6
commit 9ab5d4300a
26 changed files with 3506 additions and 1890 deletions

0
inventory-server/chat/export-chat-data.sh Executable file → Normal file
View File

0
inventory-server/chat/verify-migration.js Executable file → Normal file
View File

View File

@@ -504,6 +504,152 @@ router.get('/search', async (req, res) => {
} }
}); });
// Batch lookup of product display data by pid list (used by Create PO page)
// Accepts ?pids=1,2,3 — comma-separated; de-duped server-side; capped at 500.
// Returns rows in the same order as the deduped input pids; missing pids are silently dropped.
router.get('/batch', async (req, res) => {
const pool = req.app.locals.pool;
const raw = req.query.pids;
if (!raw || typeof raw !== 'string') {
return res.status(400).json({ error: 'pids query parameter is required' });
}
const pids = Array.from(new Set(
raw.split(',')
.map(s => parseInt(s.trim(), 10))
.filter(n => Number.isInteger(n) && n > 0)
)).slice(0, 500);
if (pids.length === 0) {
return res.status(400).json({ error: 'No valid pids provided' });
}
try {
const { rows } = await pool.query(`
SELECT
p.pid,
p.title,
p.image_175 AS image_url,
p.barcode,
p.vendor_reference,
p.notions_reference,
p.notions_inv_count,
pm.current_stock,
p.baskets,
pm.on_order_qty,
p.total_sold,
pm.current_cost_price,
pm.date_last_sold,
pm.date_first_received,
p.moq
FROM products p
LEFT JOIN product_metrics pm ON pm.pid = p.pid
WHERE p.pid = ANY($1::bigint[])
`, [pids]);
// products.pid is BIGINT, which the pg driver returns as a STRING by
// default (to preserve precision for values > 2^53). Coerce to Number
// so the JSON response has numeric pids and Map lookups work.
const normalized = rows.map(r => ({ ...r, pid: Number(r.pid) }));
const byPid = new Map(normalized.map(r => [r.pid, r]));
// Preserve the requested order so the frontend can append rows in input order
const ordered = pids.map(pid => byPid.get(pid)).filter(Boolean);
res.json(ordered);
} catch (error) {
console.error('Error fetching batch products:', error);
res.status(500).json({ error: 'Failed to fetch products' });
}
});
// Bulk resolve a list of identifiers (UPC / SKU / supplier # / notions # / pid)
// to candidate products in ONE query. Used by the Create PO paste/upload flow.
// Body: { identifiers: string[] }
// Response: { results: Array<{ identifier: string, candidates: Candidate[] }> }
// Results are returned in the same order as the input identifiers, with
// duplicates preserved (so the caller can pair results back to input rows
// positionally).
router.post('/resolve-identifiers', async (req, res) => {
const pool = req.app.locals.pool;
const body = req.body || {};
const raw = Array.isArray(body.identifiers) ? body.identifiers : null;
if (!raw) {
return res.status(400).json({ error: 'identifiers array is required' });
}
// Clean and cap. Cleaned keeps ORIGINAL order (duplicates preserved) so the
// response aligns with the caller's input rows positionally.
const cleaned = raw
.map(s => (typeof s === 'string' ? s.trim() : ''))
.filter(s => s.length > 0)
.slice(0, 1000);
if (cleaned.length === 0) {
return res.json({ results: [] });
}
// Dedupe for the DB lookup, and split numeric-looking values off for a
// separate bigint equality check (so the pid index can be used).
const uniqueTextIds = Array.from(new Set(cleaned));
const numericPids = Array.from(new Set(
uniqueTextIds
.filter(s => /^\d+$/.test(s) && s.length <= 18) // safe for Number()
.map(s => Number(s))
.filter(n => Number.isSafeInteger(n) && n > 0)
));
try {
const { rows } = await pool.query(`
SELECT
p.pid,
p.title,
p.sku,
p.barcode,
p.vendor_reference,
p.notions_reference,
p.brand
FROM products p
WHERE p.sku = ANY($1::text[])
OR p.barcode = ANY($1::text[])
OR p.vendor_reference = ANY($1::text[])
OR p.notions_reference = ANY($1::text[])
OR p.pid = ANY($2::bigint[])
`, [uniqueTextIds, numericPids]);
// Normalize pid to Number once (products.pid is BIGINT → pg returns string)
const products = rows.map(r => ({
pid: Number(r.pid),
title: r.title,
sku: r.sku,
barcode: r.barcode,
vendor_reference: r.vendor_reference,
notions_reference: r.notions_reference,
brand: r.brand,
}));
// Group per-input-identifier. A product counts as a match for an
// identifier if any of its indexable fields equals the identifier string
// (or the pid matches when the identifier is numeric). The comparison is
// done in JS against the fetched products — cheap because the product
// count is bounded by the DB result set.
const results = cleaned.map(identifier => {
const candidates = products.filter(p => (
p.sku === identifier ||
p.barcode === identifier ||
p.vendor_reference === identifier ||
p.notions_reference === identifier ||
String(p.pid) === identifier
));
return { identifier, candidates };
});
res.json({ results });
} catch (error) {
console.error('Error resolving identifiers:', error);
res.status(500).json({ error: 'Failed to resolve identifiers' });
}
});
// Get a single product // Get a single product
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
try { try {

View File

@@ -23,11 +23,11 @@ const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ defau
const Forecasting = lazy(() => import('./pages/Forecasting')); const Forecasting = lazy(() => import('./pages/Forecasting'));
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator')); const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
const HtsLookup = lazy(() => import('./pages/HtsLookup')); const HtsLookup = lazy(() => import('./pages/HtsLookup'));
const Vendors = lazy(() => import('./pages/Vendors'));
const Categories = lazy(() => import('./pages/Categories')); const Categories = lazy(() => import('./pages/Categories'));
const Brands = lazy(() => import('./pages/Brands')); const Brands = lazy(() => import('./pages/Brands'));
const ProductLines = lazy(() => import('./pages/ProductLines')); const ProductLines = lazy(() => import('./pages/ProductLines'));
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders')); const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
const CreatePurchaseOrder = lazy(() => import('./pages/CreatePurchaseOrder'));
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard')); const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
const Newsletter = lazy(() => import('./pages/Newsletter')); const Newsletter = lazy(() => import('./pages/Newsletter'));
const RepeatOrders = lazy(() => import('./pages/RepeatOrders')); const RepeatOrders = lazy(() => import('./pages/RepeatOrders'));
@@ -137,13 +137,6 @@ function App() {
</Suspense> </Suspense>
</Protected> </Protected>
} /> } />
<Route path="/vendors" element={
<Protected page="vendors">
<Suspense fallback={<PageLoading />}>
<Vendors />
</Suspense>
</Protected>
} />
<Route path="/brands" element={ <Route path="/brands" element={
<Protected page="brands"> <Protected page="brands">
<Suspense fallback={<PageLoading />}> <Suspense fallback={<PageLoading />}>
@@ -200,6 +193,13 @@ function App() {
</Suspense> </Suspense>
</Protected> </Protected>
} /> } />
<Route path="/create-purchase-order" element={
<Protected page="create_purchase_orders">
<Suspense fallback={<PageLoading />}>
<CreatePurchaseOrder />
</Suspense>
</Protected>
} />
{/* Always loaded settings */} {/* Always loaded settings */}
<Route path="/settings" element={ <Route path="/settings" element={

View File

@@ -10,7 +10,7 @@ const PAGES = [
{ path: "/overview", permission: "access:overview" }, { path: "/overview", permission: "access:overview" },
{ path: "/products", permission: "access:products" }, { path: "/products", permission: "access:products" },
{ path: "/categories", permission: "access:categories" }, { path: "/categories", permission: "access:categories" },
{ path: "/vendors", permission: "access:vendors" }, { path: "/brands", permission: "access:brands" },
{ path: "/purchase-orders", permission: "access:purchase_orders" }, { path: "/purchase-orders", permission: "access:purchase_orders" },
{ path: "/analytics", permission: "access:analytics" }, { path: "/analytics", permission: "access:analytics" },
{ path: "/discount-simulator", permission: "access:discount_simulator" }, { path: "/discount-simulator", permission: "access:discount_simulator" },

View File

@@ -129,8 +129,7 @@ Admin users automatically have all permissions.
| `access:overview` | Access to Overview page | | `access:overview` | Access to Overview page |
| `access:products` | Access to Products page | | `access:products` | Access to Products page |
| `access:categories` | Access to Categories page | | `access:categories` | Access to Categories page |
| `access:brands` | Access to Brands page | | `access:brands` | Access to Brands & Vendors page |
| `access:vendors` | Access to Vendors page |
| `access:purchase_orders` | Access to Purchase Orders page | | `access:purchase_orders` | Access to Purchase Orders page |
| `access:analytics` | Access to Analytics page | | `access:analytics` | Access to Analytics page |
| `access:discount_simulator` | Access to Discount Simulator page | | `access:discount_simulator` | Access to Discount Simulator page |

View File

@@ -0,0 +1,787 @@
/**
* Modal that lets the user add products to the PO via a single unified
* interface that covers search, paste, and file upload.
*
* The three previously-separate tabs are replaced by one input surface:
* - Type a query → live-search `/api/products/search` and show results
* - Paste multi-line or tab-separated data → onPaste intercepts, parses,
* and switches to a column-mapping preview
* - Click "Upload file" OR drop a .xlsx/.csv onto the dialog → parse and
* show the same column-mapping preview
*
* The "mode" of the interface is derived from state (presence of a parsed
* table) rather than stored — no state machine, no drift.
*
* Paste/upload funnel through the same pipeline: parse → auto-detect
* columns → preview w/ inline remapping → resolve identifiers → (optional
* review dialog for ambiguous matches) → onAdd. Search results resolve
* directly since each row is a known pid.
*/
import { useCallback, useMemo, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Loader2,
Upload,
FileSpreadsheet,
AlertCircle,
Search as SearchIcon,
Check,
X,
} from "lucide-react";
import { toast } from "sonner";
import axios from "axios";
import type { SearchProduct } from "@/components/product-editor/types";
import {
parsePastedTable,
parseWorkbookFirstSheet,
autoDetectColumns,
applyMapping,
type ParsedTable,
type DetectedMapping,
type ColumnRole,
} from "./parseSpreadsheet";
import { resolveIdentifiers } from "./resolveIdentifiers";
import type { ResolveResult } from "./types";
import { ReviewMatchesDialog } from "./ReviewMatchesDialog";
import { cn } from "@/lib/utils";
export interface AddProductsResult {
/** Resolved (pid, qty) pairs ready to be hydrated by the parent. */
items: Array<{ pid: number; qty: number }>;
}
interface AddProductsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Pids that are already on the PO; used to dim/disable in search results. */
existingPids: Set<number>;
/** Called when the user has finalized a list of pids to add. */
onAdd: (result: AddProductsResult) => void;
}
interface QuickSearchResult {
pid: number;
title: string;
sku: string;
barcode: string;
brand: string;
line: string;
regular_price: number;
image_175: string | null;
}
const SEARCH_LIMIT = 100;
export function AddProductsDialog({
open,
onOpenChange,
existingPids,
onAdd,
}: AddProductsDialogProps) {
// ----- Search state --------------------------------------------------------
const [query, setQuery] = useState("");
const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]);
const [searchTotal, setSearchTotal] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const [searched, setSearched] = useState(false);
// ----- Parse state (from paste/upload) ------------------------------------
const [table, setTable] = useState<ParsedTable | null>(null);
const [mapping, setMapping] = useState<DetectedMapping | null>(null);
const [filename, setFilename] = useState<string | null>(null);
// ----- Review (ambiguous match) state --------------------------------------
const [reviewOpen, setReviewOpen] = useState(false);
const [reviewResult, setReviewResult] = useState<ResolveResult | null>(null);
const [resolving, setResolving] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Mode is derived: if we have a parsed table, we're showing the preview;
// otherwise we're in search mode.
const inParseMode = table !== null;
// Computed at the dialog level so the sticky footer's Add button shows an
// accurate count without having to plumb state out of ParsedPreview.
// applyMapping is pure, so ParsedPreview computing the same thing for its
// own summary line / row highlighting can't drift from this value.
const parseImportable = useMemo(() => {
if (!table || !mapping || mapping.identifierIdx < 0) {
return { count: 0, hasIdentifier: false };
}
return {
count: applyMapping(table, mapping).length,
hasIdentifier: true,
};
}, [table, mapping]);
// ----- Reset everything (used by Start over button) -----------------------
const resetAll = useCallback(() => {
setTable(null);
setMapping(null);
setFilename(null);
setQuery("");
setSearchResults([]);
setSearchTotal(0);
setSearched(false);
}, []);
// ----- Close handler that also resets ------------------------------------
const handleClose = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) resetAll();
onOpenChange(nextOpen);
},
[onOpenChange, resetAll]
);
// ----- Search --------------------------------------------------------------
const runSearch = useCallback(async () => {
if (!query.trim()) return;
setIsSearching(true);
setSearched(true);
try {
const res = await axios.get<{ results: QuickSearchResult[]; total: number }>(
"/api/products/search",
{ params: { q: query } }
);
setSearchResults(res.data.results ?? []);
setSearchTotal(res.data.total ?? 0);
} catch {
toast.error("Search failed");
} finally {
setIsSearching(false);
}
}, [query]);
const handleSelectSearchResult = useCallback(
async (result: QuickSearchResult) => {
// Look up full details to pull MOQ (to default qty sensibly)
try {
const res = await axios.get<SearchProduct[]>("/api/import/search-products", {
params: { pid: result.pid },
});
const full = (res.data ?? [])[0];
const moq = full?.moq && full.moq > 0 ? full.moq : 1;
onAdd({ items: [{ pid: Number(result.pid), qty: moq }] });
} catch {
// Fall back to qty=1 if the detail lookup fails — still adds the product
onAdd({ items: [{ pid: Number(result.pid), qty: 1 }] });
}
handleClose(false);
},
[onAdd, handleClose]
);
const handleLoadAllSearchResults = useCallback(async () => {
const pids = searchResults
.map((r) => Number(r.pid))
.filter((pid) => !existingPids.has(pid));
if (pids.length === 0) return;
try {
const res = await axios.get<SearchProduct[]>("/api/import/search-products", {
params: { pid: pids.join(",") },
});
const items = (res.data ?? []).map((p) => ({
pid: Number(p.pid),
qty: p.moq && p.moq > 0 ? p.moq : 1,
}));
onAdd({ items });
handleClose(false);
} catch {
toast.error("Failed to load products");
}
}, [searchResults, existingPids, onAdd, handleClose]);
// ----- Paste interception --------------------------------------------------
// If the clipboard content looks like tabular data (contains newlines or
// tabs), we hijack the paste and switch to parse mode instead of letting
// the text land in the search input.
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData("text");
if (!text || (!text.includes("\n") && !text.includes("\t"))) {
// Normal paste — let it through
return;
}
e.preventDefault();
const parsed = parsePastedTable(text);
if (!parsed.headers.length || !parsed.rows.length) {
toast.error("No data detected in pasted content");
return;
}
setTable(parsed);
setMapping(autoDetectColumns(parsed.headers, parsed.rows));
setFilename(null);
setQuery("");
}, []);
// ----- File handling -------------------------------------------------------
const handleFile = useCallback(async (file: File) => {
setFilename(file.name);
try {
const buffer = await file.arrayBuffer();
const parsed = parseWorkbookFirstSheet(buffer);
if (!parsed.headers.length || !parsed.rows.length) {
toast.error("No data found in file");
setFilename(null);
return;
}
setTable(parsed);
setMapping(autoDetectColumns(parsed.headers, parsed.rows));
} catch (e) {
console.error(e);
toast.error("Could not parse file");
setFilename(null);
}
}, []);
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
// Reset the input so selecting the same file again still fires onChange
e.target.value = "";
},
[handleFile]
);
// Full-dialog dropzone — the user can drop a file anywhere on the modal
const { getRootProps, getInputProps, isDragActive } = useDropzone({
noClick: true,
noKeyboard: true,
maxFiles: 1,
accept: {
"application/vnd.ms-excel": [".xls"],
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"],
"text/csv": [".csv"],
},
onDropAccepted: ([file]) => {
void handleFile(file);
},
onDropRejected: (rejections) => {
const msg = rejections[0]?.errors[0]?.message || "Invalid file";
toast.error(msg);
},
});
// ----- Parse preview → resolve → review → onAdd pipeline -------------------
const handleResolveAndAdd = useCallback(async () => {
if (!table || !mapping) return;
const rows = applyMapping(table, mapping);
if (rows.length === 0) {
toast.error("No valid rows to import");
return;
}
setResolving(true);
try {
const result = await resolveIdentifiers(rows);
// Fast path: everything matched cleanly → skip the review dialog
if (result.ambiguous.length === 0 && result.unmatched.length === 0) {
onAdd({ items: result.matched.map((m) => ({ pid: m.pid, qty: m.qty })) });
handleClose(false);
return;
}
setReviewResult(result);
setReviewOpen(true);
} catch (e) {
console.error(e);
toast.error("Failed to look up products");
} finally {
setResolving(false);
}
}, [table, mapping, onAdd, handleClose]);
const handleReviewConfirm = useCallback(
(resolved: Array<{ pid: number; qty: number }>) => {
setReviewOpen(false);
setReviewResult(null);
if (resolved.length === 0) {
toast.warning("No rows selected to add");
return;
}
onAdd({ items: resolved });
handleClose(false);
},
[onAdd, handleClose]
);
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className="max-w-4xl max-h-[85vh] flex flex-col p-0 gap-0"
{...getRootProps()}
>
{/* Hidden file input driven by the Upload button */}
<input
ref={fileInputRef}
type="file"
accept=".xls,.xlsx,.csv"
className="hidden"
onChange={handleFileInputChange}
/>
{/* react-dropzone's input (for keyboard accessibility) */}
<input {...getInputProps()} />
{/* Drag-over overlay */}
{isDragActive && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-primary/10 backdrop-blur-sm rounded-lg border-2 border-dashed border-primary pointer-events-none">
<div className="flex flex-col items-center gap-2 text-primary">
<FileSpreadsheet className="h-10 w-10" />
<p className="font-medium">Drop file to parse</p>
</div>
</div>
)}
<DialogHeader className="p-6 pb-3">
<DialogTitle>Add products</DialogTitle>
</DialogHeader>
{/* Primary input row — always visible */}
<div className="px-6 pb-3">
<div className="flex gap-2">
{/* Wrapper has px-0.5 so the input's focus ring isn't clipped
by the dialog's rounded clipping region on the left edge */}
<div className="flex-1 px-0.5">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
runSearch();
}
}}
onPaste={handlePaste}
placeholder="Search by name, UPC, SKU, supplier # — or paste multi-line data"
disabled={inParseMode}
/>
</div>
<Button onClick={runSearch} disabled={!query.trim() || inParseMode || isSearching}>
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SearchIcon className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={inParseMode}
>
<Upload className="h-4 w-4 mr-2" />
Upload file
</Button>
</div>
</div>
{/* Content area: parse preview OR search results OR empty hint.
`flex-1 overflow-auto min-h-0` lets this region shrink and
scroll independently while the sticky footer below stays put.
isSearching is checked BEFORE the searched branch so that the
moment the user clicks Search (which flips both `searched` and
`isSearching` to true in the same render) we show a loading
state instead of flashing "No results" against the previous
empty results array while the network request is in flight. */}
<div className="flex-1 overflow-auto px-6 pb-3 min-h-0">
{inParseMode && table && mapping ? (
<ParsedPreview
table={table}
mapping={mapping}
filename={filename}
onMappingChange={setMapping}
onReset={resetAll}
/>
) : isSearching ? (
<div className="text-center text-sm text-muted-foreground py-12">
<Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin opacity-60" />
Searching
</div>
) : searched ? (
<SearchResultsTable
results={searchResults}
total={searchTotal}
loadedPids={existingPids}
onSelect={handleSelectSearchResult}
onLoadAll={handleLoadAllSearchResults}
/>
) : (
<div className="text-center text-sm text-muted-foreground py-8">
<SearchIcon className="h-8 w-8 mx-auto mb-3 opacity-30" />
</div>
)}
</div>
{/* Sticky footer: only rendered in parse mode. Because it's a
flex-none sibling of the scrollable content area (not inside
it), it stays fixed at the bottom of the dialog regardless of
how tall the preview table grows. Search mode doesn't need a
footer since adding is click-per-row. */}
{inParseMode && (
<div className="flex-none border-t bg-background px-6 py-3 flex justify-end">
<Button
onClick={handleResolveAndAdd}
disabled={parseImportable.count === 0 || resolving}
>
{resolving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Looking up products
</>
) : (
<>
Add {parseImportable.count}{" "}
{parseImportable.count === 1 ? "product" : "products"}
</>
)}
</Button>
</div>
)}
</DialogContent>
</Dialog>
<ReviewMatchesDialog
open={reviewOpen}
onOpenChange={(o) => {
setReviewOpen(o);
if (!o) setReviewResult(null);
}}
result={reviewResult}
onConfirm={handleReviewConfirm}
/>
</>
);
}
// ============================================================================
// SearchResultsTable — compact results list with click-to-add + load-all
// ============================================================================
function SearchResultsTable({
results,
total,
loadedPids,
onSelect,
onLoadAll,
}: {
results: QuickSearchResult[];
total: number;
loadedPids: Set<number>;
onSelect: (result: QuickSearchResult) => void;
onLoadAll: () => void;
}) {
if (results.length === 0) {
return (
<div className="text-center text-sm text-muted-foreground py-12">
No results. Try a different search term.
</div>
);
}
const isTruncated = total > SEARCH_LIMIT;
const unloadedCount = results.filter((r) => !loadedPids.has(Number(r.pid))).length;
return (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{isTruncated
? `Showing ${SEARCH_LIMIT} of ${total} results`
: `${total} ${total === 1 ? "result" : "results"}`}
</span>
{unloadedCount > 1 && (
<Button variant="outline" size="sm" onClick={onLoadAll}>
Add all {unloadedCount}
</Button>
)}
</div>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Item #</TableHead>
<TableHead>UPC</TableHead>
<TableHead>Brand</TableHead>
<TableHead className="text-right">Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.map((r) => {
const isLoaded = loadedPids.has(Number(r.pid));
return (
<TableRow
key={r.pid}
className={cn(
isLoaded
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:bg-muted/50"
)}
onClick={() => !isLoaded && onSelect(r)}
>
<TableCell className="max-w-[320px]">
<div className="flex items-center gap-2">
{isLoaded && (
<Check className="h-3 w-3 text-emerald-600 flex-shrink-0" />
)}
<span className="truncate">{r.title}</span>
</div>
</TableCell>
<TableCell className="font-mono text-xs">{r.sku}</TableCell>
<TableCell className="font-mono text-xs">{r.barcode}</TableCell>
<TableCell>{r.brand}</TableCell>
<TableCell className="text-right">
${Number(r.regular_price).toFixed(2)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
}
// ============================================================================
// ParsedPreview — column-role mapping + data preview for paste/upload
// ============================================================================
function ParsedPreview({
table,
mapping,
filename,
onMappingChange,
onReset,
}: {
table: ParsedTable;
mapping: DetectedMapping;
filename: string | null;
onMappingChange: (m: DetectedMapping) => void;
onReset: () => void;
}) {
// Precompute the exact set of row indices that will be imported with the
// current mapping. Used both for the aggregate count and for row-level
// "will be dropped" visual feedback. When no qty column is assigned,
// each row gets an implicit qty=1 — so the check only cares about the
// identifier cell being non-empty.
const importableIndices = useMemo(() => {
const set = new Set<number>();
if (mapping.identifierIdx < 0) return set;
const hasQtyCol = mapping.qtyIdx >= 0;
table.rows.forEach((row, idx) => {
const identifier = (row[mapping.identifierIdx] || "").trim();
if (!identifier) return;
if (hasQtyCol) {
const qtyStr = (row[mapping.qtyIdx] || "").trim();
const cleaned = qtyStr.replace(/[^0-9.-]/g, "");
const qty = Math.round(Number(cleaned));
if (!Number.isFinite(qty) || qty <= 0) return;
}
set.add(idx);
});
return set;
}, [table, mapping]);
const importable = importableIndices.size;
const hasIdentifier = mapping.identifierIdx >= 0;
const handleRoleChange = useCallback(
(colIdx: number, role: ColumnRole) => {
const newRoles = [...mapping.roles];
newRoles[colIdx] = role;
// Enforce: at most one identifier and one qty column. Demote any
// previous holder of the role to "ignore".
if (role === "identifier" || role === "qty") {
for (let i = 0; i < newRoles.length; i++) {
if (i !== colIdx && newRoles[i] === role) newRoles[i] = "ignore";
}
}
onMappingChange({
identifierIdx: newRoles.findIndex((r) => r === "identifier"),
qtyIdx: newRoles.findIndex((r) => r === "qty"),
roles: newRoles,
});
},
[mapping, onMappingChange]
);
const previewRows = table.rows.slice(0, 10);
// Single source of truth for per-role styling. The header cell, the
// data cells in that column, and the dropdown trigger all read from
// these helpers so visual drift is impossible.
const columnBg = (role: ColumnRole) => {
if (role === "identifier") return "bg-emerald-50";
if (role === "qty") return "bg-sky-50";
return "";
};
const headerBorder = (role: ColumnRole) => {
if (role === "identifier") return "border-b-2 border-emerald-500";
if (role === "qty") return "border-b-2 border-sky-500";
return "border-b border-border";
};
const triggerStyle = (role: ColumnRole) => {
if (role === "identifier") return "border-emerald-500 text-emerald-900 font-medium";
if (role === "qty") return "border-sky-500 text-sky-900 font-medium";
return "text-muted-foreground";
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-2">
{filename && (
<>
<FileSpreadsheet className="h-3 w-3" />
<span className="font-medium">{filename}</span>
<span>·</span>
</>
)}
<span>
<span className="font-semibold text-foreground">{importable}</span>{" "}
of {table.rows.length} {table.rows.length === 1 ? "row" : "rows"}{" "}
will be imported
</span>
</span>
<Button variant="ghost" size="sm" onClick={onReset}>
<X className="h-3 w-3 mr-1" />
Start over
</Button>
</div>
<div className="border rounded-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr>
{table.headers.map((h, i) => {
const role = mapping.roles[i] || "ignore";
return (
<th
key={i}
className={cn(
"text-left p-2 align-top min-w-[140px]",
columnBg(role),
headerBorder(role)
)}
>
<div
className="font-mono text-xs truncate mb-1.5"
title={h || `Column ${i + 1}`}
>
{h || `Column ${i + 1}`}
</div>
<Select
value={role}
onValueChange={(v) => handleRoleChange(i, v as ColumnRole)}
>
<SelectTrigger
className={cn("h-7 text-xs w-full", triggerStyle(role))}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="identifier">Identifier</SelectItem>
<SelectItem value="qty">Quantity</SelectItem>
<SelectItem value="ignore">Ignore</SelectItem>
</SelectContent>
</Select>
</th>
);
})}
</tr>
</thead>
<tbody>
{previewRows.map((r, i) => {
const willImport = importableIndices.has(i);
return (
<tr
key={i}
className={cn(
"border-t",
!willImport && "bg-destructive/5"
)}
title={
!willImport
? "This row will be skipped (missing identifier or invalid quantity)"
: undefined
}
>
{r.map((cell, j) => {
const role = mapping.roles[j] || "ignore";
return (
<td
key={j}
// max-w + truncate + whitespace-nowrap keeps every
// row at a single-line height regardless of cell
// content length. The full value is available via
// the title tooltip for rows the user wants to
// inspect (e.g. long product descriptions).
className={cn(
"p-2 max-w-[240px] truncate whitespace-nowrap",
columnBg(role),
role === "identifier" && "font-mono",
role === "ignore" && "text-muted-foreground/70",
!willImport && "line-through decoration-destructive/50"
)}
title={cell || undefined}
>
{cell || <span className="text-muted-foreground"></span>}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
{table.rows.length > 10 && (
<div className="text-xs text-muted-foreground p-2 border-t bg-muted/30">
and {table.rows.length - 10} more rows
</div>
)}
</div>
{!hasIdentifier && (
<div className="flex items-start gap-2 text-xs text-amber-700">
<AlertCircle className="h-3 w-3 mt-0.5" />
<span>Pick an identifier column above to continue.</span>
</div>
)}
{hasIdentifier && importable === 0 && (
<div className="flex items-start gap-2 text-xs text-destructive">
<AlertCircle className="h-3 w-3 mt-0.5" />
<span>No rows have a valid identifier.</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
/**
* Post-submit success screen.
*
* Shows when the legacy backend has accepted the PO and returned a po_id.
* The single primary action is the external link to the legacy admin's PO
* editor; secondary action is "Create another" which resets the page.
*/
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CheckCircle2, ExternalLink, Plus } from "lucide-react";
interface ConfirmationViewProps {
poId: number;
itemCount: number;
onCreateAnother: () => void;
}
export function ConfirmationView({
poId,
itemCount,
onCreateAnother,
}: ConfirmationViewProps) {
const externalUrl = `https://backend.acherryontop.com/po/edit/${poId}`;
return (
<div className="max-w-2xl mx-auto pt-12">
<Card>
<CardContent className="pt-8 pb-6">
<div className="flex flex-col items-center text-center">
<div className="rounded-full bg-emerald-100 p-3 mb-4">
<CheckCircle2 className="h-8 w-8 text-emerald-600" />
</div>
<h2 className="text-2xl font-semibold mb-1">Purchase order created</h2>
<p className="text-muted-foreground mb-6">
PO #{poId} with {itemCount} {itemCount === 1 ? "item" : "items"} has been
submitted to the backend.
</p>
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<Button asChild size="lg">
<a href={externalUrl} target="_blank" rel="noreferrer">
Open PO #{poId}
<ExternalLink className="h-4 w-4 ml-2" />
</a>
</Button>
<Button variant="outline" size="lg" onClick={onCreateAnother}>
<Plus className="h-4 w-4 mr-2" />
Create another
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,354 @@
/**
* Sortable, checkbox-selectable table of PO line items.
*
* Columns: checkbox · image · title · UPC · supplier#/notions# · shelf ·
* basket · on-order · total sold · cost ea · last sold · first in ·
* [notions inv (conditional)] · MOQ (editable, local) · qty (editable,
* highlighted on MOQ mismatch) · remove
*
* Sorting is local-only (no server round-trip), driven by clickable
* column headers. The Notions column toggles based on the supplier prop.
* MOQ is inline-editable and updates a per-row `moqOverride` field —
* never sent to the backend, never persisted.
*/
import { useMemo, useState, useCallback } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { X as XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatCurrency, formatNumber, formatDateShort } from "@/utils/productUtils";
import type { PoLineItem } from "./types";
import { NOTIONS_SUPPLIER_ID } from "./constants";
type SortKey =
| "title"
| "barcode"
| "supplier_ref"
| "current_stock"
| "baskets"
| "on_order_qty"
| "total_sold"
| "current_cost_price"
| "date_last_sold"
| "date_first_received"
| "notions_inv_count"
| "moq"
| "qty";
type SortDir = "asc" | "desc";
interface LineItemsTableProps {
items: PoLineItem[];
selectedPids: Set<number>;
supplierId: number | undefined;
onToggleSelect: (pid: number) => void;
onToggleSelectAll: (selectAll: boolean) => void;
onChangeQty: (pid: number, qty: number) => void;
onChangeMoqOverride: (pid: number, moq: number | undefined) => void;
onRemove: (pid: number) => void;
}
/** Effective MOQ for a row, considering the user's local override. */
function effectiveMoq(item: PoLineItem): number | null {
if (item.moqOverride != null) return item.moqOverride;
return item.moq;
}
/** True if the row's qty isn't a multiple of its (effective) MOQ. */
function isQtyMoqMismatch(item: PoLineItem): boolean {
const moq = effectiveMoq(item);
if (!moq || moq <= 0) return false;
if (item.qty <= 0) return false;
return item.qty % moq !== 0;
}
function compareValues(a: unknown, b: unknown, dir: SortDir): number {
const sign = dir === "asc" ? 1 : -1;
// Push nulls/undefineds to the bottom regardless of direction
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
if (typeof a === "number" && typeof b === "number") {
return (a - b) * sign;
}
// Date strings sort lexicographically as ISO; fall back to string compare
return String(a).localeCompare(String(b)) * sign;
}
export function LineItemsTable({
items,
selectedPids,
supplierId,
onToggleSelect,
onToggleSelectAll,
onChangeQty,
onChangeMoqOverride,
onRemove,
}: LineItemsTableProps) {
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>("asc");
const isNotions = Number(supplierId) === NOTIONS_SUPPLIER_ID;
const sorted = useMemo(() => {
if (!sortKey) return items;
const accessors: Record<SortKey, (i: PoLineItem) => unknown> = {
title: (i) => i.title?.toLowerCase(),
barcode: (i) => i.barcode,
supplier_ref: (i) => (isNotions ? i.notions_reference : i.vendor_reference),
current_stock: (i) => i.current_stock,
baskets: (i) => i.baskets,
on_order_qty: (i) => i.on_order_qty,
total_sold: (i) => i.total_sold,
current_cost_price: (i) => i.current_cost_price,
date_last_sold: (i) => i.date_last_sold,
date_first_received: (i) => i.date_first_received,
notions_inv_count: (i) => i.notions_inv_count,
moq: (i) => effectiveMoq(i),
qty: (i) => i.qty,
};
const accessor = accessors[sortKey];
return [...items].sort((a, b) => compareValues(accessor(a), accessor(b), sortDir));
}, [items, sortKey, sortDir, isNotions]);
const handleSort = useCallback(
(key: SortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
},
[sortKey]
);
const allSelected =
items.length > 0 && items.every((i) => selectedPids.has(i.pid));
const someSelected = !allSelected && items.some((i) => selectedPids.has(i.pid));
if (items.length === 0) {
return (
<div className="rounded-md border border-dashed p-12 text-center text-muted-foreground">
<p className="text-sm">No products added yet.</p>
</div>
);
}
const SortableHead = ({
label,
sortBy,
align = "left",
className,
}: {
label: string;
sortBy: SortKey;
align?: "left" | "right" | "center";
className?: string;
}) => {
const isActive = sortKey === sortBy;
return (
<TableHead
className={cn(
align === "right" && "text-right",
align === "center" && "text-center",
className
)}
>
<button
type="button"
onClick={() => handleSort(sortBy)}
className={cn(
"inline-flex items-center gap-1 hover:text-foreground transition-colors",
isActive ? "text-foreground" : "text-muted-foreground",
align === "right" && "flex-row-reverse",
align === "center" && "justify-center w-full"
)}
>
{label}
</button>
</TableHead>
);
};
return (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={(v) => onToggleSelectAll(Boolean(v))}
aria-label="Select all rows"
/>
</TableHead>
<SortableHead label="Qty" sortBy="qty" align="center" />
<TableHead className="w-[72px] text-center">Image</TableHead>
<SortableHead label="Product" sortBy="title" />
<SortableHead label="UPC" sortBy="barcode" align="left" />
<SortableHead
label={isNotions ? "Notions #" : "Supplier #"}
sortBy="supplier_ref"
className="whitespace-nowrap"
/>
<SortableHead label="MOQ" sortBy="moq" align="center" />
<SortableHead label="Shelf" sortBy="current_stock" align="center" />
<SortableHead label="Basket" sortBy="baskets" align="center" />
<SortableHead label="On Order" sortBy="on_order_qty" align="center" />
<SortableHead label="Total Sold" sortBy="total_sold" align="center" />
<SortableHead label="Cost" sortBy="current_cost_price" align="center" />
<SortableHead label="Last Sold" sortBy="date_last_sold" align="center" className="whitespace-nowrap"/>
<SortableHead label="First In" sortBy="date_first_received" align="center" className="whitespace-nowrap"/>
{isNotions && (
<SortableHead
label="Notions Inv."
sortBy="notions_inv_count"
align="center"
/>
)}
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((item) => {
const isSelected = selectedPids.has(item.pid);
const moq = effectiveMoq(item);
const mismatch = isQtyMoqMismatch(item);
return (
<TableRow
key={item.pid}
className={cn(isSelected && "bg-muted/50")}
>
<TableCell>
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect(item.pid)}
aria-label={`Select ${item.title}`}
/>
</TableCell>
<TableCell className="text-center">
<Input
type="text"
inputMode="numeric"
value={item.qty}
onChange={(e) => {
const n = Math.max(
0,
Math.round(Number(e.target.value.replace(/[^0-9]/g, "")) || 0)
);
onChangeQty(item.pid, n);
}}
className={cn(
"w-14 h-8 text-center",
mismatch &&
"border-amber-500 focus-visible:ring-amber-500 bg-amber-50"
)}
aria-label={`Quantity for ${item.title}`}
title={
mismatch
? `Not a multiple of MOQ (${moq})`
: undefined
}
/>
</TableCell>
<TableCell>
{item.image_url ? (
<img
src={item.image_url}
alt={item.title}
className="h-[60px] w-[60px] object-contain rounded"
loading="lazy"
/>
) : (
<div className="h-[60px] w-[60px] rounded bg-muted" />
)}
</TableCell>
<TableCell className="max-w-[350px] min-w-[200px]">
<div className="font-medium line-clamp-2" title={item.title}>
{item.title}
</div>
</TableCell>
<TableCell className="font-mono text-xs whitespace-nowrap text-center">
{item.barcode || "—"}
</TableCell>
<TableCell className="font-mono text-xs whitespace-nowrap text-center">
{(isNotions ? item.notions_reference : item.vendor_reference) || "—"}
</TableCell>
<TableCell className="text-center">
<Input
type="text"
inputMode="numeric"
value={moq ?? ""}
onChange={(e) => {
const v = e.target.value.replace(/[^0-9]/g, "");
if (v === "") {
onChangeMoqOverride(item.pid, 0);
} else {
const n = Math.max(0, Math.round(Number(v)));
onChangeMoqOverride(item.pid, Number.isFinite(n) ? n : 0);
}
}}
className="w-14 h-8 text-center text-xs"
aria-label={`MOQ for ${item.title}`}
/>
</TableCell>
<TableCell className="text-center">
{item.current_stock != null ? formatNumber(item.current_stock) : "—"}
</TableCell>
<TableCell className="text-center">
{item.baskets != null ? formatNumber(item.baskets) : "—"}
</TableCell>
<TableCell className="text-center">
{item.on_order_qty != null ? formatNumber(item.on_order_qty) : "—"}
</TableCell>
<TableCell className="text-center">
{item.total_sold != null ? formatNumber(item.total_sold) : "—"}
</TableCell>
<TableCell className="text-center whitespace-nowrap">
{item.current_cost_price != null
? formatCurrency(item.current_cost_price)
: "—"}
</TableCell>
<TableCell className="text-xs whitespace-nowrap text-center">
{item.date_last_sold ? formatDateShort(item.date_last_sold) : "—"}
</TableCell>
<TableCell className="text-xs whitespace-nowrap text-center">
{item.date_first_received ? formatDateShort(item.date_first_received) : "—"}
</TableCell>
{isNotions && (
<TableCell className="text-center">
{item.notions_inv_count != null
? formatNumber(item.notions_inv_count)
: "—"}
</TableCell>
)}
<TableCell>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:text-destructive hover:bg-transparent"
onClick={() => onRemove(item.pid)}
aria-label={`Remove ${item.title}`}
>
<XIcon className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,62 @@
/**
* Floating action bar that appears when one or more PO line items are
* checkbox-selected. Provides bulk Remove + Clear selection.
*
* Pattern adapted from inventory/src/components/product-import/steps/
* ValidationStep/components/FloatingSelectionBar.tsx — but stripped down:
* no zustand store, no template management, no delete confirmation
* dialog (Remove on the PO page is local-only and instantly reversible
* by adding the product again, so a confirm step would be friction).
*/
import { memo } from "react";
import { Button } from "@/components/ui/button";
import { X, Trash2 } from "lucide-react";
interface PoFloatingSelectionBarProps {
selectedCount: number;
onClear: () => void;
onRemove: () => void;
}
export const PoFloatingSelectionBar = memo(function PoFloatingSelectionBar({
selectedCount,
onClear,
onRemove,
}: PoFloatingSelectionBarProps) {
if (selectedCount === 0) return null;
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-200">
<div className="bg-background border border-border rounded-xl shadow-xl px-4 py-3 flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="bg-primary text-primary-foreground text-sm font-medium px-2.5 py-1 rounded-md whitespace-nowrap">
{selectedCount} selected
</div>
<Button
variant="ghost"
size="sm"
onClick={onClear}
className="h-8 w-8 p-0"
title="Clear selection"
aria-label="Clear selection"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="h-8 w-px bg-border" />
<Button
variant="destructive"
size="sm"
onClick={onRemove}
className="gap-2"
>
<Trash2 className="h-4 w-4" />
Remove
</Button>
</div>
</div>
);
});

View File

@@ -0,0 +1,251 @@
/**
* Review screen for the paste/upload flow.
*
* Shows the result of `resolveIdentifiers()`:
* - "matched" rows are summarized at the top (no action needed)
* - "ambiguous" rows show a radio-pick of candidates per row
* - "unmatched" rows are listed separately so the user knows what was
* dropped (no inline rescue — user can re-paste a corrected value)
*
* On confirm, the dialog returns the final pid+qty list (matched rows
* plus user-resolved ambiguous rows). Skipped ambiguous rows are
* dropped silently.
*/
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { CheckCircle2, AlertCircle, HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ResolveResult } from "./types";
interface ReviewMatchesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
result: ResolveResult | null;
onConfirm: (resolved: Array<{ pid: number; qty: number }>) => void;
}
export function ReviewMatchesDialog({
open,
onOpenChange,
result,
onConfirm,
}: ReviewMatchesDialogProps) {
// Per-ambiguous-row selected pid (keyed by row index in result.ambiguous)
const [picks, setPicks] = useState<Record<number, number | null>>({});
// Reset picks whenever a new result comes in
useEffect(() => {
setPicks({});
}, [result]);
if (!result) return null;
const matchedCount = result.matched.length;
const ambiguousCount = result.ambiguous.length;
const unmatchedCount = result.unmatched.length;
const handleConfirm = () => {
const out: Array<{ pid: number; qty: number }> = result.matched.map((m) => ({
pid: m.pid,
qty: m.qty,
}));
result.ambiguous.forEach((row, idx) => {
const picked = picks[idx];
if (picked) out.push({ pid: picked, qty: row.qty });
});
onConfirm(out);
};
// Skip review entirely if there's nothing ambiguous and nothing unmatched —
// the parent should ideally call onConfirm directly in that case, but we
// also short-circuit here as a safety net.
const reviewNeeded = ambiguousCount > 0 || unmatchedCount > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Review imported rows</DialogTitle>
<DialogDescription>
{reviewNeeded
? "Resolve ambiguous matches before adding products to your purchase order."
: "All rows matched successfully."}
</DialogDescription>
</DialogHeader>
{/* Summary cards */}
<div className="grid grid-cols-3 gap-3 mt-2">
<div className="border rounded-md p-3 bg-emerald-50 border-emerald-200">
<div className="flex items-center gap-2 text-emerald-800">
<CheckCircle2 className="h-4 w-4" />
<span className="text-xs font-medium">Matched</span>
</div>
<div className="text-2xl font-semibold text-emerald-900 mt-1">
{matchedCount}
</div>
</div>
<div
className={cn(
"border rounded-md p-3",
ambiguousCount > 0
? "bg-amber-50 border-amber-200"
: "bg-muted border-border"
)}
>
<div
className={cn(
"flex items-center gap-2",
ambiguousCount > 0 ? "text-amber-800" : "text-muted-foreground"
)}
>
<HelpCircle className="h-4 w-4" />
<span className="text-xs font-medium">Ambiguous</span>
</div>
<div
className={cn(
"text-2xl font-semibold mt-1",
ambiguousCount > 0 ? "text-amber-900" : "text-muted-foreground"
)}
>
{ambiguousCount}
</div>
</div>
<div
className={cn(
"border rounded-md p-3",
unmatchedCount > 0
? "bg-destructive/10 border-destructive/30"
: "bg-muted border-border"
)}
>
<div
className={cn(
"flex items-center gap-2",
unmatchedCount > 0 ? "text-destructive" : "text-muted-foreground"
)}
>
<AlertCircle className="h-4 w-4" />
<span className="text-xs font-medium">Unmatched</span>
</div>
<div
className={cn(
"text-2xl font-semibold mt-1",
unmatchedCount > 0 ? "text-destructive" : "text-muted-foreground"
)}
>
{unmatchedCount}
</div>
</div>
</div>
<ScrollArea className="flex-1 mt-4 -mx-6 px-6">
{ambiguousCount > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Pick a product for each ambiguous row</h3>
{result.ambiguous.map((row, idx) => (
<div
key={`ambig-${idx}-${row.identifier}`}
className="border rounded-md p-3"
>
<div className="flex items-center justify-between mb-2">
<div>
<div className="text-xs text-muted-foreground">Pasted value</div>
<div className="font-mono text-sm">{row.identifier}</div>
</div>
<div className="text-right">
<div className="text-xs text-muted-foreground">Qty</div>
<div className="text-sm font-semibold">{row.qty}</div>
</div>
</div>
<div className="space-y-1.5">
{row.candidates.map((c) => {
const isPicked = picks[idx] === c.pid;
return (
<button
key={c.pid}
type="button"
onClick={() =>
setPicks((prev) => ({
...prev,
[idx]: isPicked ? null : c.pid,
}))
}
className={cn(
"w-full text-left flex items-center gap-3 p-2 rounded-md border transition-colors",
isPicked
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/50"
)}
>
<div
className={cn(
"h-4 w-4 rounded-full border-2 flex-shrink-0",
isPicked
? "border-primary bg-primary"
: "border-muted-foreground/40"
)}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{c.title}
</div>
<div className="text-xs text-muted-foreground flex gap-3">
<span>PID {c.pid}</span>
{c.sku && <span>SKU {c.sku}</span>}
{c.barcode && <span>UPC {c.barcode}</span>}
{c.brand && <span>{c.brand}</span>}
</div>
</div>
</button>
);
})}
</div>
</div>
))}
</div>
)}
{unmatchedCount > 0 && (
<div className="mt-6">
<h3 className="text-sm font-semibold mb-2">
Unmatched (these will be skipped)
</h3>
<div className="border rounded-md divide-y">
{result.unmatched.map((row, idx) => (
<div
key={`unmatched-${idx}-${row.identifier}`}
className="flex items-center justify-between p-2 text-sm"
>
<span className="font-mono">{row.identifier}</span>
<span className="text-muted-foreground text-xs">qty {row.qty}</span>
</div>
))}
</div>
</div>
)}
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm}>
Add{" "}
{matchedCount + Object.values(picks).filter((p) => p != null).length}{" "}
products
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,64 @@
/**
* Supplier picker for the Create PO page.
*
* Reuses the existing ComboboxField from product-editor (the canonical
* combobox in this app). Loads the supplier list once via react-query
* using the shared `["field-options"]` queryKey, so the cache is shared
* with any other page that reads the same field options and the
* suppliers list will already be warm if visited elsewhere first.
*/
import { useQuery } from "@tanstack/react-query";
import { ComboboxField } from "@/components/product-editor/ComboboxField";
import { Skeleton } from "@/components/ui/skeleton";
import type { FieldOption } from "@/components/product-editor/types";
interface FieldOptionsResponse {
suppliers?: FieldOption[];
}
export function SupplierSelector({
value,
onChange,
disabled,
}: {
/** The selected supplier ID as a string (matches FieldOption.value type). */
value: string | undefined;
onChange: (supplierId: string | undefined) => void;
disabled?: boolean;
}) {
const { data, isLoading, error } = useQuery({
queryKey: ["field-options"],
queryFn: async (): Promise<FieldOptionsResponse> => {
const res = await fetch("/api/import/field-options");
if (!res.ok) throw new Error("Failed to load suppliers");
return res.json();
},
staleTime: 30 * 60 * 1000, // 30 min — matches the server-side cache TTL
});
const options = data?.suppliers ?? [];
if (isLoading) {
return <Skeleton className="h-9 w-full" />;
}
if (error) {
return (
<div className="text-sm text-destructive">
Failed to load suppliers. Try refreshing the page.
</div>
);
}
return (
<ComboboxField
options={options}
value={value ?? ""}
onChange={(v) => onChange(v || undefined)}
placeholder="Select a supplier…"
searchPlaceholder="Search suppliers…"
disabled={disabled}
/>
);
}

View File

@@ -0,0 +1,13 @@
/**
* The Notions supplier has a fixed ID in the legacy backend, hardcoded in
* several places (see inventory-server/src/routes/products.js, import.js).
* When this supplier is selected, the Create PO page swaps "Supplier #" for
* "Notions #" and surfaces the per-product Notions inventory column.
*/
export const NOTIONS_SUPPLIER_ID = 92;
/** Max pids the backend /api/products/batch endpoint accepts in one call. */
export const BATCH_LOOKUP_MAX_PIDS = 200;
/** Max search-result rows we'll attempt to resolve when bulk-adding by paste/upload. */
export const RESOLVE_LOOKUP_MAX_ROWS = 1000;

View File

@@ -0,0 +1,353 @@
/**
* Spreadsheet parsing helpers for the Create PO page.
*
* The parsing helpers (parsePasted, detectDelimiter, autoMapHeaderNames,
* toIntOrUndefined) were originally adapted from a now-removed
* forecasting/QuickOrderBuilder.tsx prototype, scoped down to the
* 2-column (identifier, qty) PO use case.
*
* Two main entry points:
* - parsePastedTable(text) → headers + rows from a TSV/CSV string
* - parseWorkbookFirstSheet(buf) → headers + rows from an .xlsx/.xls/.csv file
*
* Plus a `autoDetectColumns(headers)` that picks the most likely identifier
* and qty column for the auto-detect path. Both PasteTab and UploadTab
* funnel through these helpers and end at the same RawIdentifierRow[]
* shape consumed by `resolveIdentifiers()`.
*/
import * as XLSX from "xlsx";
import type { RawIdentifierRow } from "./types";
// --- Header synonym lists -----------------------------------------------------
const IDENTIFIER_HEADER_SYNONYMS = [
"upc",
"barcode",
"bar code",
"ean",
"jan",
"sku",
"item",
"item#",
"item number",
"item no",
"item_no",
"supplier #",
"supplier no",
"supplier_no",
"supplier number",
"notions #",
"notions no",
"notions_no",
"notions number",
"product code",
"code",
"id",
"pid",
];
const QTY_HEADER_SYNONYMS = [
"qty",
"quantity",
"order qty",
"order quantity",
"amount",
"count",
"units",
];
function normalizeHeader(h: string): string {
return (h || "").trim().toLowerCase();
}
// --- Delimiter detection ------------------------------------------------------
function detectDelimiter(text: string): string {
// Heuristic: prefer tab, then comma, then semicolon. Sample first 5 lines.
const lines = text.split(/\r?\n/).slice(0, 5);
const counts: Record<string, number> = { "\t": 0, ",": 0, ";": 0 };
for (const line of lines) {
counts["\t"] += (line.match(/\t/g) || []).length;
counts[","] += (line.match(/,/g) || []).length;
counts[";"] += (line.match(/;/g) || []).length;
}
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
}
// --- Pasted text parser -------------------------------------------------------
export interface ParsedTable {
headers: string[];
rows: string[][];
/** True when the first row appears to be a header (contains non-numeric strings). */
hasHeader: boolean;
}
/**
* Parse a pasted TSV/CSV string. Detects delimiter, splits cleanly, pads
* short rows to header width, and trims whitespace per cell. All cells are
* preserved as STRINGS — we never coerce to numbers here, because UPCs with
* leading zeros must survive parsing intact.
*
* Header detection: if the first row's cells are mostly non-numeric strings
* we treat it as a header and pull it out; otherwise we synthesize generic
* "Column 1", "Column 2" headers and treat all rows as data. This makes the
* paste flow forgiving: users can paste data with or without a header row.
*/
export function parsePastedTable(text: string): ParsedTable {
if (!text || !text.trim()) return { headers: [], rows: [], hasHeader: false };
const delimiter = detectDelimiter(text);
const lines = text
.split(/\r?\n/)
.map((l) => l.trimEnd()) // trim trailing whitespace but keep tabs as separators
.filter((l) => l.length > 0);
if (lines.length === 0) return { headers: [], rows: [], hasHeader: false };
const split = (line: string) => line.split(delimiter).map((s) => s.trim());
const firstRow = split(lines[0]);
const restRows = lines.slice(1).map(split);
// Header heuristic: at least one cell in the first row is non-numeric and
// not empty, AND there's more than one row of data (otherwise treat the
// single row as data).
const looksLikeHeader =
firstRow.length >= 2 &&
firstRow.some((c) => c && !/^[0-9.\-+ ]+$/.test(c));
if (looksLikeHeader && restRows.length > 0) {
const headers = firstRow.map((h) => h || "");
const rows = restRows.map((r) => {
while (r.length < headers.length) r.push("");
return r;
});
return { headers, rows, hasHeader: true };
}
// No header — synthesize column names and treat every line as data
const allRows = [firstRow, ...restRows];
const maxCols = Math.max(...allRows.map((r) => r.length));
const headers = Array.from({ length: maxCols }, (_, i) => `Column ${i + 1}`);
const rows = allRows.map((r) => {
while (r.length < maxCols) r.push("");
return r;
});
return { headers, rows, hasHeader: false };
}
// --- Workbook (xlsx/xls/csv) parser -------------------------------------------
/**
* Parse the first sheet of a workbook (read from an ArrayBuffer) into the
* same {headers, rows} shape as parsePastedTable. All cell values are
* coerced to STRINGS to keep UPCs intact (raw: false). Empty cells become
* empty strings.
*/
export function parseWorkbookFirstSheet(buffer: ArrayBuffer): ParsedTable {
// raw:false → use formatted text values (preserves leading zeros if cell
// is text-formatted in the source spreadsheet); cellDates:true → date
// cells become Date objects which we then stringify.
const wb = XLSX.read(buffer, {
type: "array",
cellDates: true,
raw: false,
codepage: 65001,
WTF: false,
});
const sheetName = wb.SheetNames[0];
if (!sheetName) return { headers: [], rows: [], hasHeader: false };
const sheet = wb.Sheets[sheetName];
// header:1 returns rows as arrays; defval ensures missing cells become ""
const aoa = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
raw: false,
defval: "",
});
if (!aoa.length) return { headers: [], rows: [], hasHeader: false };
const stringify = (v: unknown): string => {
if (v === null || v === undefined) return "";
if (v instanceof Date) return v.toISOString().slice(0, 10);
return String(v).trim();
};
const allRows = aoa.map((row) => row.map(stringify));
const firstRow = allRows[0];
const restRows = allRows.slice(1).filter((r) => r.some((c) => c.length > 0));
const looksLikeHeader =
firstRow.length >= 2 &&
firstRow.some((c) => c && !/^[0-9.\-+ ]+$/.test(c));
if (looksLikeHeader && restRows.length > 0) {
const headers = firstRow.map((h) => h || "");
const rows = restRows.map((r) => {
while (r.length < headers.length) r.push("");
return r;
});
return { headers, rows, hasHeader: true };
}
const all = restRows.length > 0 ? [firstRow, ...restRows] : [firstRow];
const maxCols = Math.max(...all.map((r) => r.length));
const headers = Array.from({ length: maxCols }, (_, i) => `Column ${i + 1}`);
const rows = all.map((r) => {
while (r.length < maxCols) r.push("");
return r;
});
return { headers, rows, hasHeader: false };
}
// --- Auto-detect column roles -------------------------------------------------
export type ColumnRole = "identifier" | "qty" | "ignore";
export interface DetectedMapping {
/** Index of the column to treat as the product identifier (UPC/SKU/etc.) */
identifierIdx: number;
/** Index of the column to treat as the quantity. */
qtyIdx: number;
/** Per-column role assignment, indexed by header position. */
roles: ColumnRole[];
}
/**
* Best-effort guess of which column is the identifier and which is the qty.
*
* Strategy:
* 1. If headers contain known synonyms, use them.
* 2. Otherwise inspect the first few data rows: a column where most cells
* look numeric AND values are small (<10000) is the qty; the other is
* the identifier.
* 3. Fall back to: column 0 = identifier, column 1 = qty.
*/
export function autoDetectColumns(
headers: string[],
rows: string[][]
): DetectedMapping {
const norm = headers.map(normalizeHeader);
const findFirst = (syns: string[]): number => {
for (const s of syns) {
const idx = norm.findIndex((h) => h === s || (h && h.includes(s)));
if (idx >= 0) return idx;
}
return -1;
};
let identifierIdx = findFirst(IDENTIFIER_HEADER_SYNONYMS);
let qtyIdx = findFirst(QTY_HEADER_SYNONYMS);
// If headers didn't tell us, look at the data
if (identifierIdx < 0 || qtyIdx < 0) {
const sample = rows.slice(0, 10);
const numCols = headers.length;
const numericLikeRatios: number[] = [];
const avgValues: number[] = [];
for (let c = 0; c < numCols; c++) {
let numericCount = 0;
let sum = 0;
let parsedCount = 0;
for (const row of sample) {
const cell = (row[c] || "").trim();
if (!cell) continue;
// Treat as "numeric and small" if it parses to an int <= 10000
if (/^\d+$/.test(cell)) {
const n = Number(cell);
if (Number.isFinite(n)) {
numericCount += 1;
if (n <= 10000) {
sum += n;
parsedCount += 1;
}
}
}
}
numericLikeRatios.push(sample.length > 0 ? numericCount / sample.length : 0);
avgValues.push(parsedCount > 0 ? sum / parsedCount : Infinity);
}
if (qtyIdx < 0) {
// Best qty candidate: smallest avg value, prefer columns where >50% rows are numeric
let bestIdx = -1;
let bestAvg = Infinity;
for (let c = 0; c < numCols; c++) {
if (numericLikeRatios[c] >= 0.5 && avgValues[c] < bestAvg) {
bestAvg = avgValues[c];
bestIdx = c;
}
}
qtyIdx = bestIdx;
}
if (identifierIdx < 0) {
// Identifier = first column that isn't qty
for (let c = 0; c < numCols; c++) {
if (c !== qtyIdx) {
identifierIdx = c;
break;
}
}
}
}
// Final fallback: identifier must point somewhere; qty is optional and
// stays unassigned (-1) when there's no other column to use. applyMapping
// defaults each row's qty to 1 when qtyIdx is -1 — so a single-column
// paste of UPCs now imports as "one of each" instead of trying to parse
// the UPC itself as a qty (which previously produced billion-unit rows).
if (identifierIdx < 0) identifierIdx = 0;
if (qtyIdx < 0) {
qtyIdx = headers.length > 1 && identifierIdx !== 1 ? 1 : -1;
}
const roles: ColumnRole[] = headers.map((_, i) => {
if (i === identifierIdx) return "identifier";
if (i === qtyIdx) return "qty";
return "ignore";
});
return { identifierIdx, qtyIdx, roles };
}
// --- Convert mapped rows → RawIdentifierRow[] --------------------------------
/**
* Apply a column mapping to the parsed table to produce raw identifier
* rows. Rows with an empty identifier are dropped.
*
* When `qtyIdx` is -1 (no qty column assigned), each row gets a default
* qty of 1 — this makes single-column pastes like "paste a list of UPCs,
* one per line" work without forcing users to add a qty column. When
* `qtyIdx` is set but a row has an invalid/non-positive qty value, that
* row is dropped.
*/
export function applyMapping(
table: ParsedTable,
mapping: DetectedMapping
): RawIdentifierRow[] {
const out: RawIdentifierRow[] = [];
const hasQtyColumn = mapping.qtyIdx >= 0;
for (const row of table.rows) {
const identifier = (row[mapping.identifierIdx] || "").trim();
if (!identifier) continue;
let qty = 1;
if (hasQtyColumn) {
const qtyStr = (row[mapping.qtyIdx] || "").trim();
// Strip non-numeric chars from qty (commas, spaces, decimals)
const cleaned = qtyStr.replace(/[^0-9.-]/g, "");
const parsed = Math.round(Number(cleaned));
if (!Number.isFinite(parsed) || parsed <= 0) continue;
qty = parsed;
}
out.push({ identifier, qty });
}
return out;
}

View File

@@ -0,0 +1,137 @@
/**
* Identifier-resolution pipeline for the Create PO page.
*
* Takes a list of `RawIdentifierRow` (from paste/upload parsing) and looks
* them up in ONE backend call via `POST /api/products/resolve-identifiers`.
* The endpoint does the SQL work: a single query with indexed equality
* matches across sku, barcode, vendor_reference, notions_reference, and pid,
* returning candidates grouped per input identifier in input order.
*
* Each row resolves to one of:
* - matched → exactly one candidate, ready to add
* - ambiguous → multiple candidates; user must pick one in ReviewMatchesDialog
* - unmatched → zero candidates; surfaced so the user knows it was dropped
*
* Also exports `fetchBatchProducts`, which hydrates a list of pids into
* full PoLineItem rows via `GET /api/products/batch`.
*/
import axios from "axios";
import type {
RawIdentifierRow,
ResolveResult,
SearchCandidate,
PoLineItem,
} from "./types";
import { BATCH_LOOKUP_MAX_PIDS } from "./constants";
interface ResolveApiCandidate {
pid: number | string;
title: string;
sku: string | null;
barcode: string | null;
vendor_reference: string | null;
notions_reference: string | null;
brand: string | null;
}
interface ResolveApiResponse {
results: Array<{
identifier: string;
candidates: ResolveApiCandidate[];
}>;
}
/**
* Coerces a candidate from the API into the frontend's SearchCandidate
* shape. pid may come back as a string if the backend ever regresses to
* un-normalized BIGINT — Number() is defensive.
*/
function toSearchCandidate(c: ResolveApiCandidate): SearchCandidate {
return {
pid: Number(c.pid),
title: c.title,
sku: c.sku,
barcode: c.barcode,
brand: c.brand,
};
}
export async function resolveIdentifiers(
rows: RawIdentifierRow[]
): Promise<ResolveResult> {
if (rows.length === 0) {
return { matched: [], ambiguous: [], unmatched: [] };
}
const identifiers = rows.map((r) => r.identifier);
const res = await axios.post<ResolveApiResponse>(
"/api/products/resolve-identifiers",
{ identifiers }
);
const apiResults = res.data?.results ?? [];
const result: ResolveResult = { matched: [], ambiguous: [], unmatched: [] };
// The backend preserves input order and length, so we can zip by index.
// If the response is shorter than the input (backend truncation or error),
// missing rows are treated as unmatched so the user still sees them.
rows.forEach((row, i) => {
const apiRow = apiResults[i];
const candidates = apiRow?.candidates ?? [];
if (candidates.length === 0) {
result.unmatched.push({ identifier: row.identifier, qty: row.qty });
} else if (candidates.length === 1) {
result.matched.push({
pid: Number(candidates[0].pid),
qty: row.qty,
identifier: row.identifier,
});
} else {
result.ambiguous.push({
identifier: row.identifier,
qty: row.qty,
candidates: candidates.map(toSearchCandidate),
});
}
});
return result;
}
/**
* Fetch full product display data for a list of pids. Chunks the request
* to BATCH_LOOKUP_MAX_PIDS to stay under URL length limits even if the
* caller passes hundreds of pids.
*
* Returns a flat array of PoLineItem with `qty` set to the value passed in
* the `qtyByPid` map (default 1).
*/
export async function fetchBatchProducts(
pids: number[],
qtyByPid: Map<number, number> = new Map()
): Promise<PoLineItem[]> {
if (pids.length === 0) return [];
const uniqPids = Array.from(new Set(pids));
const out: PoLineItem[] = [];
for (let i = 0; i < uniqPids.length; i += BATCH_LOOKUP_MAX_PIDS) {
const chunk = uniqPids.slice(i, i + BATCH_LOOKUP_MAX_PIDS);
const res = await axios.get<Omit<PoLineItem, "qty">[]>(
"/api/products/batch",
{ params: { pids: chunk.join(",") } }
);
for (const row of res.data ?? []) {
// Defensive Number() coercion: the backend already returns pid as a
// Number, but if it ever regresses to a BIGINT string the numeric
// Set/Map lookups downstream would silently fail instead of crashing.
const pid = Number(row.pid);
out.push({ ...row, pid, qty: qtyByPid.get(pid) ?? 1 });
}
}
return out;
}

View File

@@ -0,0 +1,76 @@
/**
* Display shape for a single line item on the Create PO page. This is the
* exact response shape returned by GET /api/products/batch (snake_case).
*
* `qty` and `moqOverride` are local-only client state appended to the API
* shape; they are NOT returned by the backend.
*/
export interface PoLineItem {
pid: number;
title: string;
image_url: string | null;
barcode: string | null;
vendor_reference: string | null;
notions_reference: string | null;
notions_inv_count: number | null;
current_stock: number | null;
baskets: number | null;
on_order_qty: number | null;
total_sold: number | null;
current_cost_price: number | null;
date_last_sold: string | null;
date_first_received: string | null;
/** From the products table; may be null/0/inconsistent. The user can override locally. */
moq: number | null;
// --- Local-only client state ---
/** User-entered order quantity. */
qty: number;
/**
* Local override of MOQ for this row. Undefined means "use the canonical
* `moq` value above". This is never sent to the backend; it only affects
* the qty-validation highlight on the row.
*/
moqOverride?: number;
}
/** Wire shape sent to the legacy PHP endpoint. */
export interface PoSubmitItem {
pid: number;
qty: number;
}
/**
* Intermediate shape produced by paste/upload parsing — before identifier
* resolution against the products table.
*/
export interface RawIdentifierRow {
identifier: string;
qty: number;
}
/** A single search result candidate from /api/products/search. */
export interface SearchCandidate {
pid: number;
title: string;
sku: string | null;
barcode: string | null;
brand: string | null;
}
/**
* The result of attempting to resolve a single raw row to a product:
* - matched → exactly one product found
* - ambiguous → multiple candidates; user must pick one
* - unmatched → zero candidates; user can manually fix or drop
*/
export type ResolveOutcome =
| { kind: "matched"; pid: number; qty: number; identifier: string }
| { kind: "ambiguous"; identifier: string; qty: number; candidates: SearchCandidate[] }
| { kind: "unmatched"; identifier: string; qty: number };
export interface ResolveResult {
matched: Array<{ pid: number; qty: number; identifier: string }>;
ambiguous: Array<{ identifier: string; qty: number; candidates: SearchCandidate[] }>;
unmatched: Array<{ identifier: string; qty: number }>;
}

View File

@@ -1,956 +0,0 @@
import { useEffect, useMemo, useRef, useState, useTransition, useCallback, memo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Code } from "@/components/ui/code";
import * as XLSX from "xlsx";
import { toast } from "sonner";
import { X as XIcon } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
export interface CategorySummary {
category: string;
categoryPath: string;
avgTotalSold: number;
minSold: number;
maxSold: number;
}
type ParsedRow = {
product: string;
sku?: string;
categoryHint?: string;
moq?: number;
upc?: string;
};
type OrderRow = ParsedRow & {
matchedCategoryPath?: string;
matchedCategoryName?: string;
baseSuggestion?: number; // from category avg
finalQty: number; // adjusted for MOQ
};
type HeaderMap = {
// Stores generated column ids like "col-0" instead of raw header text
product?: string;
sku?: string;
categoryHint?: string;
moq?: string;
upc?: string;
};
const PRODUCT_HEADER_SYNONYMS = [
"product",
"name",
"title",
"description",
"item",
"item name",
"sku description",
"product name",
];
const SKU_HEADER_SYNONYMS = [
"sku",
"item#",
"item number",
"supplier #",
"supplier no",
"supplier_no",
"product code",
];
const CATEGORY_HEADER_SYNONYMS = [
"category",
"categories",
"line",
"collection",
"type",
];
const MOQ_HEADER_SYNONYMS = [
"moq",
"min qty",
"min. order qty",
"min order qty",
"qty per unit",
"unit qty",
"inner pack",
"case pack",
"pack",
];
const UPC_HEADER_SYNONYMS = [
"upc",
"barcode",
"bar code",
"ean",
"jan",
"upc code",
];
function normalizeHeader(h: string) {
return h.trim().toLowerCase();
}
function autoMapHeaderNames(headers: string[]): { product?: string; sku?: string; categoryHint?: string; moq?: string; upc?: string } {
const norm = headers.map((h) => normalizeHeader(h));
const findFirst = (syns: string[]) => {
for (const s of syns) {
const idx = norm.findIndex((h) => h === s || h.includes(s));
if (idx >= 0) return headers[idx];
}
return undefined;
};
return {
product: findFirst(PRODUCT_HEADER_SYNONYMS) || headers[0],
sku: findFirst(SKU_HEADER_SYNONYMS),
categoryHint: findFirst(CATEGORY_HEADER_SYNONYMS),
moq: findFirst(MOQ_HEADER_SYNONYMS),
upc: findFirst(UPC_HEADER_SYNONYMS),
};
}
function detectDelimiter(text: string): string {
// Very simple heuristic: prefer tab, then comma, then semicolon
const lines = text.split(/\r?\n/).slice(0, 5);
const counts = { "\t": 0, ",": 0, ";": 0 } as Record<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 [, 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

@@ -809,8 +809,3 @@ export function DesignerSubComponent({ row }: { row: { original: DesignerGroup }
); );
} }
// ─── Legacy exports for backward compatibility with QuickOrderBuilder ───────
// The old ForecastItem type mapped to CategoryGroup
export type ForecastItem = CategoryGroup;
export const columns = categoryColumns;
export const renderSubComponent = CategorySubComponent;

View File

@@ -7,7 +7,6 @@ import {
Tags, Tags,
PackagePlus, PackagePlus,
ShoppingBag, ShoppingBag,
Truck,
MessageCircle, MessageCircle,
LayoutDashboard, LayoutDashboard,
Percent, Percent,
@@ -18,6 +17,7 @@ import {
Mail, Mail,
Layers, Layers,
Repeat, Repeat,
ClipboardPlus,
} from "lucide-react"; } from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react"; import { IconCrystalBall } from "@tabler/icons-react";
import { import {
@@ -80,18 +80,6 @@ const inventoryItems = [
url: "/brands", url: "/brands",
permission: "access:brands" permission: "access:brands"
}, },
{
title: "Product Lines",
icon: Layers,
url: "/product-lines",
permission: "access:product_lines"
},
{
title: "Vendors",
icon: Truck,
url: "/vendors",
permission: "access:vendors"
},
{ {
title: "Purchase Orders", title: "Purchase Orders",
icon: ClipboardList, icon: ClipboardList,
@@ -106,18 +94,12 @@ const inventoryItems = [
} }
]; ];
const toolsItems = [ const buyingItems = [
{ {
title: "Discount Simulator", title: "Product Lines",
icon: Percent, icon: Layers,
url: "/discount-simulator", url: "/product-lines",
permission: "access:discount_simulator" permission: "access:product_lines"
},
{
title: "HTS Lookup",
icon: FileSearch,
url: "/hts-lookup",
permission: "access:hts_lookup"
}, },
{ {
title: "Forecasting", title: "Forecasting",
@@ -131,6 +113,21 @@ const toolsItems = [
url: "/repeat-orders", url: "/repeat-orders",
permission: "access:repeat_orders" permission: "access:repeat_orders"
}, },
{
title: "Create PO",
icon: ClipboardPlus,
url: "/create-purchase-order",
permission: "access:create_purchase_orders"
}
];
const productManagementItems = [
{
title: "Create Products",
icon: PackagePlus,
url: "/import",
permission: "access:import"
},
{ {
title: "Product Editor", title: "Product Editor",
icon: FilePenLine, icon: FilePenLine,
@@ -142,25 +139,28 @@ const toolsItems = [
icon: PenLine, icon: PenLine,
url: "/bulk-edit", url: "/bulk-edit",
permission: "access:bulk_edit" permission: "access:bulk_edit"
}, }
];
const toolsItems = [
{ {
title: "Newsletter", title: "Newsletter",
icon: Mail, icon: Mail,
url: "/newsletter", url: "/newsletter",
permission: "access:newsletter" permission: "access:newsletter"
} },
];
const productSetupItems = [
{ {
title: "Create Products", title: "Discount Simulator",
icon: PackagePlus, icon: Percent,
url: "/import", url: "/discount-simulator",
permission: "access:import" permission: "access:discount_simulator"
} },
]; {
title: "HTS Lookup",
const chatItems = [ icon: FileSearch,
url: "/hts-lookup",
permission: "access:hts_lookup"
},
{ {
title: "Chat Archive", title: "Chat Archive",
icon: MessageCircle, icon: MessageCircle,
@@ -266,6 +266,30 @@ export function AppSidebar() {
</SidebarGroup> </SidebarGroup>
)} )}
{/* Buying Section */}
{hasAccessToSection(buyingItems) && (
<SidebarGroup>
<SidebarGroupLabel>Buying</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(buyingItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{/* Product Management Section */}
{hasAccessToSection(productManagementItems) && (
<SidebarGroup>
<SidebarGroupLabel>Product Management</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(productManagementItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{/* Tools Section */} {/* Tools Section */}
{hasAccessToSection(toolsItems) && ( {hasAccessToSection(toolsItems) && (
<SidebarGroup> <SidebarGroup>
@@ -278,30 +302,6 @@ export function AppSidebar() {
</SidebarGroup> </SidebarGroup>
)} )}
{/* Product Setup Section */}
{hasAccessToSection(productSetupItems) && (
<SidebarGroup>
<SidebarGroupLabel>Product Setup</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(productSetupItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{/* Chat Section */}
{hasAccessToSection(chatItems) && (
<SidebarGroup>
<SidebarGroupLabel>Chat</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(chatItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{/* Settings Section */} {/* Settings Section */}
<Protected permission="access:settings" fallback={null}> <Protected permission="access:settings" fallback={null}>
<SidebarGroup> <SidebarGroup>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,318 @@
/**
* Create Purchase Order page.
*
* Lets the user pick a supplier and assemble a list of products via search,
* paste, or file upload, then submits the PO to the legacy PHP backend via
* the existing /apiv2 proxy. On success, shows a confirmation view with a
* link to the new PO in the legacy admin.
*
* State model:
* - supplierId → controlled string from SupplierSelector
* - lineItems[] → the working list (PoLineItem; local-only fields
* qty + moqOverride live here)
* - selectedPids: Set → checkbox state for the bulk-remove flow
* - addOpen → AddProductsDialog visibility
* - submitting → submit button spinner
* - confirmation → null while building; { poId, itemCount } after
* a successful submit
*
* Dedup is enforced server-naive: when AddProductsDialog returns a list of
* (pid, qty) pairs, we filter out pids that are already on the PO and show
* a brief toast indicating how many were skipped. The user can edit existing
* rows manually if they want to bump quantities — the dialog never mutates
* existing rows.
*/
import { useCallback, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Loader2, Plus } from "lucide-react";
import { toast } from "sonner";
import { SupplierSelector } from "@/components/create-po/SupplierSelector";
import { LineItemsTable } from "@/components/create-po/LineItemsTable";
import { PoFloatingSelectionBar } from "@/components/create-po/PoFloatingSelectionBar";
import { AddProductsDialog } from "@/components/create-po/AddProductsDialog";
import { ConfirmationView } from "@/components/create-po/ConfirmationView";
import { fetchBatchProducts } from "@/components/create-po/resolveIdentifiers";
import type { PoLineItem } from "@/components/create-po/types";
import { submitNewPurchaseOrder } from "@/services/apiv2";
export default function CreatePurchaseOrder() {
const [supplierId, setSupplierId] = useState<string | undefined>(undefined);
const [lineItems, setLineItems] = useState<PoLineItem[]>([]);
const [selectedPids, setSelectedPids] = useState<Set<number>>(new Set());
const [addOpen, setAddOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [hydrating, setHydrating] = useState(false);
const [confirmation, setConfirmation] = useState<{
poId: number;
itemCount: number;
} | null>(null);
// ---- Add products from any tab (Search/Paste/Upload) ----------------------
const handleAddProducts = useCallback(
async (items: Array<{ pid: number; qty: number }>) => {
if (items.length === 0) return;
// Dedup against existing line items — silently skip duplicates
const existing = new Set(lineItems.map((i) => i.pid));
const fresh = items.filter((i) => !existing.has(i.pid));
const skipped = items.length - fresh.length;
if (skipped > 0) {
toast.info(
skipped === 1
? "1 product already on PO"
: `${skipped} products already on PO`
);
}
if (fresh.length === 0) return;
setHydrating(true);
try {
const qtyByPid = new Map(fresh.map((i) => [i.pid, i.qty]));
const hydrated = await fetchBatchProducts(
fresh.map((i) => i.pid),
qtyByPid
);
if (hydrated.length === 0) {
toast.error("Could not load product details");
return;
}
// Append in the order returned by the backend (which preserves request order)
setLineItems((prev) => [...prev, ...hydrated]);
toast.success(
hydrated.length === 1
? "1 product added"
: `${hydrated.length} products added`
);
} catch (e) {
console.error(e);
toast.error("Failed to load product details");
} finally {
setHydrating(false);
}
},
[lineItems]
);
// ---- Row mutation handlers (passed to LineItemsTable) --------------------
const handleToggleSelect = useCallback((pid: number) => {
setSelectedPids((prev) => {
const next = new Set(prev);
if (next.has(pid)) next.delete(pid);
else next.add(pid);
return next;
});
}, []);
const handleToggleSelectAll = useCallback(
(selectAll: boolean) => {
if (selectAll) {
setSelectedPids(new Set(lineItems.map((i) => i.pid)));
} else {
setSelectedPids(new Set());
}
},
[lineItems]
);
const handleChangeQty = useCallback((pid: number, qty: number) => {
setLineItems((prev) =>
prev.map((i) => (i.pid === pid ? { ...i, qty } : i))
);
}, []);
const handleChangeMoqOverride = useCallback(
(pid: number, moq: number | undefined) => {
setLineItems((prev) =>
prev.map((i) => (i.pid === pid ? { ...i, moqOverride: moq } : i))
);
},
[]
);
const handleRemoveRow = useCallback((pid: number) => {
setLineItems((prev) => prev.filter((i) => i.pid !== pid));
setSelectedPids((prev) => {
const next = new Set(prev);
next.delete(pid);
return next;
});
}, []);
const handleBulkRemove = useCallback(() => {
setLineItems((prev) => prev.filter((i) => !selectedPids.has(i.pid)));
setSelectedPids(new Set());
toast.success(
selectedPids.size === 1
? "1 product removed"
: `${selectedPids.size} products removed`
);
}, [selectedPids]);
const handleClearSelection = useCallback(() => {
setSelectedPids(new Set());
}, []);
// ---- Submit ---------------------------------------------------------------
const handleSubmit = useCallback(async () => {
if (!supplierId) {
toast.error("Pick a supplier first");
return;
}
const validItems = lineItems
.filter((i) => i.qty > 0)
.map((i) => ({ pid: i.pid, qty: i.qty }));
if (validItems.length === 0) {
toast.error("Add at least one product with a positive quantity");
return;
}
setSubmitting(true);
try {
const res = await submitNewPurchaseOrder({ supplierId, items: validItems });
if (!res.success || !res.poId) {
const msg =
(typeof res.error === "string" && res.error) ||
res.message ||
"PO submission failed";
toast.error(msg);
return;
}
setConfirmation({ poId: res.poId, itemCount: validItems.length });
} catch (e) {
console.error(e);
toast.error(e instanceof Error ? e.message : "PO submission failed");
} finally {
setSubmitting(false);
}
}, [supplierId, lineItems]);
// ---- Reset for "Create another" -------------------------------------------
const handleCreateAnother = useCallback(() => {
setSupplierId(undefined);
setLineItems([]);
setSelectedPids(new Set());
setConfirmation(null);
}, []);
// ---- Confirmation view (post-submit) --------------------------------------
if (confirmation) {
return (
<div className="container mx-auto p-6">
<ConfirmationView
poId={confirmation.poId}
itemCount={confirmation.itemCount}
onCreateAnother={handleCreateAnother}
/>
</div>
);
}
// ---- Builder view ---------------------------------------------------------
const totalQty = lineItems.reduce((sum, i) => sum + (i.qty > 0 ? i.qty : 0), 0);
const totalCost = lineItems.reduce(
(sum, i) =>
sum + (i.qty > 0 ? i.qty * (i.current_cost_price ?? 0) : 0),
0
);
return (
<div className="container mx-auto p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Create Purchase Order</h1>
</div>
<Card>
<CardHeader>
<CardTitle>Supplier</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-md">
<SupplierSelector value={supplierId} onChange={setSupplierId} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>
Line Items
{lineItems.length > 0 && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
({lineItems.length} {lineItems.length === 1 ? "product" : "products"} ·{" "}
{totalQty.toLocaleString()} units · $
{totalCost.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
)
</span>
)}
</CardTitle>
<Button onClick={() => setAddOpen(true)} disabled={hydrating}>
{hydrating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Add products
</>
)}
</Button>
</CardHeader>
<CardContent>
<LineItemsTable
items={lineItems}
selectedPids={selectedPids}
supplierId={supplierId ? Number(supplierId) : undefined}
onToggleSelect={handleToggleSelect}
onToggleSelectAll={handleToggleSelectAll}
onChangeQty={handleChangeQty}
onChangeMoqOverride={handleChangeMoqOverride}
onRemove={handleRemoveRow}
/>
</CardContent>
</Card>
<div className="flex justify-end">
<Button
size="lg"
onClick={handleSubmit}
disabled={submitting || lineItems.length === 0 || !supplierId}
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Submitting
</>
) : (
<>
Create purchase order
</>
)}
</Button>
</div>
<AddProductsDialog
open={addOpen}
onOpenChange={setAddOpen}
existingPids={new Set(lineItems.map((i) => i.pid))}
onAdd={(result) => {
void handleAddProducts(result.items);
}}
/>
<PoFloatingSelectionBar
selectedCount={selectedPids.size}
onClear={handleClearSelection}
onRemove={handleBulkRemove}
/>
</div>
);
}

View File

@@ -37,7 +37,6 @@ import { Input } from "@/components/ui/input";
import { X, Layers, FolderTree, TrendingUp, Palette } from "lucide-react"; import { X, Layers, FolderTree, TrendingUp, Palette } from "lucide-react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { QuickOrderBuilder } from "@/components/forecasting/QuickOrderBuilder";
type GroupMode = "line" | "category" | "designer"; type GroupMode = "line" | "category" | "designer";
@@ -211,20 +210,6 @@ export default function Forecasting() {
state: { sorting: designerSorting }, state: { sorting: designerSorting },
}); });
// ─── QuickOrderBuilder data (always category-based) ─────────────────────
const qobCategories = useMemo(
() =>
categoryGroups.map((c) => ({
category: c.category,
categoryPath: c.categoryPath,
avgTotalSold: c.avgLifetimeSales,
minSold: c.minSales,
maxSold: c.maxSales,
})),
[categoryGroups]
);
// ─── Summary stats ───────────────────────────────────────────────────── // ─── Summary stats ─────────────────────────────────────────────────────
const totalProducts = filteredProducts.length; const totalProducts = filteredProducts.length;
@@ -409,9 +394,6 @@ export default function Forecasting() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Quick Order Builder (unchanged interface) */}
<QuickOrderBuilder brand={selectedBrand} categories={qobCategories} />
</div> </div>
); );
} }

View File

@@ -1,506 +0,0 @@
import { useState, useMemo, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { motion } from "framer-motion";
import config from "../config";
import { Skeleton } from "@/components/ui/skeleton";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
// Matches backend COLUMN_MAP keys for sorting
type VendorSortableColumns =
| 'vendorName' | 'productCount' | 'activeProductCount' | 'currentStockUnits'
| 'currentStockCost' | 'onOrderUnits' | 'onOrderCost' | 'avgLeadTimeDays'
| 'revenue_30d' | 'profit_30d' | 'avg_margin_30d' | 'po_count_365d'
| 'salesGrowth30dVsPrev' | 'revenueGrowth30dVsPrev' | 'status';
interface VendorMetric {
vendor_id: string | number;
vendor_name: string;
last_calculated: string;
product_count: number;
active_product_count: number;
replenishable_product_count: number;
current_stock_units: number;
current_stock_cost: string | number;
current_stock_retail: string | number;
on_order_units: number;
on_order_cost: string | number;
po_count_365d: number;
avg_lead_time_days: number | null;
sales_7d: number;
revenue_7d: string | number;
sales_30d: number;
revenue_30d: string | number;
profit_30d: string | number;
cogs_30d: string | number;
sales_365d: number;
revenue_365d: string | number;
lifetime_sales: number;
lifetime_revenue: string | number;
avg_margin_30d: string | number | null;
// Growth metrics
sales_growth_30d_vs_prev: string | number | null;
revenue_growth_30d_vs_prev: string | number | null;
// New fields added by vendorsAggregate
status: string;
vendor_status: string;
cost_metrics_30d: {
avg_unit_cost: number;
total_spend: number;
order_count: number;
};
// Camel case versions
vendorId: string | number;
vendorName: string;
lastCalculated: string;
productCount: number;
activeProductCount: number;
replenishableProductCount: number;
currentStockUnits: number;
currentStockCost: string | number;
currentStockRetail: string | number;
onOrderUnits: number;
onOrderCost: string | number;
poCount_365d: number;
avgLeadTimeDays: number | null;
lifetimeSales: number;
lifetimeRevenue: string | number;
avgMargin_30d: string | number | null;
salesGrowth30dVsPrev: string | number | null;
revenueGrowth30dVsPrev: string | number | null;
}
// Define response type to avoid type errors
interface VendorResponse {
vendors: VendorMetric[];
pagination: {
total: number;
pages: number;
currentPage: number;
limit: number;
};
}
interface VendorFilterOptions {
statuses: string[];
}
interface VendorStats {
totalVendors: number;
activeVendors: number;
totalActiveProducts: number;
totalValue: number;
totalOnOrderValue: number;
avgLeadTime: number;
}
interface VendorFilters {
search: string;
status: string;
showInactive: boolean;
}
const ITEMS_PER_PAGE = 50;
const formatCurrency = (value: number | string | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(parsed);
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(value);
};
const formatNumber = (value: number | string | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return parsed.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return value.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
const formatPercentage = (value: number | string | null | undefined, digits = 1): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return `${parsed.toFixed(digits)}%`;
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return `${value.toFixed(digits)}%`;
};
const formatDays = (value: number | string | null | undefined, digits = 1): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return `${parsed.toFixed(digits)} days`;
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return `${value.toFixed(digits)} days`;
};
// Growth formatting with color coding
const formatGrowth = (value: number | string | null | undefined, digits = 1) => {
if (value == null) return <span className="text-muted-foreground">N/A</span>;
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(numValue)) return <span className="text-muted-foreground">N/A</span>;
const formatted = `${numValue >= 0 ? '+' : ''}${numValue.toFixed(digits)}%`;
const colorClass = numValue >= 0 ? 'text-green-600' : 'text-red-600';
return <span className={colorClass}>{formatted}</span>;
};
const getStatusVariant = (status: string): "default" | "secondary" | "outline" | "destructive" => {
switch (status) {
case 'active':
return 'default';
case 'inactive':
return 'secondary';
case 'discontinued':
return 'destructive';
default:
return 'outline';
}
};
export function Vendors() {
const [page, setPage] = useState(1);
const [limit] = useState(ITEMS_PER_PAGE);
const [sortColumn, setSortColumn] = useState<VendorSortableColumns>("vendorName");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [filters, setFilters] = useState<VendorFilters>({
search: "",
status: "all",
showInactive: false, // Default to hiding vendors with 0 active products
});
// --- Data Fetching ---
const queryParams = useMemo(() => {
const params = new URLSearchParams();
params.set('page', page.toString());
params.set('limit', limit.toString());
params.set('sort', sortColumn);
params.set('order', sortDirection);
if (filters.search) {
params.set('vendorName_ilike', filters.search); // Filter by name
}
if (filters.status !== 'all') {
params.set('status', filters.status); // Filter by status
}
if (!filters.showInactive) {
params.set('activeProductCount_gt', '0'); // Only show vendors with active products
}
return params;
}, [page, limit, sortColumn, sortDirection, filters]);
const { data: listData, isLoading: isLoadingList, error: listError } = useQuery<VendorResponse, Error>({
queryKey: ['vendors', queryParams.toString()],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/vendors-aggregate?${queryParams.toString()}`, {
credentials: 'include'
});
if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
return response.json();
},
placeholderData: (prev) => prev, // Modern replacement for keepPreviousData
});
const { data: statsData, isLoading: isLoadingStats } = useQuery<VendorStats, Error>({
queryKey: ['vendorsStats'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/vendors-aggregate/stats`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch vendor stats");
return response.json();
},
});
// Fetch filter options
const { data: filterOptions } = useQuery<VendorFilterOptions, Error>({
queryKey: ['vendorsFilterOptions'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/vendors-aggregate/filter-options`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch filter options");
return response.json();
},
});
// --- Event Handlers ---
const handleSort = useCallback((column: VendorSortableColumns) => {
setSortDirection(prev => (sortColumn === column && prev === "asc" ? "desc" : "asc"));
setSortColumn(column);
setPage(1);
}, [sortColumn]);
const handleFilterChange = useCallback((filterName: keyof VendorFilters, value: string | boolean) => {
setFilters(prev => ({ ...prev, [filterName]: value }));
setPage(1);
}, []);
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= (listData?.pagination.pages ?? 1)) {
setPage(newPage);
}
};
// --- Derived Data ---
const vendors = listData?.vendors ?? [];
const pagination = listData?.pagination;
const totalPages = pagination?.pages ?? 0;
// --- Rendering ---
return (
<motion.div
layout
transition={{ layout: { duration: 0.15, ease: [0.4, 0, 0.2, 1] } }}
className="container mx-auto py-6 space-y-4"
>
{/* Header */}
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Vendors</h1>
<div className="text-sm text-muted-foreground">
{isLoadingList && !pagination ? 'Loading...' : `${formatNumber(pagination?.total)} vendors`}
</div>
</motion.div>
{/* Stats Cards */}
<motion.div layout="preserve-aspect" transition={{ duration: 0.15 }} className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Vendors</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalVendors)}</div>}
<p className="text-xs text-muted-foreground">
{isLoadingStats ? <Skeleton className="h-4 w-28" /> :
`${formatNumber(statsData?.activeVendors)} active`}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Stock Value</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-28" /> : <div className="text-2xl font-bold">{formatCurrency(statsData?.totalValue)}</div>}
<p className="text-xs text-muted-foreground">
Current cost value
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Value On Order</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-28" /> : <div className="text-2xl font-bold">{formatCurrency(statsData?.totalOnOrderValue)}</div>}
<p className="text-xs text-muted-foreground">
Total cost on open POs
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Lead Time</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-20" /> : <div className="text-2xl font-bold">{formatDays(statsData?.avgLeadTime)}</div>}
<p className="text-xs text-muted-foreground">
Average across vendors
</p>
</CardContent>
</Card>
</motion.div>
{/* Filter Controls */}
<div className="flex flex-wrap items-center space-y-2 sm:space-y-0 sm:space-x-2">
<Input
placeholder="Search vendors..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
className="w-full sm:w-[250px]"
/>
<Select
value={filters.status}
onValueChange={(value) => handleFilterChange('status', value)}
>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{filterOptions?.statuses?.map((status) => (
<SelectItem key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center space-x-2 ml-auto">
<Switch
id="show-inactive-vendors"
checked={filters.showInactive}
onCheckedChange={(checked) => handleFilterChange('showInactive', checked)}
/>
<Label htmlFor="show-inactive-vendors">Show vendors with no active products</Label>
</div>
</div>
{/* Data Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead onClick={() => handleSort("vendorName")} className="cursor-pointer">Vendor</TableHead>
<TableHead onClick={() => handleSort("activeProductCount")} className="cursor-pointer text-right">Active Prod.</TableHead>
<TableHead onClick={() => handleSort("currentStockCost")} className="cursor-pointer text-right">Stock Value</TableHead>
<TableHead onClick={() => handleSort("onOrderUnits")} className="cursor-pointer text-right">On Order (Units)</TableHead>
<TableHead onClick={() => handleSort("onOrderCost")} className="cursor-pointer text-right">On Order (Cost)</TableHead>
<TableHead onClick={() => handleSort("avgLeadTimeDays")} className="cursor-pointer text-right">Avg Lead Time</TableHead>
<TableHead onClick={() => handleSort("revenue_30d")} className="cursor-pointer text-right">Revenue (30d)</TableHead>
<TableHead onClick={() => handleSort("profit_30d")} className="cursor-pointer text-right">Profit (30d)</TableHead>
<TableHead onClick={() => handleSort("avg_margin_30d")} className="cursor-pointer text-right">Margin (30d)</TableHead>
<TableHead onClick={() => handleSort("po_count_365d")} className="cursor-pointer text-right">POs (365d)</TableHead>
<TableHead onClick={() => handleSort("salesGrowth30dVsPrev")} className="cursor-pointer text-right">Sales Growth</TableHead>
<TableHead onClick={() => handleSort("revenueGrowth30dVsPrev")} className="cursor-pointer text-right">Revenue Growth</TableHead>
<TableHead onClick={() => handleSort("status")} className="cursor-pointer text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoadingList && !listData ? (
Array.from({ length: 5 }).map((_, i) => ( // Skeleton rows
<TableRow key={`skel-${i}`}>
<TableCell><Skeleton className="h-5 w-40" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
</TableRow>
))
) : listError ? (
<TableRow>
<TableCell colSpan={13} className="text-center py-8 text-destructive">
Error loading vendors: {listError.message}
</TableCell>
</TableRow>
) : vendors.length === 0 ? (
<TableRow>
<TableCell colSpan={13} className="text-center py-8 text-muted-foreground">
No vendors found matching your criteria.
</TableCell>
</TableRow>
) : (
vendors.map((vendor: VendorMetric) => (
<TableRow key={vendor.vendor_id} className={vendor.active_product_count === 0 ? "opacity-60" : ""}>
<TableCell className="font-medium">{vendor.vendor_name}</TableCell>
<TableCell className="text-right">{formatNumber(vendor.active_product_count || vendor.activeProductCount)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.current_stock_cost as number)}</TableCell>
<TableCell className="text-right">{formatNumber(vendor.on_order_units || vendor.onOrderUnits)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.on_order_cost as number)}</TableCell>
<TableCell className="text-right">{formatDays(vendor.avg_lead_time_days || vendor.avgLeadTimeDays)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.revenue_30d as number)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.profit_30d as number)}</TableCell>
<TableCell className="text-right">{formatPercentage(vendor.avg_margin_30d as number)}</TableCell>
<TableCell className="text-right">{formatNumber(vendor.po_count_365d || vendor.poCount_365d)}</TableCell>
<TableCell className="text-right">{formatGrowth(vendor.sales_growth_30d_vs_prev)}</TableCell>
<TableCell className="text-right">{formatGrowth(vendor.revenue_growth_30d_vs_prev)}</TableCell>
<TableCell className="text-right">
<Badge variant={getStatusVariant(vendor.status)}>
{vendor.status || 'Unknown'}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
{totalPages > 1 && pagination && (
<div className="flex justify-center">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage - 1); }}
aria-disabled={pagination.currentPage === 1}
className={pagination.currentPage === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{[...Array(totalPages)].map((_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(i + 1); }}
isActive={pagination.currentPage === i + 1}
>
{i + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage + 1); }}
aria-disabled={pagination.currentPage >= totalPages}
className={pagination.currentPage >= totalPages ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</motion.div>
);
}
export default Vendors;

View File

@@ -12,6 +12,24 @@ export interface SubmitNewProductsResponse {
error?: unknown; error?: unknown;
} }
export interface PoLineItemSubmit {
pid: number;
qty: number;
}
export interface SubmitNewPurchaseOrderArgs {
supplierId: number | string;
items: PoLineItemSubmit[];
}
export interface SubmitNewPurchaseOrderResponse {
success: boolean;
poId?: number;
message?: string;
error?: unknown;
raw?: unknown;
}
export interface CreateProductCategoryArgs { export interface CreateProductCategoryArgs {
masterCatId: string | number; masterCatId: string | number;
name: string; name: string;
@@ -204,3 +222,103 @@ export async function createProductCategory({
return normalizedResponse; return normalizedResponse;
} }
/**
* Creates a new purchase order on the legacy PHP backend.
*
* Mirrors the auth/serialization pattern of `submitNewProducts`:
* - URL-encoded body
* - cookie-based auth (`credentials: 'include'`)
* - HTML response → "Backend authentication required" guard
*
* The endpoint accepts a JSON array of `{pid, qty}` items in the `items` body
* field, with the supplier ID as a path parameter. Returns `{po_id: number}`
* on success. Designed to be called from anywhere in the app — not just the
* Create PO page.
*/
export async function submitNewPurchaseOrder({
supplierId,
items,
}: SubmitNewPurchaseOrderArgs): Promise<SubmitNewPurchaseOrderResponse> {
if (!supplierId) {
throw new Error("supplierId is required");
}
if (!Array.isArray(items) || items.length === 0) {
throw new Error("At least one item is required");
}
const cleanItems = items
.map((i) => ({ pid: Number(i.pid), qty: Number(i.qty) }))
.filter((i) => Number.isInteger(i.pid) && i.pid > 0 && Number.isFinite(i.qty) && i.qty > 0);
if (cleanItems.length === 0) {
throw new Error("No valid items to submit");
}
const targetUrl = `/apiv2/po/new/${encodeURIComponent(String(supplierId))}`;
const payload = new URLSearchParams();
payload.append("items", JSON.stringify(cleanItems));
let response: Response;
try {
response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
},
body: payload,
credentials: "include",
});
} catch (networkError) {
throw new Error(networkError instanceof Error ? networkError.message : "Network request failed");
}
const rawBody = await response.text();
if (isHtmlResponse(rawBody)) {
throw new Error("Backend authentication required. Please ensure you are logged into the backend system.");
}
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
throw new Error(`Unexpected response from backend (${response.status}).`);
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Empty response from backend");
}
const record = parsed as Record<string, unknown>;
// The PHP /apiv2 backend wraps successful responses in an envelope:
// {"success": true, "data": { "po_id": "32705" }}
// po_id can appear either at the top level (legacy shape) OR nested
// inside `data` (current shape), and the value is sometimes a string
// (e.g. "32705") rather than a number. We look in both locations and
// coerce to Number to handle both cases.
const data =
record.data && typeof record.data === "object"
? (record.data as Record<string, unknown>)
: {};
const rawPoId = record.po_id ?? record.poId ?? data.po_id ?? data.poId;
const poIdNum = typeof rawPoId === "number" ? rawPoId : Number(rawPoId);
const hasValidPoId = Number.isFinite(poIdNum) && poIdNum > 0;
// Trust the backend's own `success` flag when it's present — it's the
// authoritative signal. Fall back to "HTTP OK + valid po_id" for older
// response shapes that don't include the flag.
const backendSuccess =
record.success === true ||
record.success === "true" ||
record.success === 1;
const success = response.ok && hasValidPoId && (backendSuccess || record.success === undefined);
return {
success,
poId: success ? poIdNum : undefined,
message: typeof record.message === "string" ? record.message : undefined,
error: record.error ?? record.errors ?? record.error_msg,
raw: parsed,
};
}

View File

@@ -40,6 +40,19 @@ export const formatDate = (dateString: string | null | undefined): string => {
} }
}; };
export const formatDateShort = (dateString: string | null | undefined): string => {
if (!dateString) return 'N/A';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'numeric',
day: 'numeric'
});
} catch (e) {
return 'Invalid Date';
}
};
export const formatBoolean = (value: boolean | null | undefined): string => { export const formatBoolean = (value: boolean | null | undefined): string => {
if (value == null) return 'N/A'; if (value == null) return 'N/A';
return value ? 'Yes' : 'No'; return value ? 'Yes' : 'No';

File diff suppressed because one or more lines are too long