Add repeat orders page

This commit is contained in:
2026-04-09 10:36:52 -04:00
parent c276f165f4
commit 338f829eb6
5 changed files with 1254 additions and 0 deletions

View 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;

View File

@@ -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) => {

View File

@@ -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={

View File

@@ -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,

View 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>
);
}