Add create PO page, remove old quick order builder from forecasting page, reorder sidebar, combine brands/vendors pages
This commit is contained in:
0
inventory-server/chat/export-chat-data.sh
Executable file → Normal file
0
inventory-server/chat/export-chat-data.sh
Executable file → Normal file
0
inventory-server/chat/verify-migration.js
Executable file → Normal file
0
inventory-server/chat/verify-migration.js
Executable file → Normal 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
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -23,11 +23,11 @@ const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ defau
|
||||
const Forecasting = lazy(() => import('./pages/Forecasting'));
|
||||
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
|
||||
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 ProductLines = lazy(() => import('./pages/ProductLines'));
|
||||
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
|
||||
const CreatePurchaseOrder = lazy(() => import('./pages/CreatePurchaseOrder'));
|
||||
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
|
||||
const Newsletter = lazy(() => import('./pages/Newsletter'));
|
||||
const RepeatOrders = lazy(() => import('./pages/RepeatOrders'));
|
||||
@@ -137,13 +137,6 @@ function App() {
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/vendors" element={
|
||||
<Protected page="vendors">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Vendors />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/brands" element={
|
||||
<Protected page="brands">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
@@ -200,6 +193,13 @@ function App() {
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/create-purchase-order" element={
|
||||
<Protected page="create_purchase_orders">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<CreatePurchaseOrder />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
{/* Always loaded settings */}
|
||||
<Route path="/settings" element={
|
||||
|
||||
@@ -10,7 +10,7 @@ const PAGES = [
|
||||
{ path: "/overview", permission: "access:overview" },
|
||||
{ path: "/products", permission: "access:products" },
|
||||
{ path: "/categories", permission: "access:categories" },
|
||||
{ path: "/vendors", permission: "access:vendors" },
|
||||
{ path: "/brands", permission: "access:brands" },
|
||||
{ path: "/purchase-orders", permission: "access:purchase_orders" },
|
||||
{ path: "/analytics", permission: "access:analytics" },
|
||||
{ path: "/discount-simulator", permission: "access:discount_simulator" },
|
||||
|
||||
@@ -129,8 +129,7 @@ Admin users automatically have all permissions.
|
||||
| `access:overview` | Access to Overview page |
|
||||
| `access:products` | Access to Products page |
|
||||
| `access:categories` | Access to Categories page |
|
||||
| `access:brands` | Access to Brands page |
|
||||
| `access:vendors` | Access to Vendors page |
|
||||
| `access:brands` | Access to Brands & Vendors page |
|
||||
| `access:purchase_orders` | Access to Purchase Orders page |
|
||||
| `access:analytics` | Access to Analytics page |
|
||||
| `access:discount_simulator` | Access to Discount Simulator page |
|
||||
|
||||
787
inventory/src/components/create-po/AddProductsDialog.tsx
Normal file
787
inventory/src/components/create-po/AddProductsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
inventory/src/components/create-po/ConfirmationView.tsx
Normal file
57
inventory/src/components/create-po/ConfirmationView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
354
inventory/src/components/create-po/LineItemsTable.tsx
Normal file
354
inventory/src/components/create-po/LineItemsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
251
inventory/src/components/create-po/ReviewMatchesDialog.tsx
Normal file
251
inventory/src/components/create-po/ReviewMatchesDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
inventory/src/components/create-po/SupplierSelector.tsx
Normal file
64
inventory/src/components/create-po/SupplierSelector.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
13
inventory/src/components/create-po/constants.ts
Normal file
13
inventory/src/components/create-po/constants.ts
Normal 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;
|
||||
353
inventory/src/components/create-po/parseSpreadsheet.ts
Normal file
353
inventory/src/components/create-po/parseSpreadsheet.ts
Normal 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;
|
||||
}
|
||||
137
inventory/src/components/create-po/resolveIdentifiers.ts
Normal file
137
inventory/src/components/create-po/resolveIdentifiers.ts
Normal 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;
|
||||
}
|
||||
76
inventory/src/components/create-po/types.ts
Normal file
76
inventory/src/components/create-po/types.ts
Normal 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 }>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Tags,
|
||||
PackagePlus,
|
||||
ShoppingBag,
|
||||
Truck,
|
||||
MessageCircle,
|
||||
LayoutDashboard,
|
||||
Percent,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
Mail,
|
||||
Layers,
|
||||
Repeat,
|
||||
ClipboardPlus,
|
||||
} from "lucide-react";
|
||||
import { IconCrystalBall } from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -80,18 +80,6 @@ const inventoryItems = [
|
||||
url: "/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",
|
||||
icon: ClipboardList,
|
||||
@@ -106,18 +94,12 @@ const inventoryItems = [
|
||||
}
|
||||
];
|
||||
|
||||
const toolsItems = [
|
||||
const buyingItems = [
|
||||
{
|
||||
title: "Discount Simulator",
|
||||
icon: Percent,
|
||||
url: "/discount-simulator",
|
||||
permission: "access:discount_simulator"
|
||||
},
|
||||
{
|
||||
title: "HTS Lookup",
|
||||
icon: FileSearch,
|
||||
url: "/hts-lookup",
|
||||
permission: "access:hts_lookup"
|
||||
title: "Product Lines",
|
||||
icon: Layers,
|
||||
url: "/product-lines",
|
||||
permission: "access:product_lines"
|
||||
},
|
||||
{
|
||||
title: "Forecasting",
|
||||
@@ -131,6 +113,21 @@ const toolsItems = [
|
||||
url: "/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",
|
||||
icon: FilePenLine,
|
||||
@@ -142,25 +139,28 @@ const toolsItems = [
|
||||
icon: PenLine,
|
||||
url: "/bulk-edit",
|
||||
permission: "access:bulk_edit"
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
const toolsItems = [
|
||||
{
|
||||
title: "Newsletter",
|
||||
icon: Mail,
|
||||
url: "/newsletter",
|
||||
permission: "access:newsletter"
|
||||
}
|
||||
];
|
||||
|
||||
const productSetupItems = [
|
||||
},
|
||||
{
|
||||
title: "Create Products",
|
||||
icon: PackagePlus,
|
||||
url: "/import",
|
||||
permission: "access:import"
|
||||
}
|
||||
];
|
||||
|
||||
const chatItems = [
|
||||
title: "Discount Simulator",
|
||||
icon: Percent,
|
||||
url: "/discount-simulator",
|
||||
permission: "access:discount_simulator"
|
||||
},
|
||||
{
|
||||
title: "HTS Lookup",
|
||||
icon: FileSearch,
|
||||
url: "/hts-lookup",
|
||||
permission: "access:hts_lookup"
|
||||
},
|
||||
{
|
||||
title: "Chat Archive",
|
||||
icon: MessageCircle,
|
||||
@@ -266,6 +266,30 @@ export function AppSidebar() {
|
||||
</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 */}
|
||||
{hasAccessToSection(toolsItems) && (
|
||||
<SidebarGroup>
|
||||
@@ -278,30 +302,6 @@ export function AppSidebar() {
|
||||
</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 */}
|
||||
<Protected permission="access:settings" fallback={null}>
|
||||
<SidebarGroup>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
318
inventory/src/pages/CreatePurchaseOrder.tsx
Normal file
318
inventory/src/pages/CreatePurchaseOrder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,6 @@ import { Input } from "@/components/ui/input";
|
||||
import { X, Layers, FolderTree, TrendingUp, Palette } from "lucide-react";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { QuickOrderBuilder } from "@/components/forecasting/QuickOrderBuilder";
|
||||
|
||||
type GroupMode = "line" | "category" | "designer";
|
||||
|
||||
@@ -211,20 +210,6 @@ export default function Forecasting() {
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
const totalProducts = filteredProducts.length;
|
||||
@@ -409,9 +394,6 @@ export default function Forecasting() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Order Builder (unchanged interface) */}
|
||||
<QuickOrderBuilder brand={selectedBrand} categories={qobCategories} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -12,6 +12,24 @@ export interface SubmitNewProductsResponse {
|
||||
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 {
|
||||
masterCatId: string | number;
|
||||
name: string;
|
||||
@@ -204,3 +222,103 @@ export async function createProductCategory({
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
if (value == null) return 'N/A';
|
||||
return value ? 'Yes' : 'No';
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user