Add repeat orders page
This commit is contained in:
391
inventory-server/src/routes/repeat-orders.js
Normal file
391
inventory-server/src/routes/repeat-orders.js
Normal file
@@ -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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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() {
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/repeat-orders" element={
|
||||
<Protected page="repeat_orders">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<RepeatOrders />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
|
||||
{/* Always loaded settings */}
|
||||
<Route path="/settings" element={
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PenLine,
|
||||
Mail,
|
||||
Layers,
|
||||
Repeat,
|
||||
} from "lucide-react";
|
||||
import { IconCrystalBall } from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -124,6 +125,12 @@ const toolsItems = [
|
||||
url: "/forecasting",
|
||||
permission: "access:forecasting"
|
||||
},
|
||||
{
|
||||
title: "Repeat Orders",
|
||||
icon: Repeat,
|
||||
url: "/repeat-orders",
|
||||
permission: "access:repeat_orders"
|
||||
},
|
||||
{
|
||||
title: "Product Editor",
|
||||
icon: FilePenLine,
|
||||
|
||||
846
inventory/src/pages/RepeatOrders.tsx
Normal file
846
inventory/src/pages/RepeatOrders.tsx
Normal file
@@ -0,0 +1,846 @@
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Loader2,
|
||||
PackageOpen,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PHASE_CONFIG } from "@/utils/lifecyclePhases";
|
||||
|
||||
const getPhaseLabel = (phase: string): string =>
|
||||
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<HistoryResponse>({
|
||||
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 (
|
||||
<div className="py-6 text-center text-muted-foreground text-xs">
|
||||
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-4 text-center text-destructive text-xs">
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 p-4 bg-muted/30 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Purchase orders ({data.purchaseOrders.length})
|
||||
</div>
|
||||
{data.purchaseOrders.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
No PO lines in window.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-8 text-xs">Date</TableHead>
|
||||
<TableHead className="h-8 text-xs">PO</TableHead>
|
||||
<TableHead className="h-8 text-xs text-right">Qty</TableHead>
|
||||
<TableHead className="h-8 text-xs text-right">Cost</TableHead>
|
||||
<TableHead className="h-8 text-xs">Status</TableHead>
|
||||
<TableHead className="h-8 text-xs">Expected</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.purchaseOrders.map((po) => (
|
||||
<TableRow key={`${po.poId}-${po.date}`}>
|
||||
<TableCell className="py-1.5 text-xs whitespace-nowrap">
|
||||
{fmtDateLong(po.date)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs font-mono">
|
||||
{po.poId}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs text-right tabular-nums">
|
||||
{po.ordered}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs text-right tabular-nums">
|
||||
{fmtNum(po.costPrice, 2)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs">
|
||||
<Badge
|
||||
variant={
|
||||
po.status === "canceled"
|
||||
? "outline"
|
||||
: po.status === "done"
|
||||
? "secondary"
|
||||
: "default"
|
||||
}
|
||||
className="text-[10px] px-1.5 py-0 font-normal"
|
||||
>
|
||||
{po.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs text-muted-foreground">
|
||||
{fmtDate(po.expectedDate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Receivings ({data.receivings.length})
|
||||
</div>
|
||||
{data.receivings.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
No receivings in window.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-8 text-xs">Date</TableHead>
|
||||
<TableHead className="h-8 text-xs">Recv</TableHead>
|
||||
<TableHead className="h-8 text-xs text-right">Qty</TableHead>
|
||||
<TableHead className="h-8 text-xs text-right">Cost</TableHead>
|
||||
<TableHead className="h-8 text-xs">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.receivings.map((rc) => (
|
||||
<TableRow key={`${rc.receivingId}-${rc.receivedDate}`}>
|
||||
<TableCell className="py-1.5 text-xs whitespace-nowrap">
|
||||
{fmtDateLong(rc.receivedDate)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs font-mono">
|
||||
{rc.receivingId}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs text-right tabular-nums">
|
||||
{rc.qtyEach}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs text-right tabular-nums">
|
||||
{fmtNum(rc.costEach, 2)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-xs">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 font-normal"
|
||||
>
|
||||
{rc.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RepeatOrders() {
|
||||
const [supplierId, setSupplierId] = useState<number>(DEFAULT_SUPPLIER_ID);
|
||||
const [windowDays, setWindowDays] = useState<number>(30);
|
||||
const [minPoCount, setMinPoCount] = useState<number>(3);
|
||||
const [maxAvgQty, setMaxAvgQty] = useState<number>(10);
|
||||
const [sortKey, setSortKey] = useState<SortKey>("po_count");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const [expanded, setExpanded] = useState<Set<number>>(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<RepeatOrdersResponse>({
|
||||
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 (
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-3xl font-bold">Repeat Order Opportunities</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-6">
|
||||
<div className="flex flex-col gap-1.5 md:col-span-1">
|
||||
<Label htmlFor="supplier" className="h-5 leading-5">
|
||||
Supplier
|
||||
</Label>
|
||||
<Select
|
||||
value={String(supplierId)}
|
||||
onValueChange={(v) => setSupplierId(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="supplier" className="h-9">
|
||||
<SelectValue placeholder="Select supplier" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(suppliersData?.suppliers || []).map((s) => (
|
||||
<SelectItem key={s.supplierId} value={String(s.supplierId)}>
|
||||
{s.vendorName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="window" className="h-5 leading-5">
|
||||
Window
|
||||
</Label>
|
||||
<Select
|
||||
value={String(windowDays)}
|
||||
onValueChange={(v) => setWindowDays(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="window" className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="14">Last 14 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="60">Last 60 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="minPo" className="h-5 leading-5">
|
||||
Min POs
|
||||
</Label>
|
||||
<Input
|
||||
id="minPo"
|
||||
type="number"
|
||||
min={2}
|
||||
max={50}
|
||||
value={minPoCount}
|
||||
onChange={(e) => setMinPoCount(Number(e.target.value) || 3)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="maxAvg" className="h-5 leading-5">
|
||||
Max avg qty
|
||||
</Label>
|
||||
<Input
|
||||
id="maxAvg"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={maxAvgQty}
|
||||
onChange={(e) => setMaxAvgQty(Number(e.target.value) || 10)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sort" className="h-5 leading-5">
|
||||
Sort by
|
||||
</Label>
|
||||
<Select
|
||||
value={sortKey}
|
||||
onValueChange={(v) => setSortKey(v as SortKey)}
|
||||
>
|
||||
<SelectTrigger id="sort" className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="po_count">PO count</SelectItem>
|
||||
<SelectItem value="repeat_score">Repeat score</SelectItem>
|
||||
<SelectItem value="avg_qty">Smallest avg qty</SelectItem>
|
||||
<SelectItem value="sales">30d sales</SelectItem>
|
||||
<SelectItem value="velocity">Velocity</SelectItem>
|
||||
<SelectItem value="cover">Days of cover</SelectItem>
|
||||
<SelectItem value="notions">Notions stock</SelectItem>
|
||||
<SelectItem value="last_po">Most recent PO</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="search" className="h-5 leading-5">
|
||||
Search
|
||||
</Label>
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Title, SKU, brand…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
{data && (
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Matching pattern</CardDescription>
|
||||
<CardTitle className="text-3xl tabular-nums">
|
||||
{fmtNum(data.matchSummary.matchCount)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Small POs in window</CardDescription>
|
||||
<CardTitle className="text-3xl tabular-nums">
|
||||
{fmtNum(data.matchSummary.totalSmallPoLines)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Combined 30d sales</CardDescription>
|
||||
<CardTitle className="text-3xl tabular-nums">
|
||||
{fmtNum(data.matchSummary.totalSales30d)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Suggested consolidation</CardDescription>
|
||||
<CardTitle className="text-3xl tabular-nums">
|
||||
{fmtNum(data.matchSummary.totalSuggestedUnits)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{isFetching && !data ? (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-10 text-center text-sm text-destructive">
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
) : !data?.results.length ? (
|
||||
<div className="py-12 text-center text-muted-foreground space-y-3">
|
||||
<PackageOpen className="mx-auto h-10 w-10" />
|
||||
<div className="text-sm">
|
||||
No products matched. Try lowering the Min POs threshold or
|
||||
widening the window.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8"></TableHead>
|
||||
<TableHead className="min-w-[260px]">Product</TableHead>
|
||||
<TableHead className="text-right">POs</TableHead>
|
||||
<TableHead className="text-right">Avg qty</TableHead>
|
||||
<TableHead className="text-right">Ordered</TableHead>
|
||||
<TableHead className="text-right">Received</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Notions</TableHead>
|
||||
<TableHead className="text-right">On order</TableHead>
|
||||
<TableHead className="text-right">Cover</TableHead>
|
||||
<TableHead className="text-right">30d sales</TableHead>
|
||||
<TableHead className="text-right">Forecast 30d</TableHead>
|
||||
<TableHead className="text-right">Suggested</TableHead>
|
||||
<TableHead className="text-right">Last PO</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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 (
|
||||
<Fragment key={row.pid}>
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-muted/40"
|
||||
onClick={() => toggleRow(row.pid)}
|
||||
>
|
||||
<TableCell className="w-8 py-2 align-middle">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2 align-middle">
|
||||
<div className="flex items-center gap-3">
|
||||
{row.imageUrl ? (
|
||||
<img
|
||||
src={row.imageUrl}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded object-cover bg-muted shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded bg-muted flex items-center justify-center shrink-0">
|
||||
<PackageOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${row.pid}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-medium text-primary hover:underline flex items-center gap-1 group"
|
||||
>
|
||||
<span className="truncate max-w-[280px]">
|
||||
{row.title}
|
||||
</span>
|
||||
<ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100" />
|
||||
</a>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<span>{row.sku}</span>
|
||||
{row.brand && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="truncate max-w-[140px]">
|
||||
{row.brand}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{row.lifecyclePhase && (
|
||||
<span
|
||||
className="inline-block px-1.5 py-0.5 rounded text-[10px] text-white"
|
||||
style={{
|
||||
backgroundColor: getPhaseColor(
|
||||
row.lifecyclePhase
|
||||
),
|
||||
}}
|
||||
>
|
||||
{getPhaseLabel(row.lifecyclePhase)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2 align-middle text-right font-medium tabular-nums">
|
||||
{row.poLineCount}
|
||||
{row.poOpenLineCount > 0 && (
|
||||
<div className="text-[10px] text-muted-foreground font-normal">
|
||||
{row.poOpenLineCount} open
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell
|
||||
className={cn(
|
||||
"py-2 align-middle text-right tabular-nums",
|
||||
smallAvg && "text-amber-600 font-medium"
|
||||
)}
|
||||
>
|
||||
{fmtNum(row.poAvgQty, row.poAvgQty < 10 ? 1 : 0)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2 align-middle text-right tabular-nums">
|
||||
{fmtNum(row.poTotalUnits)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2 align-middle text-right tabular-nums text-muted-foreground">
|
||||
{fmtNum(row.recvTotalUnits)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell
|
||||
className={cn(
|
||||
"py-2 align-middle text-right tabular-nums",
|
||||
row.currentStock === 0 && "text-destructive"
|
||||
)}
|
||||
>
|
||||
{fmtNum(row.currentStock)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell
|
||||
className={cn(
|
||||
"py-2 align-middle text-right tabular-nums",
|
||||
(row.notionsInvCount ?? 0) === 0
|
||||
? "text-muted-foreground"
|
||||
: (row.notionsInvCount ?? 0) >= 50
|
||||
? "text-emerald-600 font-medium"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
{fmtNum(row.notionsInvCount)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2 align-middle text-right tabular-nums text-muted-foreground">
|
||||
{fmtNum(row.onOrderQty)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell
|
||||
className={cn(
|
||||
"py-2 align-middle text-right tabular-nums",
|
||||
urgentCover && "text-destructive font-medium"
|
||||
)}
|
||||
>
|
||||
{row.coverDays !== null
|
||||
? `${fmtNum(row.coverDays, 1)}d`
|
||||
: "—"}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2 align-middle text-right tabular-nums">
|
||||
{fmtNum(row.sales30d)}
|
||||
{row.velocity > 0 && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{fmtNum(row.velocity, 2)}/day
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2 align-middle text-right tabular-nums">
|
||||
{fmtNum(row.forecast30d, 0)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2 align-middle text-right tabular-nums font-medium">
|
||||
{row.replenishmentUnits &&
|
||||
row.replenishmentUnits > 0 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="tabular-nums"
|
||||
>
|
||||
{fmtNum(row.replenishmentUnits)}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2 align-middle text-right text-xs text-muted-foreground whitespace-nowrap">
|
||||
{fmtDate(row.lastPoDate)}
|
||||
{stale !== null && (
|
||||
<div className="text-[10px]">
|
||||
{stale === 0 ? "today" : `${stale}d ago`}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{isExpanded && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={COLUMN_COUNT + 1}
|
||||
className="p-0"
|
||||
>
|
||||
<ProductHistory
|
||||
pid={row.pid}
|
||||
supplierId={supplierId}
|
||||
windowDays={windowDays}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user