diff --git a/inventory-server/src/routes/repeat-orders.js b/inventory-server/src/routes/repeat-orders.js new file mode 100644 index 0000000..b06a2f0 --- /dev/null +++ b/inventory-server/src/routes/repeat-orders.js @@ -0,0 +1,391 @@ +const express = require('express'); +const router = express.Router(); + +// Stale PO statuses to exclude from our counting — these are the "still active" +// statuses as defined in scripts/metrics-new/update_product_metrics.sql. POs that +// are canceled or done are excluded separately. +const OPEN_PO_STATUSES = [ + 'created', + 'ordered', + 'preordered', + 'electronically_sent', + 'electronically_ready_send', + 'receiving_started' +]; + +// GET /api/repeat-orders +// Finds products that are repeatedly appearing on small POs to a given supplier, +// indicating the auto-PO pattern where we keep buying MOQ instead of batching. +// +// Design notes on dedup between POs and receivings: +// - We count PO line appearances (not unit totals) as the frequency signal, since +// that is exactly the user action we are trying to detect (repeated small orders). +// - Canceled POs are filtered out so unreceived garbage cannot inflate the count. +// - Receivings are joined for read-only context (units actually arrived) and are +// NOT added to the frequency count, so there is no double-counting between POs +// and their matching receivings. +// - Stock / on-order / replenishment are pulled from product_metrics, which uses +// the canonical FIFO logic in update_product_metrics.sql to avoid double-counting +// units between POs and their receivings. +// +// Query params: +// supplierId (int, default 92 = Notions) +// windowDays (int, default 30, range 1..180) +// minPoCount (int, default 3) +// maxAvgQty (number, default 10 — filters out legitimate large batch orders) +router.get('/', async (req, res) => { + const supplierId = parseInt(req.query.supplierId, 10); + const windowDays = Math.min(180, Math.max(1, parseInt(req.query.windowDays, 10) || 30)); + const minPoCount = Math.max(2, parseInt(req.query.minPoCount, 10) || 3); + const maxAvgQtyRaw = parseFloat(req.query.maxAvgQty); + const maxAvgQty = Number.isFinite(maxAvgQtyRaw) && maxAvgQtyRaw > 0 ? maxAvgQtyRaw : 10; + + if (!Number.isFinite(supplierId)) { + return res.status(400).json({ error: 'supplierId query param is required' }); + } + + try { + const pool = req.app.locals.pool; + + const { rows } = await pool.query( + ` + WITH po_window AS ( + SELECT + po.pid, + po.po_id, + po.date, + po.ordered, + po.po_cost_price, + po.status + FROM purchase_orders po + WHERE po.supplier_id = $1 + AND po.date >= NOW() - ($2::int * INTERVAL '1 day') + AND po.status <> 'canceled' + ), + po_agg AS ( + SELECT + pid, + COUNT(*)::int AS po_line_count, + COUNT(DISTINCT date::date)::int AS po_days_active, + SUM(ordered)::int AS po_total_units, + AVG(ordered::numeric)::numeric(10,2) AS po_avg_qty, + MIN(ordered)::int AS po_min_qty, + MAX(ordered)::int AS po_max_qty, + MIN(date)::date AS first_po_date, + MAX(date)::date AS last_po_date, + SUM(ordered * po_cost_price)::numeric(14,2) AS po_total_cost, + -- Count of PO lines still in an open status (not yet received / closed) + COUNT(*) FILTER (WHERE status = ANY($5::text[]))::int AS po_open_line_count + FROM po_window + GROUP BY pid + HAVING COUNT(*) >= $3 + AND AVG(ordered::numeric) <= $4 + ), + recv_window AS ( + SELECT + pid, + COUNT(*)::int AS recv_line_count, + COUNT(DISTINCT received_date::date)::int AS recv_days_active, + SUM(qty_each)::int AS recv_total_units, + MAX(received_date)::date AS last_recv_date + FROM receivings + WHERE supplier_id = $1 + AND received_date >= NOW() - ($2::int * INTERVAL '1 day') + AND status IN ('partial_received', 'full_received', 'paid') + GROUP BY pid + ), + forecast_agg AS ( + SELECT + pid, + SUM(forecast_units) FILTER ( + WHERE forecast_date >= CURRENT_DATE + AND forecast_date < CURRENT_DATE + INTERVAL '30 days' + )::numeric(10,2) AS forecast_30d, + SUM(forecast_units) FILTER ( + WHERE forecast_date >= CURRENT_DATE + AND forecast_date < CURRENT_DATE + INTERVAL '60 days' + )::numeric(10,2) AS forecast_60d, + MAX(lifecycle_phase) AS lifecycle_phase + FROM product_forecasts + WHERE pid IN (SELECT pid FROM po_agg) + GROUP BY pid + ) + SELECT + pm.pid, + pm.sku, + pm.title, + pm.brand, + pm.vendor, + pm.image_url, + pm.is_visible, + pm.is_replenishable, + pm.moq, + pm.current_stock, + pm.notions_inv_count, + pm.on_order_qty, + pm.sales_30d, + pm.sales_velocity_daily::numeric(10,3) AS velocity, + pm.stock_cover_in_days::numeric(10,1) AS cover_days, + pm.replenishment_units, + pm.to_order_units, + pm.avg_lead_time_days, + pm.lifecycle_phase, + pm.earliest_expected_date, + po.po_line_count, + po.po_days_active, + po.po_total_units, + po.po_avg_qty, + po.po_min_qty, + po.po_max_qty, + po.first_po_date, + po.last_po_date, + po.po_total_cost, + po.po_open_line_count, + COALESCE(r.recv_line_count, 0) AS recv_line_count, + COALESCE(r.recv_days_active, 0) AS recv_days_active, + COALESCE(r.recv_total_units, 0) AS recv_total_units, + r.last_recv_date, + f.forecast_30d, + f.forecast_60d + FROM po_agg po + JOIN product_metrics pm ON pm.pid = po.pid + LEFT JOIN recv_window r ON r.pid = po.pid + LEFT JOIN forecast_agg f ON f.pid = po.pid + ORDER BY po.po_line_count DESC, pm.sales_30d DESC NULLS LAST + LIMIT 500 + `, + [supplierId, windowDays, minPoCount, maxAvgQty, OPEN_PO_STATUSES] + ); + + // Supplier-level summary for the filters in effect — independent of the + // minPoCount / maxAvgQty filters so the user can see the full baseline. + const { rows: summaryRows } = await pool.query( + ` + SELECT + COUNT(*)::int AS total_po_lines, + COUNT(DISTINCT po_id)::int AS distinct_po_ids, + COUNT(DISTINCT pid)::int AS distinct_products, + COUNT(DISTINCT date::date)::int AS distinct_days, + SUM(ordered)::int AS total_units, + AVG(ordered::numeric)::numeric(10,2) AS avg_qty_per_line + FROM purchase_orders + WHERE supplier_id = $1 + AND date >= NOW() - ($2::int * INTERVAL '1 day') + AND status <> 'canceled' + `, + [supplierId, windowDays] + ); + + const results = rows.map((row) => { + const poAvg = parseFloat(row.po_avg_qty) || 0; + const velocity = parseFloat(row.velocity) || 0; + const forecast30 = parseFloat(row.forecast_30d) || 0; + const poCount = parseInt(row.po_line_count, 10) || 0; + const poDays = parseInt(row.po_days_active, 10) || 0; + + // Repeat score: higher = more consolidation opportunity. + // - poCount boosts the score + // - inversely weighted by avg qty (small buys = bigger problem) + // - boosted by velocity (real demand, not noise) + const repeatScore = poAvg > 0 + ? Number(((poCount * Math.max(velocity, 0.1)) / poAvg).toFixed(2)) + : 0; + + return { + pid: row.pid, + sku: row.sku, + title: row.title, + brand: row.brand, + vendor: row.vendor, + imageUrl: row.image_url, + isVisible: row.is_visible, + isReplenishable: row.is_replenishable, + moq: row.moq, + + currentStock: row.current_stock, + notionsInvCount: row.notions_inv_count, + onOrderQty: row.on_order_qty, + sales30d: row.sales_30d, + velocity, + coverDays: row.cover_days !== null ? parseFloat(row.cover_days) : null, + replenishmentUnits: row.replenishment_units, + toOrderUnits: row.to_order_units, + avgLeadTimeDays: row.avg_lead_time_days, + lifecyclePhase: row.lifecycle_phase, + earliestExpectedDate: row.earliest_expected_date, + + poLineCount: poCount, + poDaysActive: poDays, + poTotalUnits: row.po_total_units, + poAvgQty: poAvg, + poMinQty: row.po_min_qty, + poMaxQty: row.po_max_qty, + firstPoDate: row.first_po_date, + lastPoDate: row.last_po_date, + poTotalCost: parseFloat(row.po_total_cost) || 0, + poOpenLineCount: row.po_open_line_count, + + recvLineCount: row.recv_line_count, + recvDaysActive: row.recv_days_active, + recvTotalUnits: row.recv_total_units, + lastRecvDate: row.last_recv_date, + + forecast30d: forecast30, + forecast60d: parseFloat(row.forecast_60d) || 0, + + repeatScore, + }; + }); + + const summary = summaryRows[0] || {}; + + res.json({ + params: { + supplierId, + windowDays, + minPoCount, + maxAvgQty, + }, + supplierSummary: { + totalPoLines: summary.total_po_lines || 0, + distinctPoIds: summary.distinct_po_ids || 0, + distinctProducts: summary.distinct_products || 0, + distinctDays: summary.distinct_days || 0, + totalUnits: summary.total_units || 0, + avgQtyPerLine: parseFloat(summary.avg_qty_per_line) || 0, + }, + matchSummary: { + matchCount: results.length, + totalSmallPoLines: results.reduce((sum, r) => sum + r.poLineCount, 0), + totalSmallPoUnits: results.reduce((sum, r) => sum + (r.poTotalUnits || 0), 0), + totalSales30d: results.reduce((sum, r) => sum + (r.sales30d || 0), 0), + totalSuggestedUnits: results.reduce((sum, r) => sum + (r.replenishmentUnits || 0), 0), + }, + results, + }); + } catch (error) { + console.error('Error fetching repeat orders:', error); + res.status(500).json({ error: 'Failed to fetch repeat orders' }); + } +}); + +// GET /api/repeat-orders/suppliers +// Returns the list of suppliers that have had non-canceled PO activity recently, +// so the UI can offer a supplier dropdown without an expensive scan. +router.get('/suppliers', async (req, res) => { + const windowDays = Math.min(365, Math.max(7, parseInt(req.query.windowDays, 10) || 90)); + + try { + const pool = req.app.locals.pool; + const { rows } = await pool.query( + ` + SELECT + supplier_id, + MAX(vendor) AS vendor_name, + COUNT(*)::int AS line_count, + COUNT(DISTINCT po_id)::int AS po_count, + MAX(date)::date AS last_po_date + FROM purchase_orders + WHERE supplier_id IS NOT NULL + AND date >= NOW() - ($1::int * INTERVAL '1 day') + AND status <> 'canceled' + GROUP BY supplier_id + HAVING COUNT(*) >= 5 + ORDER BY line_count DESC + `, + [windowDays] + ); + + res.json({ + suppliers: rows.map((r) => ({ + supplierId: r.supplier_id, + vendorName: r.vendor_name, + lineCount: r.line_count, + poCount: r.po_count, + lastPoDate: r.last_po_date, + })), + }); + } catch (error) { + console.error('Error fetching repeat-order suppliers:', error); + res.status(500).json({ error: 'Failed to fetch suppliers' }); + } +}); + +// GET /api/repeat-orders/:pid/history +// Returns the full PO + receiving history within the window for a single product, +// for drill-down detail when the user clicks a row. +router.get('/:pid/history', async (req, res) => { + const pid = parseInt(req.params.pid, 10); + const supplierId = parseInt(req.query.supplierId, 10); + const windowDays = Math.min(365, Math.max(1, parseInt(req.query.windowDays, 10) || 30)); + + if (!Number.isFinite(pid) || !Number.isFinite(supplierId)) { + return res.status(400).json({ error: 'pid and supplierId are required' }); + } + + try { + const pool = req.app.locals.pool; + + const { rows: poRows } = await pool.query( + ` + SELECT + po_id, + date, + expected_date, + ordered, + po_cost_price, + status, + notes + FROM purchase_orders + WHERE pid = $1 + AND supplier_id = $2 + AND date >= NOW() - ($3::int * INTERVAL '1 day') + ORDER BY date DESC + `, + [pid, supplierId, windowDays] + ); + + const { rows: recvRows } = await pool.query( + ` + SELECT + receiving_id, + received_date, + qty_each, + cost_each, + status + FROM receivings + WHERE pid = $1 + AND supplier_id = $2 + AND received_date >= NOW() - ($3::int * INTERVAL '1 day') + ORDER BY received_date DESC + `, + [pid, supplierId, windowDays] + ); + + res.json({ + pid, + supplierId, + purchaseOrders: poRows.map((r) => ({ + poId: r.po_id, + date: r.date, + expectedDate: r.expected_date, + ordered: r.ordered, + costPrice: parseFloat(r.po_cost_price) || 0, + status: r.status, + notes: r.notes, + })), + receivings: recvRows.map((r) => ({ + receivingId: r.receiving_id, + receivedDate: r.received_date, + qtyEach: r.qty_each, + costEach: parseFloat(r.cost_each) || 0, + status: r.status, + })), + }); + } catch (error) { + console.error('Error fetching repeat-order history:', error); + res.status(500).json({ error: 'Failed to fetch product history' }); + } +}); + +module.exports = router; diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index eaff3ea..10cc3df 100644 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -28,6 +28,7 @@ const importAuditLogRouter = require('./routes/import-audit-log'); const productEditorAuditLogRouter = require('./routes/product-editor-audit-log'); const newsletterRouter = require('./routes/newsletter'); const linesAggregateRouter = require('./routes/linesAggregate'); +const repeatOrdersRouter = require('./routes/repeat-orders'); // Get the absolute path to the .env file const envPath = '/var/www/html/inventory/.env'; @@ -140,6 +141,7 @@ async function startServer() { app.use('/api/product-editor-audit-log', productEditorAuditLogRouter); app.use('/api/newsletter', newsletterRouter); app.use('/api/lines-aggregate', linesAggregateRouter); + app.use('/api/repeat-orders', repeatOrdersRouter); // Basic health check route app.get('/health', (req, res) => { diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index 4dc3f7a..453ed9e 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -30,6 +30,7 @@ const ProductLines = lazy(() => import('./pages/ProductLines')); const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders')); const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard')); const Newsletter = lazy(() => import('./pages/Newsletter')); +const RepeatOrders = lazy(() => import('./pages/RepeatOrders')); // 2. Dashboard app - separate chunk const Dashboard = lazy(() => import('./pages/Dashboard')); @@ -192,6 +193,13 @@ function App() { } /> + + }> + + + + } /> {/* Always loaded settings */} + PHASE_CONFIG[phase]?.label || phase; + +const getPhaseColor = (phase: string): string => + PHASE_CONFIG[phase]?.color || "#94A3B8"; + +// Notions = supplier 92; the daily auto-PO workflow runs here by default. +const DEFAULT_SUPPLIER_ID = 92; + +type Supplier = { + supplierId: number; + vendorName: string; + lineCount: number; + poCount: number; + lastPoDate: string | null; +}; + +type RepeatOrderRow = { + pid: number; + sku: string | null; + title: string | null; + brand: string | null; + vendor: string | null; + imageUrl: string | null; + isVisible: boolean | null; + isReplenishable: boolean | null; + moq: number | null; + + currentStock: number; + notionsInvCount: number | null; + onOrderQty: number; + sales30d: number | null; + velocity: number; + coverDays: number | null; + replenishmentUnits: number | null; + toOrderUnits: number | null; + avgLeadTimeDays: number | null; + lifecyclePhase: string | null; + earliestExpectedDate: string | null; + + poLineCount: number; + poDaysActive: number; + poTotalUnits: number | null; + poAvgQty: number; + poMinQty: number | null; + poMaxQty: number | null; + firstPoDate: string | null; + lastPoDate: string | null; + poTotalCost: number; + poOpenLineCount: number; + + recvLineCount: number; + recvDaysActive: number; + recvTotalUnits: number; + lastRecvDate: string | null; + + forecast30d: number; + forecast60d: number; + + repeatScore: number; +}; + +type RepeatOrdersResponse = { + params: { + supplierId: number; + windowDays: number; + minPoCount: number; + maxAvgQty: number; + }; + supplierSummary: { + totalPoLines: number; + distinctPoIds: number; + distinctProducts: number; + distinctDays: number; + totalUnits: number; + avgQtyPerLine: number; + }; + matchSummary: { + matchCount: number; + totalSmallPoLines: number; + totalSmallPoUnits: number; + totalSales30d: number; + totalSuggestedUnits: number; + }; + results: RepeatOrderRow[]; +}; + +type HistoryResponse = { + pid: number; + supplierId: number; + purchaseOrders: { + poId: string; + date: string; + expectedDate: string | null; + ordered: number; + costPrice: number; + status: string; + notes: string | null; + }[]; + receivings: { + receivingId: string; + receivedDate: string; + qtyEach: number; + costEach: number; + status: string; + }[]; +}; + +type SortKey = + | "po_count" + | "repeat_score" + | "avg_qty" + | "sales" + | "velocity" + | "cover" + | "notions" + | "last_po"; + +const COLUMN_COUNT = 13; + +const fmtNum = (v: number | null | undefined, decimals = 0) => { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +}; + +const fmtDate = (v: string | null | undefined) => { + if (!v) return "—"; + const d = new Date(v); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +}; + +const fmtDateLong = (v: string | null | undefined) => { + if (!v) return "—"; + const d = new Date(v); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +}; + +const daysSince = (v: string | null | undefined) => { + if (!v) return null; + const d = new Date(v); + if (Number.isNaN(d.getTime())) return null; + return Math.floor((Date.now() - d.getTime()) / (1000 * 60 * 60 * 24)); +}; + +function ProductHistory({ + pid, + supplierId, + windowDays, +}: { + pid: number; + supplierId: number; + windowDays: number; +}) { + const { data, isFetching, error } = useQuery({ + queryKey: ["repeat-orders-history", pid, supplierId, windowDays], + queryFn: async () => { + const params = new URLSearchParams({ + supplierId: String(supplierId), + windowDays: String(windowDays), + }); + const res = await fetch( + `/api/repeat-orders/${pid}/history?${params.toString()}` + ); + if (!res.ok) throw new Error("Failed to load history"); + return res.json(); + }, + staleTime: 60 * 1000, + }); + + if (isFetching && !data) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {(error as Error).message} +
+ ); + } + + if (!data) return null; + + return ( +
+
+
+ Purchase orders ({data.purchaseOrders.length}) +
+ {data.purchaseOrders.length === 0 ? ( +
+ No PO lines in window. +
+ ) : ( +
+ + + + Date + PO + Qty + Cost + Status + Expected + + + + {data.purchaseOrders.map((po) => ( + + + {fmtDateLong(po.date)} + + + {po.poId} + + + {po.ordered} + + + {fmtNum(po.costPrice, 2)} + + + + {po.status} + + + + {fmtDate(po.expectedDate)} + + + ))} + +
+
+ )} +
+ +
+
+ Receivings ({data.receivings.length}) +
+ {data.receivings.length === 0 ? ( +
+ No receivings in window. +
+ ) : ( +
+ + + + Date + Recv + Qty + Cost + Status + + + + {data.receivings.map((rc) => ( + + + {fmtDateLong(rc.receivedDate)} + + + {rc.receivingId} + + + {rc.qtyEach} + + + {fmtNum(rc.costEach, 2)} + + + + {rc.status} + + + + ))} + +
+
+ )} +
+
+ ); +} + +export default function RepeatOrders() { + const [supplierId, setSupplierId] = useState(DEFAULT_SUPPLIER_ID); + const [windowDays, setWindowDays] = useState(30); + const [minPoCount, setMinPoCount] = useState(3); + const [maxAvgQty, setMaxAvgQty] = useState(10); + const [sortKey, setSortKey] = useState("po_count"); + const [search, setSearch] = useState(""); + const [expanded, setExpanded] = useState>(new Set()); + + const toggleRow = (pid: number) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(pid)) next.delete(pid); + else next.add(pid); + return next; + }); + }; + + const { data: suppliersData } = useQuery<{ suppliers: Supplier[] }>({ + queryKey: ["repeat-orders-suppliers"], + queryFn: async () => { + const res = await fetch(`/api/repeat-orders/suppliers?windowDays=90`); + if (!res.ok) throw new Error("Failed to load suppliers"); + return res.json(); + }, + staleTime: 5 * 60 * 1000, + }); + + const { data, isFetching, error } = useQuery({ + queryKey: ["repeat-orders", supplierId, windowDays, minPoCount, maxAvgQty], + queryFn: async () => { + const params = new URLSearchParams({ + supplierId: String(supplierId), + windowDays: String(windowDays), + minPoCount: String(minPoCount), + maxAvgQty: String(maxAvgQty), + }); + const res = await fetch(`/api/repeat-orders?${params.toString()}`); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload.error || "Failed to load repeat orders"); + } + return res.json(); + }, + staleTime: 60 * 1000, + }); + + const sortedResults = useMemo(() => { + if (!data?.results) return []; + + const filtered = search.trim() + ? data.results.filter((r) => { + const q = search.toLowerCase(); + return ( + (r.title || "").toLowerCase().includes(q) || + (r.sku || "").toLowerCase().includes(q) || + (r.brand || "").toLowerCase().includes(q) + ); + }) + : data.results; + + const sorted = [...filtered]; + sorted.sort((a, b) => { + switch (sortKey) { + case "po_count": + return ( + b.poLineCount - a.poLineCount || + (b.sales30d || 0) - (a.sales30d || 0) + ); + case "repeat_score": + return b.repeatScore - a.repeatScore; + case "avg_qty": + return a.poAvgQty - b.poAvgQty; + case "sales": + return (b.sales30d || 0) - (a.sales30d || 0); + case "velocity": + return (b.velocity || 0) - (a.velocity || 0); + case "cover": + return (a.coverDays ?? Infinity) - (b.coverDays ?? Infinity); + case "notions": + return (b.notionsInvCount ?? 0) - (a.notionsInvCount ?? 0); + case "last_po": + return ( + new Date(b.lastPoDate || 0).getTime() - + new Date(a.lastPoDate || 0).getTime() + ); + default: + return 0; + } + }); + return sorted; + }, [data?.results, sortKey, search]); + + return ( +
+

Repeat Order Opportunities

+ + {/* Filters */} + + +
+
+ + +
+ +
+ + +
+ +
+ + setMinPoCount(Number(e.target.value) || 3)} + className="h-9" + /> +
+ +
+ + setMaxAvgQty(Number(e.target.value) || 10)} + className="h-9" + /> +
+ +
+ + +
+ +
+ + setSearch(e.target.value)} + className="h-9" + /> +
+
+
+
+ + {/* Summary */} + {data && ( +
+ + + Matching pattern + + {fmtNum(data.matchSummary.matchCount)} + + + + + + + Small POs in window + + {fmtNum(data.matchSummary.totalSmallPoLines)} + + + + + + + Combined 30d sales + + {fmtNum(data.matchSummary.totalSales30d)} + + + + + + + Suggested consolidation + + {fmtNum(data.matchSummary.totalSuggestedUnits)} + + + +
+ )} + + {/* Results */} + + + {isFetching && !data ? ( +
+ +
+ ) : error ? ( +
+ {(error as Error).message} +
+ ) : !data?.results.length ? ( +
+ +
+ No products matched. Try lowering the Min POs threshold or + widening the window. +
+
+ ) : ( +
+ + + + + Product + POs + Avg qty + Ordered + Received + Stock + Notions + On order + Cover + 30d sales + Forecast 30d + Suggested + Last PO + + + + {sortedResults.map((row) => { + const stale = daysSince(row.lastPoDate); + const urgentCover = + row.coverDays !== null && row.coverDays < 7; + const smallAvg = row.poAvgQty <= 2; + const isExpanded = expanded.has(row.pid); + return ( + + toggleRow(row.pid)} + > + + {isExpanded ? ( + + ) : ( + + )} + + + +
+ {row.imageUrl ? ( + + ) : ( +
+ +
+ )} +
+ e.stopPropagation()} + className="font-medium text-primary hover:underline flex items-center gap-1 group" + > + + {row.title} + + + +
+ {row.sku} + {row.brand && ( + <> + + + {row.brand} + + + )} + {row.lifecyclePhase && ( + + {getPhaseLabel(row.lifecyclePhase)} + + )} +
+
+
+
+ + + {row.poLineCount} + {row.poOpenLineCount > 0 && ( +
+ {row.poOpenLineCount} open +
+ )} +
+ + + {fmtNum(row.poAvgQty, row.poAvgQty < 10 ? 1 : 0)} + + + + {fmtNum(row.poTotalUnits)} + + + + {fmtNum(row.recvTotalUnits)} + + + + {fmtNum(row.currentStock)} + + + = 50 + ? "text-emerald-600 font-medium" + : "" + )} + > + {fmtNum(row.notionsInvCount)} + + + + {fmtNum(row.onOrderQty)} + + + + {row.coverDays !== null + ? `${fmtNum(row.coverDays, 1)}d` + : "—"} + + + + {fmtNum(row.sales30d)} + {row.velocity > 0 && ( +
+ {fmtNum(row.velocity, 2)}/day +
+ )} +
+ + + {fmtNum(row.forecast30d, 0)} + + + + {row.replenishmentUnits && + row.replenishmentUnits > 0 ? ( + + {fmtNum(row.replenishmentUnits)} + + ) : ( + + )} + + + + {fmtDate(row.lastPoDate)} + {stale !== null && ( +
+ {stale === 0 ? "today" : `${stale}d ago`} +
+ )} +
+
+ + {isExpanded && ( + + + + + + )} +
+ ); + })} +
+
+
+ )} +
+
+
+ ); +}