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
|
// Get a single product
|
||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ defau
|
|||||||
const Forecasting = lazy(() => import('./pages/Forecasting'));
|
const Forecasting = lazy(() => import('./pages/Forecasting'));
|
||||||
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
|
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
|
||||||
const HtsLookup = lazy(() => import('./pages/HtsLookup'));
|
const HtsLookup = lazy(() => import('./pages/HtsLookup'));
|
||||||
const Vendors = lazy(() => import('./pages/Vendors'));
|
|
||||||
const Categories = lazy(() => import('./pages/Categories'));
|
const Categories = lazy(() => import('./pages/Categories'));
|
||||||
const Brands = lazy(() => import('./pages/Brands'));
|
const Brands = lazy(() => import('./pages/Brands'));
|
||||||
const ProductLines = lazy(() => import('./pages/ProductLines'));
|
const ProductLines = lazy(() => import('./pages/ProductLines'));
|
||||||
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
|
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
|
||||||
|
const CreatePurchaseOrder = lazy(() => import('./pages/CreatePurchaseOrder'));
|
||||||
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
|
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
|
||||||
const Newsletter = lazy(() => import('./pages/Newsletter'));
|
const Newsletter = lazy(() => import('./pages/Newsletter'));
|
||||||
const RepeatOrders = lazy(() => import('./pages/RepeatOrders'));
|
const RepeatOrders = lazy(() => import('./pages/RepeatOrders'));
|
||||||
@@ -137,13 +137,6 @@ function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Protected>
|
</Protected>
|
||||||
} />
|
} />
|
||||||
<Route path="/vendors" element={
|
|
||||||
<Protected page="vendors">
|
|
||||||
<Suspense fallback={<PageLoading />}>
|
|
||||||
<Vendors />
|
|
||||||
</Suspense>
|
|
||||||
</Protected>
|
|
||||||
} />
|
|
||||||
<Route path="/brands" element={
|
<Route path="/brands" element={
|
||||||
<Protected page="brands">
|
<Protected page="brands">
|
||||||
<Suspense fallback={<PageLoading />}>
|
<Suspense fallback={<PageLoading />}>
|
||||||
@@ -200,6 +193,13 @@ function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Protected>
|
</Protected>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/create-purchase-order" element={
|
||||||
|
<Protected page="create_purchase_orders">
|
||||||
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
<CreatePurchaseOrder />
|
||||||
|
</Suspense>
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Always loaded settings */}
|
{/* Always loaded settings */}
|
||||||
<Route path="/settings" element={
|
<Route path="/settings" element={
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const PAGES = [
|
|||||||
{ path: "/overview", permission: "access:overview" },
|
{ path: "/overview", permission: "access:overview" },
|
||||||
{ path: "/products", permission: "access:products" },
|
{ path: "/products", permission: "access:products" },
|
||||||
{ path: "/categories", permission: "access:categories" },
|
{ path: "/categories", permission: "access:categories" },
|
||||||
{ path: "/vendors", permission: "access:vendors" },
|
{ path: "/brands", permission: "access:brands" },
|
||||||
{ path: "/purchase-orders", permission: "access:purchase_orders" },
|
{ path: "/purchase-orders", permission: "access:purchase_orders" },
|
||||||
{ path: "/analytics", permission: "access:analytics" },
|
{ path: "/analytics", permission: "access:analytics" },
|
||||||
{ path: "/discount-simulator", permission: "access:discount_simulator" },
|
{ path: "/discount-simulator", permission: "access:discount_simulator" },
|
||||||
|
|||||||
@@ -129,8 +129,7 @@ Admin users automatically have all permissions.
|
|||||||
| `access:overview` | Access to Overview page |
|
| `access:overview` | Access to Overview page |
|
||||||
| `access:products` | Access to Products page |
|
| `access:products` | Access to Products page |
|
||||||
| `access:categories` | Access to Categories page |
|
| `access:categories` | Access to Categories page |
|
||||||
| `access:brands` | Access to Brands page |
|
| `access:brands` | Access to Brands & Vendors page |
|
||||||
| `access:vendors` | Access to Vendors page |
|
|
||||||
| `access:purchase_orders` | Access to Purchase Orders page |
|
| `access:purchase_orders` | Access to Purchase Orders page |
|
||||||
| `access:analytics` | Access to Analytics page |
|
| `access:analytics` | Access to Analytics page |
|
||||||
| `access:discount_simulator` | Access to Discount Simulator page |
|
| `access:discount_simulator` | Access to Discount Simulator page |
|
||||||
|
|||||||
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,
|
Tags,
|
||||||
PackagePlus,
|
PackagePlus,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
Truck,
|
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Percent,
|
Percent,
|
||||||
@@ -18,6 +17,7 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
Layers,
|
Layers,
|
||||||
Repeat,
|
Repeat,
|
||||||
|
ClipboardPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -80,18 +80,6 @@ const inventoryItems = [
|
|||||||
url: "/brands",
|
url: "/brands",
|
||||||
permission: "access:brands"
|
permission: "access:brands"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Product Lines",
|
|
||||||
icon: Layers,
|
|
||||||
url: "/product-lines",
|
|
||||||
permission: "access:product_lines"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Vendors",
|
|
||||||
icon: Truck,
|
|
||||||
url: "/vendors",
|
|
||||||
permission: "access:vendors"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Purchase Orders",
|
title: "Purchase Orders",
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
@@ -106,18 +94,12 @@ const inventoryItems = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const toolsItems = [
|
const buyingItems = [
|
||||||
{
|
{
|
||||||
title: "Discount Simulator",
|
title: "Product Lines",
|
||||||
icon: Percent,
|
icon: Layers,
|
||||||
url: "/discount-simulator",
|
url: "/product-lines",
|
||||||
permission: "access:discount_simulator"
|
permission: "access:product_lines"
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "HTS Lookup",
|
|
||||||
icon: FileSearch,
|
|
||||||
url: "/hts-lookup",
|
|
||||||
permission: "access:hts_lookup"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Forecasting",
|
title: "Forecasting",
|
||||||
@@ -131,6 +113,21 @@ const toolsItems = [
|
|||||||
url: "/repeat-orders",
|
url: "/repeat-orders",
|
||||||
permission: "access:repeat_orders"
|
permission: "access:repeat_orders"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Create PO",
|
||||||
|
icon: ClipboardPlus,
|
||||||
|
url: "/create-purchase-order",
|
||||||
|
permission: "access:create_purchase_orders"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const productManagementItems = [
|
||||||
|
{
|
||||||
|
title: "Create Products",
|
||||||
|
icon: PackagePlus,
|
||||||
|
url: "/import",
|
||||||
|
permission: "access:import"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Product Editor",
|
title: "Product Editor",
|
||||||
icon: FilePenLine,
|
icon: FilePenLine,
|
||||||
@@ -142,25 +139,28 @@ const toolsItems = [
|
|||||||
icon: PenLine,
|
icon: PenLine,
|
||||||
url: "/bulk-edit",
|
url: "/bulk-edit",
|
||||||
permission: "access:bulk_edit"
|
permission: "access:bulk_edit"
|
||||||
},
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const toolsItems = [
|
||||||
{
|
{
|
||||||
title: "Newsletter",
|
title: "Newsletter",
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
url: "/newsletter",
|
url: "/newsletter",
|
||||||
permission: "access:newsletter"
|
permission: "access:newsletter"
|
||||||
}
|
},
|
||||||
];
|
|
||||||
|
|
||||||
const productSetupItems = [
|
|
||||||
{
|
{
|
||||||
title: "Create Products",
|
title: "Discount Simulator",
|
||||||
icon: PackagePlus,
|
icon: Percent,
|
||||||
url: "/import",
|
url: "/discount-simulator",
|
||||||
permission: "access:import"
|
permission: "access:discount_simulator"
|
||||||
}
|
},
|
||||||
];
|
{
|
||||||
|
title: "HTS Lookup",
|
||||||
const chatItems = [
|
icon: FileSearch,
|
||||||
|
url: "/hts-lookup",
|
||||||
|
permission: "access:hts_lookup"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Chat Archive",
|
title: "Chat Archive",
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
@@ -266,6 +266,30 @@ export function AppSidebar() {
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Buying Section */}
|
||||||
|
{hasAccessToSection(buyingItems) && (
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Buying</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{renderMenuItems(buyingItems)}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Product Management Section */}
|
||||||
|
{hasAccessToSection(productManagementItems) && (
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Product Management</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{renderMenuItems(productManagementItems)}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tools Section */}
|
{/* Tools Section */}
|
||||||
{hasAccessToSection(toolsItems) && (
|
{hasAccessToSection(toolsItems) && (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
@@ -278,30 +302,6 @@ export function AppSidebar() {
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Product Setup Section */}
|
|
||||||
{hasAccessToSection(productSetupItems) && (
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>Product Setup</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
{renderMenuItems(productSetupItems)}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chat Section */}
|
|
||||||
{hasAccessToSection(chatItems) && (
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>Chat</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
{renderMenuItems(chatItems)}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Settings Section */}
|
{/* Settings Section */}
|
||||||
<Protected permission="access:settings" fallback={null}>
|
<Protected permission="access:settings" fallback={null}>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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 { X, Layers, FolderTree, TrendingUp, Palette } from "lucide-react";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { QuickOrderBuilder } from "@/components/forecasting/QuickOrderBuilder";
|
|
||||||
|
|
||||||
type GroupMode = "line" | "category" | "designer";
|
type GroupMode = "line" | "category" | "designer";
|
||||||
|
|
||||||
@@ -211,20 +210,6 @@ export default function Forecasting() {
|
|||||||
state: { sorting: designerSorting },
|
state: { sorting: designerSorting },
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── QuickOrderBuilder data (always category-based) ─────────────────────
|
|
||||||
|
|
||||||
const qobCategories = useMemo(
|
|
||||||
() =>
|
|
||||||
categoryGroups.map((c) => ({
|
|
||||||
category: c.category,
|
|
||||||
categoryPath: c.categoryPath,
|
|
||||||
avgTotalSold: c.avgLifetimeSales,
|
|
||||||
minSold: c.minSales,
|
|
||||||
maxSold: c.maxSales,
|
|
||||||
})),
|
|
||||||
[categoryGroups]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Summary stats ─────────────────────────────────────────────────────
|
// ─── Summary stats ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const totalProducts = filteredProducts.length;
|
const totalProducts = filteredProducts.length;
|
||||||
@@ -409,9 +394,6 @@ export default function Forecasting() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Quick Order Builder (unchanged interface) */}
|
|
||||||
<QuickOrderBuilder brand={selectedBrand} categories={qobCategories} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
error?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PoLineItemSubmit {
|
||||||
|
pid: number;
|
||||||
|
qty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitNewPurchaseOrderArgs {
|
||||||
|
supplierId: number | string;
|
||||||
|
items: PoLineItemSubmit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitNewPurchaseOrderResponse {
|
||||||
|
success: boolean;
|
||||||
|
poId?: number;
|
||||||
|
message?: string;
|
||||||
|
error?: unknown;
|
||||||
|
raw?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateProductCategoryArgs {
|
export interface CreateProductCategoryArgs {
|
||||||
masterCatId: string | number;
|
masterCatId: string | number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -204,3 +222,103 @@ export async function createProductCategory({
|
|||||||
|
|
||||||
return normalizedResponse;
|
return normalizedResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new purchase order on the legacy PHP backend.
|
||||||
|
*
|
||||||
|
* Mirrors the auth/serialization pattern of `submitNewProducts`:
|
||||||
|
* - URL-encoded body
|
||||||
|
* - cookie-based auth (`credentials: 'include'`)
|
||||||
|
* - HTML response → "Backend authentication required" guard
|
||||||
|
*
|
||||||
|
* The endpoint accepts a JSON array of `{pid, qty}` items in the `items` body
|
||||||
|
* field, with the supplier ID as a path parameter. Returns `{po_id: number}`
|
||||||
|
* on success. Designed to be called from anywhere in the app — not just the
|
||||||
|
* Create PO page.
|
||||||
|
*/
|
||||||
|
export async function submitNewPurchaseOrder({
|
||||||
|
supplierId,
|
||||||
|
items,
|
||||||
|
}: SubmitNewPurchaseOrderArgs): Promise<SubmitNewPurchaseOrderResponse> {
|
||||||
|
if (!supplierId) {
|
||||||
|
throw new Error("supplierId is required");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
throw new Error("At least one item is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanItems = items
|
||||||
|
.map((i) => ({ pid: Number(i.pid), qty: Number(i.qty) }))
|
||||||
|
.filter((i) => Number.isInteger(i.pid) && i.pid > 0 && Number.isFinite(i.qty) && i.qty > 0);
|
||||||
|
|
||||||
|
if (cleanItems.length === 0) {
|
||||||
|
throw new Error("No valid items to submit");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = `/apiv2/po/new/${encodeURIComponent(String(supplierId))}`;
|
||||||
|
|
||||||
|
const payload = new URLSearchParams();
|
||||||
|
payload.append("items", JSON.stringify(cleanItems));
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(targetUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
} catch (networkError) {
|
||||||
|
throw new Error(networkError instanceof Error ? networkError.message : "Network request failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await response.text();
|
||||||
|
|
||||||
|
if (isHtmlResponse(rawBody)) {
|
||||||
|
throw new Error("Backend authentication required. Please ensure you are logged into the backend system.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Unexpected response from backend (${response.status}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
throw new Error("Empty response from backend");
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
// The PHP /apiv2 backend wraps successful responses in an envelope:
|
||||||
|
// {"success": true, "data": { "po_id": "32705" }}
|
||||||
|
// po_id can appear either at the top level (legacy shape) OR nested
|
||||||
|
// inside `data` (current shape), and the value is sometimes a string
|
||||||
|
// (e.g. "32705") rather than a number. We look in both locations and
|
||||||
|
// coerce to Number to handle both cases.
|
||||||
|
const data =
|
||||||
|
record.data && typeof record.data === "object"
|
||||||
|
? (record.data as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const rawPoId = record.po_id ?? record.poId ?? data.po_id ?? data.poId;
|
||||||
|
const poIdNum = typeof rawPoId === "number" ? rawPoId : Number(rawPoId);
|
||||||
|
const hasValidPoId = Number.isFinite(poIdNum) && poIdNum > 0;
|
||||||
|
// Trust the backend's own `success` flag when it's present — it's the
|
||||||
|
// authoritative signal. Fall back to "HTTP OK + valid po_id" for older
|
||||||
|
// response shapes that don't include the flag.
|
||||||
|
const backendSuccess =
|
||||||
|
record.success === true ||
|
||||||
|
record.success === "true" ||
|
||||||
|
record.success === 1;
|
||||||
|
const success = response.ok && hasValidPoId && (backendSuccess || record.success === undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
poId: success ? poIdNum : undefined,
|
||||||
|
message: typeof record.message === "string" ? record.message : undefined,
|
||||||
|
error: record.error ?? record.errors ?? record.error_msg,
|
||||||
|
raw: parsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ export const formatDate = (dateString: string | null | undefined): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatDateShort = (dateString: string | null | undefined): string => {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const formatBoolean = (value: boolean | null | undefined): string => {
|
export const formatBoolean = (value: boolean | null | undefined): string => {
|
||||||
if (value == null) return 'N/A';
|
if (value == null) return 'N/A';
|
||||||
return value ? 'Yes' : 'No';
|
return value ? 'Yes' : 'No';
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user