Clean up inventory overview page

This commit is contained in:
2026-02-09 22:59:34 -05:00
parent 6834a77a80
commit f41b5ab0f6
19 changed files with 1064 additions and 1542 deletions

View File

@@ -61,16 +61,72 @@ BEGIN
p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each) p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each)
FROM public.products p FROM public.products p
), ),
-- Stale POs: open, >90 days past expected, AND a newer PO exists for the same product.
-- These are likely abandoned/superseded and should not consume receivings in FIFO.
StalePOLines AS (
SELECT po.po_id, po.pid
FROM public.purchase_orders po
WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent',
'electronically_ready_send', 'receiving_started')
AND po.expected_date IS NOT NULL
AND po.expected_date < _current_date - INTERVAL '90 days'
AND EXISTS (
SELECT 1 FROM public.purchase_orders newer
WHERE newer.pid = po.pid
AND newer.status NOT IN ('canceled', 'done')
AND COALESCE(newer.date_ordered, newer.date_created)
> COALESCE(po.date_ordered, po.date_created)
)
),
-- All non-canceled, non-stale POs in FIFO order per (pid, supplier).
-- Includes closed ('done') POs so they consume receivings before open POs.
POFifo AS (
SELECT
po.pid, po.supplier_id, po.po_id, po.ordered, po.status,
po.po_cost_price, po.expected_date,
SUM(po.ordered) OVER (
PARTITION BY po.pid, po.supplier_id
ORDER BY COALESCE(po.date_ordered, po.date_created), po.po_id
) - po.ordered AS cumulative_before
FROM public.purchase_orders po
WHERE po.status != 'canceled'
AND NOT EXISTS (
SELECT 1 FROM StalePOLines s
WHERE s.po_id = po.po_id AND s.pid = po.pid
)
),
-- Total received per (product, supplier) across all receivings.
SupplierReceived AS (
SELECT pid, supplier_id, SUM(qty_each) AS total_received
FROM public.receivings
WHERE status IN ('partial_received', 'full_received', 'paid')
GROUP BY pid, supplier_id
),
-- FIFO allocation: receivings fill oldest POs first per (pid, supplier).
-- Only open PO lines are reported; closed POs just absorb receivings.
OnOrderInfo AS ( OnOrderInfo AS (
SELECT SELECT
pid, po.pid,
SUM(ordered) AS on_order_qty, SUM(GREATEST(0,
SUM(ordered * po_cost_price) AS on_order_cost, po.ordered - GREATEST(0, LEAST(po.ordered,
MIN(expected_date) AS earliest_expected_date COALESCE(sr.total_received, 0) - po.cumulative_before
FROM public.purchase_orders ))
WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started') )) AS on_order_qty,
AND status NOT IN ('canceled', 'done') SUM(GREATEST(0,
GROUP BY pid po.ordered - GREATEST(0, LEAST(po.ordered,
COALESCE(sr.total_received, 0) - po.cumulative_before
))
) * po.po_cost_price) AS on_order_cost,
MIN(po.expected_date) FILTER (WHERE
po.ordered > GREATEST(0, LEAST(po.ordered,
COALESCE(sr.total_received, 0) - po.cumulative_before
))
) AS earliest_expected_date
FROM POFifo po
LEFT JOIN SupplierReceived sr ON sr.pid = po.pid AND sr.supplier_id = po.supplier_id
WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent',
'electronically_ready_send', 'receiving_started')
GROUP BY po.pid
), ),
HistoricalDates AS ( HistoricalDates AS (
-- Note: Calculating these MIN/MAX values hourly can be slow on large tables. -- Note: Calculating these MIN/MAX values hourly can be slow on large tables.
@@ -142,6 +198,17 @@ BEGIN
FROM public.daily_product_snapshots FROM public.daily_product_snapshots
GROUP BY pid GROUP BY pid
), ),
BeginningStock AS (
-- Get stock level from 30 days ago for sell-through calculation.
-- Uses the closest available snapshot if exact date is missing (activity-only snapshots).
SELECT DISTINCT ON (pid)
pid,
eod_stock_quantity AS beginning_stock_30d
FROM public.daily_product_snapshots
WHERE snapshot_date <= _current_date - INTERVAL '30 days'
AND snapshot_date >= _current_date - INTERVAL '37 days'
ORDER BY pid, snapshot_date DESC
),
FirstPeriodMetrics AS ( FirstPeriodMetrics AS (
SELECT SELECT
pid, pid,
@@ -403,10 +470,10 @@ BEGIN
(sa.stockout_days_30d / 30.0) * 100 AS stockout_rate_30d, (sa.stockout_days_30d / 30.0) * 100 AS stockout_rate_30d,
sa.gross_regular_revenue_30d - sa.gross_revenue_30d AS markdown_30d, sa.gross_regular_revenue_30d - sa.gross_revenue_30d AS markdown_30d,
((sa.gross_regular_revenue_30d - sa.gross_revenue_30d) / NULLIF(sa.gross_regular_revenue_30d, 0)) * 100 AS markdown_rate_30d, ((sa.gross_regular_revenue_30d - sa.gross_revenue_30d) / NULLIF(sa.gross_regular_revenue_30d, 0)) * 100 AS markdown_rate_30d,
-- Fix sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received) -- Sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received)
-- Approximating beginning inventory as current stock + units sold - units received -- Uses actual snapshot from 30 days ago as beginning stock, falls back to avg_stock_units_30d
(sa.sales_30d / NULLIF( (sa.sales_30d / NULLIF(
ci.current_stock + sa.sales_30d + sa.returns_units_30d - sa.received_qty_30d, COALESCE(bs.beginning_stock_30d, sa.avg_stock_units_30d::int, 0) + sa.received_qty_30d,
0 0
)) * 100 AS sell_through_30d, )) * 100 AS sell_through_30d,
@@ -555,6 +622,7 @@ BEGIN
LEFT JOIN PreviousPeriodMetrics ppm ON ci.pid = ppm.pid LEFT JOIN PreviousPeriodMetrics ppm ON ci.pid = ppm.pid
LEFT JOIN DemandVariability dv ON ci.pid = dv.pid LEFT JOIN DemandVariability dv ON ci.pid = dv.pid
LEFT JOIN ServiceLevels sl ON ci.pid = sl.pid LEFT JOIN ServiceLevels sl ON ci.pid = sl.pid
LEFT JOIN BeginningStock bs ON ci.pid = bs.pid
LEFT JOIN SeasonalityAnalysis season ON ci.pid = season.pid LEFT JOIN SeasonalityAnalysis season ON ci.pid = season.pid
WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL -- Exclude products explicitly marked WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL -- Exclude products explicitly marked

View File

@@ -171,30 +171,37 @@ router.get('/inventory-summary', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
const { rows: [summary] } = await pool.query(` const { rows: [summary] } = await pool.query(`
SELECT WITH agg AS (
SUM(current_stock_cost) AS stock_investment, SELECT
SUM(on_order_cost) AS on_order_value, SUM(current_stock_cost) AS stock_investment,
CASE SUM(on_order_cost) AS on_order_value,
WHEN SUM(avg_stock_cost_30d) > 0 CASE
THEN (SUM(cogs_30d) / SUM(avg_stock_cost_30d)) * 12 WHEN SUM(avg_stock_cost_30d) > 0
ELSE 0 THEN (SUM(cogs_30d) / SUM(avg_stock_cost_30d)) * 12
END AS inventory_turns_annualized, ELSE 0
CASE END AS inventory_turns_annualized,
WHEN SUM(avg_stock_cost_30d) > 0 CASE
THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12 WHEN SUM(avg_stock_cost_30d) > 0
ELSE 0 THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12
END AS gmroi, ELSE 0
CASE END AS gmroi,
WHEN SUM(CASE WHEN sales_velocity_daily > 0 THEN 1 ELSE 0 END) > 0 COUNT(*) FILTER (WHERE current_stock > 0) AS products_in_stock,
THEN SUM(CASE WHEN sales_velocity_daily > 0 THEN stock_cover_in_days ELSE 0 END) COUNT(*) FILTER (WHERE is_old_stock = true AND current_stock > 0) AS dead_stock_products,
/ SUM(CASE WHEN sales_velocity_daily > 0 THEN 1 ELSE 0 END) SUM(CASE WHEN is_old_stock = true AND current_stock > 0 THEN current_stock_cost ELSE 0 END) AS dead_stock_value
ELSE 0 FROM product_metrics
END AS avg_stock_cover_days, WHERE is_visible = true
COUNT(*) FILTER (WHERE current_stock > 0) AS products_in_stock, ),
COUNT(*) FILTER (WHERE is_old_stock = true) AS dead_stock_products, cover AS (
SUM(CASE WHEN is_old_stock = true THEN current_stock_cost ELSE 0 END) AS dead_stock_value SELECT
FROM product_metrics PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY stock_cover_in_days) AS median_stock_cover_days
WHERE is_visible = true FROM product_metrics
WHERE is_visible = true
AND current_stock > 0
AND sales_velocity_daily > 0
AND stock_cover_in_days IS NOT NULL
)
SELECT agg.*, cover.median_stock_cover_days
FROM agg, cover
`); `);
res.json({ res.json({
@@ -202,7 +209,7 @@ router.get('/inventory-summary', async (req, res) => {
onOrderValue: Number(summary.on_order_value) || 0, onOrderValue: Number(summary.on_order_value) || 0,
inventoryTurns: Number(summary.inventory_turns_annualized) || 0, inventoryTurns: Number(summary.inventory_turns_annualized) || 0,
gmroi: Number(summary.gmroi) || 0, gmroi: Number(summary.gmroi) || 0,
avgStockCoverDays: Number(summary.avg_stock_cover_days) || 0, avgStockCoverDays: Number(summary.median_stock_cover_days) || 0,
productsInStock: Number(summary.products_in_stock) || 0, productsInStock: Number(summary.products_in_stock) || 0,
deadStockProducts: Number(summary.dead_stock_products) || 0, deadStockProducts: Number(summary.dead_stock_products) || 0,
deadStockValue: Number(summary.dead_stock_value) || 0, deadStockValue: Number(summary.dead_stock_value) || 0,
@@ -266,9 +273,9 @@ router.get('/portfolio', async (req, res) => {
// Dead stock and overstock summary // Dead stock and overstock summary
const { rows: [stockIssues] } = await pool.query(` const { rows: [stockIssues] } = await pool.query(`
SELECT SELECT
COUNT(*) FILTER (WHERE is_old_stock = true) AS dead_stock_count, COUNT(*) FILTER (WHERE is_old_stock = true AND current_stock > 0) AS dead_stock_count,
SUM(CASE WHEN is_old_stock = true THEN current_stock_cost ELSE 0 END) AS dead_stock_cost, SUM(CASE WHEN is_old_stock = true AND current_stock > 0 THEN current_stock_cost ELSE 0 END) AS dead_stock_cost,
SUM(CASE WHEN is_old_stock = true THEN current_stock_retail ELSE 0 END) AS dead_stock_retail, SUM(CASE WHEN is_old_stock = true AND current_stock > 0 THEN current_stock_retail ELSE 0 END) AS dead_stock_retail,
COUNT(*) FILTER (WHERE overstocked_units > 0) AS overstock_count, COUNT(*) FILTER (WHERE overstocked_units > 0) AS overstock_count,
SUM(COALESCE(overstocked_cost, 0)) AS overstock_cost, SUM(COALESCE(overstocked_cost, 0)) AS overstock_cost,
SUM(COALESCE(overstocked_retail, 0)) AS overstock_retail SUM(COALESCE(overstocked_retail, 0)) AS overstock_retail
@@ -300,14 +307,14 @@ router.get('/portfolio', async (req, res) => {
} }
}); });
// Capital efficiency — GMROI by vendor (single combined query) // Capital efficiency — GMROI by brand (single combined query)
router.get('/efficiency', async (req, res) => { router.get('/efficiency', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT SELECT
vendor AS vendor_name, COALESCE(brand, 'Unbranded') AS brand_name,
COUNT(*) AS product_count, COUNT(*) AS product_count,
SUM(current_stock_cost) AS stock_cost, SUM(current_stock_cost) AS stock_cost,
SUM(profit_30d) AS profit_30d, SUM(profit_30d) AS profit_30d,
@@ -319,17 +326,17 @@ router.get('/efficiency', async (req, res) => {
END AS gmroi END AS gmroi
FROM product_metrics FROM product_metrics
WHERE is_visible = true WHERE is_visible = true
AND vendor IS NOT NULL AND brand IS NOT NULL
AND current_stock_cost > 0 AND current_stock_cost > 0
GROUP BY vendor GROUP BY brand
HAVING SUM(current_stock_cost) > 100 HAVING SUM(current_stock_cost) > 100
ORDER BY SUM(current_stock_cost) DESC ORDER BY SUM(current_stock_cost) DESC
LIMIT 30 LIMIT 30
`); `);
res.json({ res.json({
vendors: rows.map(r => ({ brands: rows.map(r => ({
vendor: r.vendor_name, brand: r.brand_name,
productCount: Number(r.product_count) || 0, productCount: Number(r.product_count) || 0,
stockCost: Number(r.stock_cost) || 0, stockCost: Number(r.stock_cost) || 0,
profit30d: Number(r.profit_30d) || 0, profit30d: Number(r.profit_30d) || 0,
@@ -527,7 +534,7 @@ router.get('/stockout-risk', async (req, res) => {
const { rows } = await pool.query(` const { rows } = await pool.query(`
WITH base AS ( WITH base AS (
SELECT SELECT
title, sku, vendor, title, sku, brand,
${leadTimeSql} AS lead_time_days, ${leadTimeSql} AS lead_time_days,
sells_out_in_days, current_stock, sales_velocity_daily, sells_out_in_days, current_stock, sales_velocity_daily,
revenue_30d, abc_class revenue_30d, abc_class
@@ -554,7 +561,7 @@ router.get('/stockout-risk', async (req, res) => {
products: rows.map(r => ({ products: rows.map(r => ({
title: r.title, title: r.title,
sku: r.sku, sku: r.sku,
vendor: r.vendor, brand: r.brand,
leadTimeDays: Number(r.lead_time_days) || 0, leadTimeDays: Number(r.lead_time_days) || 0,
sellsOutInDays: Number(r.sells_out_in_days) || 0, sellsOutInDays: Number(r.sells_out_in_days) || 0,
currentStock: Number(r.current_stock) || 0, currentStock: Number(r.current_stock) || 0,
@@ -624,6 +631,7 @@ router.get('/growth', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
// ABC breakdown — only "comparable" products (sold in BOTH periods, i.e. growth != -100%)
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT SELECT
COALESCE(abc_class, 'N/A') AS abc_class, COALESCE(abc_class, 'N/A') AS abc_class,
@@ -645,21 +653,39 @@ router.get('/growth', async (req, res) => {
FROM product_metrics FROM product_metrics
WHERE is_visible = true WHERE is_visible = true
AND sales_growth_yoy IS NOT NULL AND sales_growth_yoy IS NOT NULL
AND sales_30d > 0
GROUP BY 1, 2, 3 GROUP BY 1, 2, 3
ORDER BY abc_class, sort_order ORDER BY abc_class, sort_order
`); `);
// Summary stats // Summary: comparable products (sold in both periods) with revenue-weighted avg
const { rows: [summary] } = await pool.query(` const { rows: [summary] } = await pool.query(`
SELECT SELECT
COUNT(*) AS total_with_yoy, COUNT(*) AS comparable_count,
COUNT(*) FILTER (WHERE sales_growth_yoy > 0) AS growing_count, COUNT(*) FILTER (WHERE sales_growth_yoy > 0) AS growing_count,
COUNT(*) FILTER (WHERE sales_growth_yoy <= 0) AS declining_count, COUNT(*) FILTER (WHERE sales_growth_yoy <= 0) AS declining_count,
ROUND(AVG(sales_growth_yoy)::numeric, 1) AS avg_growth, ROUND(
CASE WHEN SUM(revenue_30d) > 0
THEN SUM(sales_growth_yoy * revenue_30d) / SUM(revenue_30d)
ELSE 0
END::numeric, 1
) AS weighted_avg_growth,
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sales_growth_yoy)::numeric, 1) AS median_growth ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sales_growth_yoy)::numeric, 1) AS median_growth
FROM product_metrics FROM product_metrics
WHERE is_visible = true WHERE is_visible = true
AND sales_growth_yoy IS NOT NULL AND sales_growth_yoy IS NOT NULL
AND sales_30d > 0
`);
// Catalog turnover: new products (selling now, no sales last year) and discontinued (sold last year, not now)
const { rows: [turnover] } = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE sales_growth_yoy IS NULL AND sales_30d > 0 AND age_days < 365) AS new_products,
SUM(revenue_30d) FILTER (WHERE sales_growth_yoy IS NULL AND sales_30d > 0 AND age_days < 365) AS new_product_revenue,
COUNT(*) FILTER (WHERE sales_growth_yoy = -100) AS discontinued,
SUM(current_stock_cost) FILTER (WHERE sales_growth_yoy = -100 AND current_stock > 0) AS discontinued_stock_value
FROM product_metrics
WHERE is_visible = true
`); `);
res.json({ res.json({
@@ -671,12 +697,18 @@ router.get('/growth', async (req, res) => {
stockCost: Number(r.stock_cost) || 0, stockCost: Number(r.stock_cost) || 0,
})), })),
summary: { summary: {
totalWithYoy: Number(summary.total_with_yoy) || 0, comparableCount: Number(summary.comparable_count) || 0,
growingCount: Number(summary.growing_count) || 0, growingCount: Number(summary.growing_count) || 0,
decliningCount: Number(summary.declining_count) || 0, decliningCount: Number(summary.declining_count) || 0,
avgGrowth: Number(summary.avg_growth) || 0, weightedAvgGrowth: Number(summary.weighted_avg_growth) || 0,
medianGrowth: Number(summary.median_growth) || 0, medianGrowth: Number(summary.median_growth) || 0,
}, },
turnover: {
newProducts: Number(turnover.new_products) || 0,
newProductRevenue: Number(turnover.new_product_revenue) || 0,
discontinued: Number(turnover.discontinued) || 0,
discontinuedStockValue: Number(turnover.discontinued_stock_value) || 0,
},
}); });
} catch (error) { } catch (error) {
console.error('Error fetching growth data:', error); console.error('Error fetching growth data:', error);

File diff suppressed because it is too large Load Diff

View File

@@ -1190,39 +1190,68 @@ router.get('/pipeline', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
// Expected arrivals by week (ordered + electronically_sent with expected_date) // Stale PO filter (reused across queries)
const staleFilter = `
WITH stale AS (
SELECT po_id, pid
FROM purchase_orders po
WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent',
'electronically_ready_send', 'receiving_started')
AND po.expected_date IS NOT NULL
AND po.expected_date < CURRENT_DATE - INTERVAL '90 days'
AND EXISTS (
SELECT 1 FROM purchase_orders newer
WHERE newer.pid = po.pid
AND newer.status NOT IN ('canceled', 'done')
AND COALESCE(newer.date_ordered, newer.date_created)
> COALESCE(po.date_ordered, po.date_created)
)
)`;
// Expected arrivals by week (excludes stale POs)
const { rows: arrivals } = await pool.query(` const { rows: arrivals } = await pool.query(`
${staleFilter}
SELECT SELECT
DATE_TRUNC('week', expected_date)::date AS week, DATE_TRUNC('week', po.expected_date)::date AS week,
COUNT(DISTINCT po_id) AS po_count, COUNT(DISTINCT po.po_id) AS po_count,
ROUND(SUM(po_cost_price * ordered)::numeric, 0) AS expected_value, ROUND(SUM(po.po_cost_price * po.ordered)::numeric, 0) AS expected_value,
COUNT(DISTINCT vendor) AS vendor_count COUNT(DISTINCT po.vendor) AS vendor_count
FROM purchase_orders FROM purchase_orders po
WHERE status IN ('ordered', 'electronically_sent') WHERE po.status IN ('ordered', 'electronically_sent')
AND expected_date IS NOT NULL AND po.expected_date IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
GROUP BY 1 GROUP BY 1
ORDER BY 1 ORDER BY 1
`); `);
// Overdue POs (expected_date in the past) // Overdue POs (excludes stale)
const { rows: [overdue] } = await pool.query(` const { rows: [overdue] } = await pool.query(`
${staleFilter}
SELECT SELECT
COUNT(DISTINCT po_id) AS po_count, COUNT(DISTINCT po.po_id) AS po_count,
ROUND(COALESCE(SUM(po_cost_price * ordered), 0)::numeric, 0) AS total_value ROUND(COALESCE(SUM(po.po_cost_price * po.ordered), 0)::numeric, 0) AS total_value
FROM purchase_orders FROM purchase_orders po
WHERE status IN ('ordered', 'electronically_sent') WHERE po.status IN ('ordered', 'electronically_sent')
AND expected_date IS NOT NULL AND po.expected_date IS NOT NULL
AND expected_date < CURRENT_DATE AND po.expected_date < CURRENT_DATE
AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
`); `);
// Summary: all open POs // Summary: on-order value from product_metrics (FIFO-accurate), PO counts from purchase_orders with staleness filter
const { rows: [summary] } = await pool.query(` const { rows: [summary] } = await pool.query(`
${staleFilter}
SELECT SELECT
COUNT(DISTINCT po_id) AS total_open_pos, COUNT(DISTINCT po.po_id) AS total_open_pos,
ROUND(COALESCE(SUM(po_cost_price * ordered), 0)::numeric, 0) AS total_on_order_value, COUNT(DISTINCT po.vendor) AS vendor_count
COUNT(DISTINCT vendor) AS vendor_count FROM purchase_orders po
FROM purchase_orders WHERE po.status IN ('ordered', 'electronically_sent')
WHERE status IN ('ordered', 'electronically_sent') AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
`);
const { rows: [onOrderTotal] } = await pool.query(`
SELECT ROUND(COALESCE(SUM(on_order_cost), 0)::numeric, 0) AS total_on_order_value
FROM product_metrics
WHERE is_visible = true
`); `);
res.json({ res.json({
@@ -1238,7 +1267,7 @@ router.get('/pipeline', async (req, res) => {
}, },
summary: { summary: {
totalOpenPOs: Number(summary.total_open_pos) || 0, totalOpenPOs: Number(summary.total_open_pos) || 0,
totalOnOrderValue: Number(summary.total_on_order_value) || 0, totalOnOrderValue: Number(onOrderTotal.total_on_order_value) || 0,
vendorCount: Number(summary.vendor_count) || 0, vendorCount: Number(summary.vendor_count) || 0,
}, },
}); });

View File

@@ -19,8 +19,8 @@ import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency'; import { formatCurrency } from '@/utils/formatCurrency';
interface VendorData { interface BrandData {
vendor: string; brand: string;
productCount: number; productCount: number;
stockCost: number; stockCost: number;
profit30d: number; profit30d: number;
@@ -29,7 +29,7 @@ interface VendorData {
} }
interface EfficiencyData { interface EfficiencyData {
vendors: VendorData[]; brands: BrandData[];
} }
function getGmroiColor(gmroi: number): string { function getGmroiColor(gmroi: number): string {
@@ -79,8 +79,8 @@ export function CapitalEfficiency() {
// Top or bottom 15 by GMROI for bar chart // Top or bottom 15 by GMROI for bar chart
const sortedGmroi = gmroiView === 'top' const sortedGmroi = gmroiView === 'top'
? [...data.vendors].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15) ? [...data.brands].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15)
: [...data.vendors].sort((a, b) => a.gmroi - b.gmroi).slice(0, 15); : [...data.brands].sort((a, b) => a.gmroi - b.gmroi).slice(0, 15);
return ( return (
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
@@ -88,9 +88,9 @@ export function CapitalEfficiency() {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle>GMROI by Vendor</CardTitle> <CardTitle>GMROI by Brand</CardTitle>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Annualized gross margin return on investment (top 30 vendors by stock value) Annualized gross margin return on investment (top 30 brands by stock value)
</p> </p>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
@@ -117,17 +117,17 @@ export function CapitalEfficiency() {
<XAxis type="number" tick={{ fontSize: 11 }} /> <XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis <YAxis
type="category" type="category"
dataKey="vendor" dataKey="brand"
width={140} width={140}
tick={{ fontSize: 11 }} tick={{ fontSize: 11 }}
/> />
<Tooltip <Tooltip
content={({ active, payload }) => { content={({ active, payload }) => {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
const d = payload[0].payload as VendorData; const d = payload[0].payload as BrandData;
return ( return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm"> <div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.vendor}</p> <p className="font-medium mb-1">{d.brand}</p>
<p>GMROI: <span className="font-medium">{d.gmroi.toFixed(2)}</span></p> <p>GMROI: <span className="font-medium">{d.gmroi.toFixed(2)}</span></p>
<p>Stock Investment: {formatCurrency(d.stockCost)}</p> <p>Stock Investment: {formatCurrency(d.stockCost)}</p>
<p>Profit (30d): {formatCurrency(d.profit30d)}</p> <p>Profit (30d): {formatCurrency(d.profit30d)}</p>
@@ -150,7 +150,7 @@ export function CapitalEfficiency() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Investment vs Profit by Vendor</CardTitle> <CardTitle>Investment vs Profit by Brand</CardTitle>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Bubble size = product count. Ideal: high profit, low stock cost. Bubble size = product count. Ideal: high profit, low stock cost.
</p> </p>
@@ -179,10 +179,10 @@ export function CapitalEfficiency() {
<Tooltip <Tooltip
content={({ active, payload }) => { content={({ active, payload }) => {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
const d = payload[0].payload as VendorData; const d = payload[0].payload as BrandData;
return ( return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm"> <div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.vendor}</p> <p className="font-medium mb-1">{d.brand}</p>
<p>Stock Investment: {formatCurrency(d.stockCost)}</p> <p>Stock Investment: {formatCurrency(d.stockCost)}</p>
<p>Profit (30d): {formatCurrency(d.profit30d)}</p> <p>Profit (30d): {formatCurrency(d.profit30d)}</p>
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p> <p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
@@ -191,7 +191,7 @@ export function CapitalEfficiency() {
); );
}} }}
/> />
<Scatter data={data.vendors} fill={METRIC_COLORS.orders} fillOpacity={0.6} /> <Scatter data={data.brands} fill={METRIC_COLORS.orders} fillOpacity={0.6} />
</ScatterChart> </ScatterChart>
</ResponsiveContainer> </ResponsiveContainer>
</CardContent> </CardContent>

View File

@@ -12,7 +12,8 @@ import {
} from 'recharts'; } from 'recharts';
import config from '../../config'; import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { TrendingUp, TrendingDown } from 'lucide-react'; import { TrendingUp, TrendingDown, Plus, Archive } from 'lucide-react';
import { formatCurrency } from '@/utils/formatCurrency';
interface GrowthRow { interface GrowthRow {
abcClass: string; abcClass: string;
@@ -23,16 +24,24 @@ interface GrowthRow {
} }
interface GrowthSummary { interface GrowthSummary {
totalWithYoy: number; comparableCount: number;
growingCount: number; growingCount: number;
decliningCount: number; decliningCount: number;
avgGrowth: number; weightedAvgGrowth: number;
medianGrowth: number; medianGrowth: number;
} }
interface CatalogTurnover {
newProducts: number;
newProductRevenue: number;
discontinued: number;
discontinuedStockValue: number;
}
interface GrowthData { interface GrowthData {
byClass: GrowthRow[]; byClass: GrowthRow[];
summary: GrowthSummary; summary: GrowthSummary;
turnover: CatalogTurnover;
} }
const GROWTH_COLORS: Record<string, string> = { const GROWTH_COLORS: Record<string, string> = {
@@ -78,9 +87,9 @@ export function GrowthMomentum() {
); );
} }
const { summary } = data; const { summary, turnover } = data;
const growthPct = summary.totalWithYoy > 0 const growingPct = summary.comparableCount > 0
? ((summary.growingCount / summary.totalWithYoy) * 100).toFixed(0) ? ((summary.growingCount / summary.comparableCount) * 100).toFixed(0)
: '0'; : '0';
// Pivot: for each ABC class, show product counts by growth bucket // Pivot: for each ABC class, show product counts by growth bucket
@@ -97,6 +106,7 @@ export function GrowthMomentum() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Row 1: Comparable growth metrics */}
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
<Card> <Card>
<CardContent className="flex items-center gap-3 py-4"> <CardContent className="flex items-center gap-3 py-4">
@@ -104,9 +114,9 @@ export function GrowthMomentum() {
<TrendingUp className="h-4 w-4 text-green-500" /> <TrendingUp className="h-4 w-4 text-green-500" />
</div> </div>
<div> <div>
<p className="text-sm font-medium">Growing</p> <p className="text-sm font-medium">Comparable Growing</p>
<p className="text-xl font-bold">{growthPct}%</p> <p className="text-xl font-bold">{growingPct}%</p>
<p className="text-xs text-muted-foreground">{summary.growingCount.toLocaleString()} products</p> <p className="text-xs text-muted-foreground">{summary.growingCount.toLocaleString()} of {summary.comparableCount.toLocaleString()} products</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -116,18 +126,19 @@ export function GrowthMomentum() {
<TrendingDown className="h-4 w-4 text-red-500" /> <TrendingDown className="h-4 w-4 text-red-500" />
</div> </div>
<div> <div>
<p className="text-sm font-medium">Declining</p> <p className="text-sm font-medium">Comparable Declining</p>
<p className="text-xl font-bold">{summary.decliningCount.toLocaleString()}</p> <p className="text-xl font-bold">{summary.decliningCount.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">products</p> <p className="text-xs text-muted-foreground">products with lower YoY sales</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="py-4"> <CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">Avg YoY Growth</p> <p className="text-sm font-medium text-muted-foreground">Weighted Avg Growth</p>
<p className={`text-2xl font-bold ${summary.avgGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}> <p className={`text-2xl font-bold ${summary.weightedAvgGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{summary.avgGrowth > 0 ? '+' : ''}{summary.avgGrowth}% {summary.weightedAvgGrowth > 0 ? '+' : ''}{summary.weightedAvgGrowth}%
</p> </p>
<p className="text-xs text-muted-foreground">revenue-weighted</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -136,16 +147,49 @@ export function GrowthMomentum() {
<p className={`text-2xl font-bold ${summary.medianGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}> <p className={`text-2xl font-bold ${summary.medianGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{summary.medianGrowth > 0 ? '+' : ''}{summary.medianGrowth}% {summary.medianGrowth > 0 ? '+' : ''}{summary.medianGrowth}%
</p> </p>
<p className="text-xs text-muted-foreground">{summary.totalWithYoy.toLocaleString()} products tracked</p> <p className="text-xs text-muted-foreground">typical product growth</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Row 2: Catalog turnover */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-blue-500/10">
<Plus className="h-4 w-4 text-blue-500" />
</div>
<div>
<p className="text-sm font-medium">New Products (&lt;1yr)</p>
<p className="text-xl font-bold">{turnover.newProducts.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{formatCurrency(turnover.newProductRevenue)} revenue (30d)</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-amber-500/10">
<Archive className="h-4 w-4 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium">Discontinued</p>
<p className="text-xl font-bold">{turnover.discontinued.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">
{turnover.discontinuedStockValue > 0
? `${formatCurrency(turnover.discontinuedStockValue)} still in stock`
: 'no remaining stock'}
</p>
</div>
</CardContent>
</Card>
</div>
{/* Chart: comparable products only */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Growth Distribution by ABC Class</CardTitle> <CardTitle>Comparable Growth by ABC Class</CardTitle>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Year-over-year sales growth segmented by product importance Products selling in both this and last year's period — excludes new launches and discontinued
</p> </p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@@ -18,7 +18,7 @@ import { formatCurrency } from '@/utils/formatCurrency';
interface RiskProduct { interface RiskProduct {
title: string; title: string;
sku: string; sku: string;
vendor: string; brand: string;
leadTimeDays: number; leadTimeDays: number;
sellsOutInDays: number; sellsOutInDays: number;
currentStock: number; currentStock: number;

View File

@@ -4,7 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
interface Product { interface Product {
pid: number; pid: number;
@@ -22,7 +22,6 @@ interface Category {
units_sold: number; units_sold: number;
revenue: string; revenue: string;
profit: string; profit: string;
growth_rate: string;
} }
interface BestSellerBrand { interface BestSellerBrand {
@@ -39,14 +38,22 @@ interface BestSellersData {
categories: Category[] categories: Category[]
} }
function TableSkeleton() {
return (
<div className="space-y-3 p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
))}
</div>
);
}
export function BestSellers() { export function BestSellers() {
const { data } = useQuery<BestSellersData>({ const { data, isError, isLoading } = useQuery<BestSellersData>({
queryKey: ["best-sellers"], queryKey: ["best-sellers"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/best-sellers`) const response = await fetch(`${config.apiUrl}/dashboard/best-sellers`)
if (!response.ok) { if (!response.ok) throw new Error("Failed to fetch best sellers");
throw new Error("Failed to fetch best sellers")
}
return response.json() return response.json()
}, },
}) })
@@ -65,109 +72,119 @@ export function BestSellers() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<TabsContent value="products"> {isError ? (
<ScrollArea className="h-[385px] w-full"> <p className="text-sm text-destructive">Failed to load best sellers</p>
<Table> ) : isLoading ? (
<TableHeader> <TableSkeleton />
<TableRow> ) : (
<TableHead>Product</TableHead> <>
<TableHead className="text-right">Units Sold</TableHead> <TabsContent value="products">
<TableHead className="text-right">Revenue</TableHead> <ScrollArea className="h-[385px] w-full">
<TableHead className="text-right">Profit</TableHead> <Table>
</TableRow> <TableHeader>
</TableHeader> <TableRow>
<TableBody> <TableHead>Product</TableHead>
{data?.products.map((product) => ( <TableHead className="text-right">Units Sold</TableHead>
<TableRow key={product.pid}> <TableHead className="text-right">Revenue</TableHead>
<TableCell> <TableHead className="text-right">Profit</TableHead>
<a </TableRow>
href={`https://backend.acherryontop.com/product/${product.pid}`} </TableHeader>
target="_blank" <TableBody>
rel="noopener noreferrer" {data?.products.map((product) => (
className="hover:underline" <TableRow key={product.pid}>
> <TableCell>
{product.title} <a
</a> href={`https://backend.acherryontop.com/product/${product.pid}`}
<div className="text-sm text-muted-foreground">{product.sku}</div> target="_blank"
</TableCell> rel="noopener noreferrer"
<TableCell className="text-right">{product.units_sold}</TableCell> className="hover:underline"
<TableCell className="text-right">{formatCurrency(Number(product.revenue))}</TableCell> >
<TableCell className="text-right">{formatCurrency(Number(product.profit))}</TableCell> {product.title}
</TableRow> </a>
))} <div className="text-sm text-muted-foreground">{product.sku}</div>
</TableBody> </TableCell>
</Table> <TableCell className="text-right">{product.units_sold}</TableCell>
</ScrollArea> <TableCell className="text-right">{formatCurrency(Number(product.revenue))}</TableCell>
</TabsContent> <TableCell className="text-right">{formatCurrency(Number(product.profit))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</TabsContent>
<TabsContent value="brands"> <TabsContent value="brands">
<ScrollArea className="h-[400px] w-full"> <ScrollArea className="h-[400px] w-full">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[40%]">Brand</TableHead> <TableHead className="w-[40%]">Brand</TableHead>
<TableHead className="w-[15%] text-right">Sales</TableHead> <TableHead className="w-[15%] text-right">Sales</TableHead>
<TableHead className="w-[15%] text-right">Revenue</TableHead> <TableHead className="w-[15%] text-right">Revenue</TableHead>
<TableHead className="w-[15%] text-right">Profit</TableHead> <TableHead className="w-[15%] text-right">Profit</TableHead>
<TableHead className="w-[15%] text-right">Growth</TableHead> <TableHead className="w-[15%] text-right">Growth</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data?.brands.map((brand) => ( {data?.brands.map((brand) => (
<TableRow key={brand.brand}> <TableRow key={brand.brand}>
<TableCell className="w-[40%]"> <TableCell className="w-[40%]">
<p className="font-medium">{brand.brand}</p> <p className="font-medium">{brand.brand}</p>
</TableCell> </TableCell>
<TableCell className="w-[15%] text-right"> <TableCell className="w-[15%] text-right">
{brand.units_sold.toLocaleString()} {brand.units_sold.toLocaleString()}
</TableCell> </TableCell>
<TableCell className="w-[15%] text-right"> <TableCell className="w-[15%] text-right">
{formatCurrency(Number(brand.revenue))} {formatCurrency(Number(brand.revenue))}
</TableCell> </TableCell>
<TableCell className="w-[15%] text-right"> <TableCell className="w-[15%] text-right">
{formatCurrency(Number(brand.profit))} {formatCurrency(Number(brand.profit))}
</TableCell> </TableCell>
<TableCell className="w-[15%] text-right"> <TableCell className="w-[15%] text-right">
{Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}% {brand.growth_rate != null ? (
</TableCell> <>{Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}%</>
</TableRow> ) : '-'}
))} </TableCell>
</TableBody> </TableRow>
</Table> ))}
</ScrollArea> </TableBody>
</TabsContent> </Table>
</ScrollArea>
</TabsContent>
<TabsContent value="categories"> <TabsContent value="categories">
<ScrollArea className="h-[400px] w-full"> <ScrollArea className="h-[400px] w-full">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Category</TableHead> <TableHead>Category</TableHead>
<TableHead className="text-right">Units Sold</TableHead> <TableHead className="text-right">Units Sold</TableHead>
<TableHead className="text-right">Revenue</TableHead> <TableHead className="text-right">Revenue</TableHead>
<TableHead className="text-right">Profit</TableHead> <TableHead className="text-right">Profit</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data?.categories.map((category) => ( {data?.categories.map((category) => (
<TableRow key={category.cat_id}> <TableRow key={category.cat_id}>
<TableCell> <TableCell>
<div className="font-medium">{category.name}</div> <div className="font-medium">{category.name}</div>
{category.categoryPath && ( {category.categoryPath && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{category.categoryPath} {category.categoryPath}
</div> </div>
)} )}
</TableCell> </TableCell>
<TableCell className="text-right">{category.units_sold}</TableCell> <TableCell className="text-right">{category.units_sold}</TableCell>
<TableCell className="text-right">{formatCurrency(Number(category.revenue))}</TableCell> <TableCell className="text-right">{formatCurrency(Number(category.revenue))}</TableCell>
<TableCell className="text-right">{formatCurrency(Number(category.profit))}</TableCell> <TableCell className="text-right">{formatCurrency(Number(category.profit))}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
</>
)}
</CardContent> </CardContent>
</Tabs> </Tabs>
</> </>

View File

@@ -3,7 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts" import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts"
import { useState } from "react" import { useState } from "react"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
import { TrendingUp, DollarSign } from "lucide-react" import { TrendingUp, DollarSign } from "lucide-react"
import { DateRange } from "react-day-picker" import { DateRange } from "react-day-picker"
import { addDays, format } from "date-fns" import { addDays, format } from "date-fns"

View File

@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react" import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react"
interface OverstockMetricsData { interface OverstockMetricsData {
@@ -18,15 +18,17 @@ interface OverstockMetricsData {
}[] }[]
} }
function MetricSkeleton() {
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
}
export function OverstockMetrics() { export function OverstockMetrics() {
const { data } = useQuery<OverstockMetricsData>({ const { data, isError, isLoading } = useQuery<OverstockMetricsData>({
queryKey: ["overstock-metrics"], queryKey: ["overstock-metrics"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/overstock/metrics`) const response = await fetch(`${config.apiUrl}/dashboard/overstock/metrics`)
if (!response.ok) { if (!response.ok) throw new Error('Failed to fetch overstock metrics');
throw new Error("Failed to fetch overstock metrics") return response.json();
}
return response.json()
}, },
}) })
@@ -36,36 +38,48 @@ export function OverstockMetrics() {
<CardTitle className="text-xl font-medium">Overstock</CardTitle> <CardTitle className="text-xl font-medium">Overstock</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col gap-4"> {isError ? (
<div className="flex items-baseline justify-between"> <p className="text-sm text-destructive">Failed to load overstock metrics</p>
<div className="flex items-center gap-2"> ) : (
<Package className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-col gap-4">
<p className="text-sm font-medium text-muted-foreground">Overstocked Products</p> <div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Overstocked Products</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.overstockedProducts.toLocaleString()}</p>
)}
</div> </div>
<p className="text-lg font-bold">{data?.overstockedProducts.toLocaleString() || 0}</p> <div className="flex items-baseline justify-between">
</div> <div className="flex items-center gap-2">
<div className="flex items-baseline justify-between"> <Layers className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-2"> <p className="text-sm font-medium text-muted-foreground">Overstocked Units</p>
<Layers className="h-4 w-4 text-muted-foreground" /> </div>
<p className="text-sm font-medium text-muted-foreground">Overstocked Units</p> {isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.total_excess_units.toLocaleString()}</p>
)}
</div> </div>
<p className="text-lg font-bold">{data?.total_excess_units.toLocaleString() || 0}</p> <div className="flex items-baseline justify-between">
</div> <div className="flex items-center gap-2">
<div className="flex items-baseline justify-between"> <DollarSign className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-2"> <p className="text-sm font-medium text-muted-foreground">Overstocked Cost</p>
<DollarSign className="h-4 w-4 text-muted-foreground" /> </div>
<p className="text-sm font-medium text-muted-foreground">Overstocked Cost</p> {isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.total_excess_cost)}</p>
)}
</div> </div>
<p className="text-lg font-bold">{formatCurrency(data?.total_excess_cost || 0)}</p> <div className="flex items-baseline justify-between">
</div> <div className="flex items-center gap-2">
<div className="flex items-baseline justify-between"> <ShoppingCart className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-2"> <p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p>
<ShoppingCart className="h-4 w-4 text-muted-foreground" /> </div>
<p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p> {isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.total_excess_retail)}</p>
)}
</div> </div>
<p className="text-lg font-bold">{formatCurrency(data?.total_excess_retail || 0)}</p>
</div> </div>
</div> )}
</CardContent> </CardContent>
</> </>
) )

View File

@@ -1,66 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import config from '../../config';
interface SalesData {
date: string;
total: number;
}
export function Overview() {
const { data, isLoading, error } = useQuery<SalesData[]>({
queryKey: ['sales-overview'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/sales-overview`);
if (!response.ok) {
throw new Error('Failed to fetch sales overview');
}
const rawData = await response.json();
return rawData.map((item: SalesData) => ({
...item,
total: parseFloat(item.total.toString()),
date: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}));
},
});
if (isLoading) {
return <div>Loading chart...</div>;
}
if (error) {
return <div className="text-red-500">Error loading sales overview</div>;
}
return (
<ResponsiveContainer width="100%" height={350}>
<LineChart data={data}>
<XAxis
dataKey="date"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `$${value.toLocaleString()}`}
/>
<Tooltip
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales']}
labelFormatter={(label) => `Date: ${label}`}
/>
<Line
type="monotone"
dataKey="total"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -2,16 +2,16 @@ import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts" import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react"
import { useState } from "react" import { useState } from "react"
interface PurchaseMetricsData { interface PurchaseMetricsData {
activePurchaseOrders: number // Orders that are not canceled, done, or fully received activePurchaseOrders: number
overduePurchaseOrders: number // Orders past their expected delivery date overduePurchaseOrders: number
onOrderUnits: number // Total units across all active orders onOrderUnits: number
onOrderCost: number // Total cost across all active orders onOrderCost: number
onOrderRetail: number // Total retail value across all active orders onOrderRetail: number
vendorOrders: { vendorOrders: {
vendor: string vendor: string
orders: number orders: number
@@ -35,7 +35,6 @@ const COLORS = [
const renderActiveShape = (props: any) => { const renderActiveShape = (props: any) => {
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, vendor, cost } = props; const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, vendor, cost } = props;
// Split vendor name into words and create lines of max 12 chars
const words = vendor.split(' '); const words = vendor.split(' ');
const lines: string[] = []; const lines: string[] = [];
let currentLine = ''; let currentLine = '';
@@ -52,150 +51,135 @@ const renderActiveShape = (props: any) => {
return ( return (
<g> <g>
<Sector <Sector cx={cx} cy={cy} innerRadius={innerRadius} outerRadius={outerRadius} startAngle={startAngle} endAngle={endAngle} fill={fill} />
cx={cx} <Sector cx={cx} cy={cy} startAngle={startAngle} endAngle={endAngle} innerRadius={outerRadius - 1} outerRadius={outerRadius + 4} fill={fill} />
cy={cy}
innerRadius={innerRadius}
outerRadius={outerRadius}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
<Sector
cx={cx}
cy={cy}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={outerRadius - 1}
outerRadius={outerRadius + 4}
fill={fill}
/>
{lines.map((line, i) => ( {lines.map((line, i) => (
<text <text key={i} x={cx} y={cy} dy={-20 + (i * 16)} textAnchor="middle" fill="#888888" className="text-xs">
key={i}
x={cx}
y={cy}
dy={-20 + (i * 16)}
textAnchor="middle"
fill="#888888"
className="text-xs"
>
{line} {line}
</text> </text>
))} ))}
<text <text x={cx} y={cy} dy={lines.length * 16 - 10} textAnchor="middle" fill="#000000" className="text-base font-medium">
x={cx}
y={cy}
dy={lines.length * 16 - 10}
textAnchor="middle"
fill="#000000"
className="text-base font-medium"
>
{formatCurrency(cost)} {formatCurrency(cost)}
</text> </text>
</g> </g>
); );
}; };
function MetricSkeleton() {
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
}
export function PurchaseMetrics() { export function PurchaseMetrics() {
const [activeIndex, setActiveIndex] = useState<number | undefined>(); const [activeIndex, setActiveIndex] = useState<number | undefined>();
const { data, error, isLoading } = useQuery<PurchaseMetricsData>({ const { data, isError, isLoading } = useQuery<PurchaseMetricsData>({
queryKey: ["purchase-metrics"], queryKey: ["purchase-metrics"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/purchase/metrics`) const response = await fetch(`${config.apiUrl}/dashboard/purchase/metrics`)
if (!response.ok) { if (!response.ok) throw new Error('Failed to fetch purchase metrics');
const text = await response.text(); return response.json();
console.error('API Error:', text);
throw new Error(`Failed to fetch purchase metrics: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data;
}, },
}) })
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading purchase metrics</div>;
return ( return (
<> <>
<CardHeader> <CardHeader>
<CardTitle className="text-xl font-medium">Purchases</CardTitle> <CardTitle className="text-xl font-medium">Purchases</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex justify-between gap-8"> {isError ? (
<div className="flex-1"> <p className="text-sm text-destructive">Failed to load purchase metrics</p>
<div className="flex flex-col gap-4"> ) : (
<div className="flex items-baseline justify-between"> <div className="flex justify-between gap-8">
<div className="flex items-center gap-2"> <div className="flex-1">
<ClipboardList className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-col gap-4">
<p className="text-sm font-medium text-muted-foreground">Active Purchase Orders</p> <div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Active Purchase Orders</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.activePurchaseOrders.toLocaleString()}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Overdue Purchase Orders</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.overduePurchaseOrders.toLocaleString()}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">On Order Units</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.onOrderUnits.toLocaleString()}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">On Order Cost</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.onOrderCost)}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">On Order Retail</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.onOrderRetail)}</p>
)}
</div> </div>
<p className="text-lg font-bold">{data?.activePurchaseOrders.toLocaleString() || 0}</p>
</div> </div>
<div className="flex items-baseline justify-between"> </div>
<div className="flex items-center gap-2"> <div className="flex-1">
<AlertCircle className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-col gap-1">
<p className="text-sm font-medium text-muted-foreground">Overdue Purchase Orders</p> <div className="text-md flex justify-center font-medium">Purchase Orders By Vendor</div>
<div className="h-[180px]">
{isLoading || !data ? (
<div className="flex h-full items-center justify-center">
<div className="h-[160px] w-[160px] animate-pulse rounded-full bg-muted" />
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data.vendorOrders}
dataKey="cost"
nameKey="vendor"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={1}
activeIndex={activeIndex}
activeShape={renderActiveShape}
onMouseEnter={(_, index) => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(undefined)}
>
{data.vendorOrders.map((entry, index) => (
<Cell
key={entry.vendor}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
)}
</div> </div>
<p className="text-lg font-bold">{data?.overduePurchaseOrders.toLocaleString() || 0}</p>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">On Order Units</p>
</div>
<p className="text-lg font-bold">{data?.onOrderUnits.toLocaleString() || 0}</p>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">On Order Cost</p>
</div>
<p className="text-lg font-bold">{formatCurrency(data?.onOrderCost || 0)}</p>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">On Order Retail</p>
</div>
<p className="text-lg font-bold">{formatCurrency(data?.onOrderRetail || 0)}</p>
</div> </div>
</div> </div>
</div> </div>
<div className="flex-1"> )}
<div className="flex flex-col gap-1">
<div className="text-md flex justify-center font-medium">Purchase Orders By Vendor</div>
<div className="h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data?.vendorOrders || []}
dataKey="cost"
nameKey="vendor"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={1}
activeIndex={activeIndex}
activeShape={renderActiveShape}
onMouseEnter={(_, index) => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(undefined)}
>
{data?.vendorOrders?.map((entry, index) => (
<Cell
key={entry.vendor}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
</CardContent> </CardContent>
</> </>
) )

View File

@@ -1,8 +1,8 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
import { Package, DollarSign, ShoppingCart } from "lucide-react" // Importing icons import { Package, DollarSign, ShoppingCart } from "lucide-react"
interface ReplenishmentMetricsData { interface ReplenishmentMetricsData {
productsToReplenish: number productsToReplenish: number
@@ -21,54 +21,59 @@ interface ReplenishmentMetricsData {
}[] }[]
} }
function MetricSkeleton() {
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
}
export function ReplenishmentMetrics() { export function ReplenishmentMetrics() {
const { data, error, isLoading } = useQuery<ReplenishmentMetricsData>({ const { data, isError, isLoading } = useQuery<ReplenishmentMetricsData>({
queryKey: ["replenishment-metrics"], queryKey: ["replenishment-metrics"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/replenishment/metrics`) const response = await fetch(`${config.apiUrl}/dashboard/replenishment/metrics`)
if (!response.ok) { if (!response.ok) throw new Error('Failed to fetch replenishment metrics');
const text = await response.text(); return response.json();
console.error('API Error:', text);
throw new Error(`Failed to fetch replenishment metrics: ${response.status} ${response.statusText} - ${text}`)
}
const data = await response.json();
return data;
}, },
}) })
if (isLoading) return <div className="p-8 text-center">Loading replenishment metrics...</div>;
if (error) return <div className="p-8 text-center text-red-500">Error: {error.message}</div>;
if (!data) return <div className="p-8 text-center">No replenishment data available</div>;
return ( return (
<> <>
<CardHeader> <CardHeader>
<CardTitle className="text-xl font-medium">Replenishment</CardTitle> <CardTitle className="text-xl font-medium">Replenishment</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col gap-4"> {isError ? (
<div className="flex items-baseline justify-between"> <p className="text-sm text-destructive">Failed to load replenishment metrics</p>
<div className="flex items-center gap-2"> ) : (
<Package className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-col gap-4">
<p className="text-sm font-medium text-muted-foreground">Units to Replenish</p> <div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Units to Replenish</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.unitsToReplenish.toLocaleString()}</p>
)}
</div> </div>
<p className="text-lg font-bold">{data.unitsToReplenish.toLocaleString() || 0}</p> <div className="flex items-baseline justify-between">
</div> <div className="flex items-center gap-2">
<div className="flex items-baseline justify-between"> <DollarSign className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-2"> <p className="text-sm font-medium text-muted-foreground">Replenishment Cost</p>
<DollarSign className="h-4 w-4 text-muted-foreground" /> </div>
<p className="text-sm font-medium text-muted-foreground">Replenishment Cost</p> {isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.replenishmentCost)}</p>
)}
</div> </div>
<p className="text-lg font-bold">{formatCurrency(data.replenishmentCost || 0)}</p> <div className="flex items-baseline justify-between">
</div> <div className="flex items-center gap-2">
<div className="flex items-baseline justify-between"> <ShoppingCart className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-2"> <p className="text-sm font-medium text-muted-foreground">Replenishment Retail</p>
<ShoppingCart className="h-4 w-4 text-muted-foreground" /> </div>
<p className="text-sm font-medium text-muted-foreground">Replenishment Retail</p> {isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.replenishmentRetail)}</p>
)}
</div> </div>
<p className="text-lg font-bold">{formatCurrency(data.replenishmentRetail || 0)}</p>
</div> </div>
</div> )}
</CardContent> </CardContent>
</> </>
) )

View File

@@ -3,7 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts" import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts"
import { useState } from "react" import { useState } from "react"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react" import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react"
import { DateRange } from "react-day-picker" import { DateRange } from "react-day-picker"
import { addDays, format } from "date-fns" import { addDays, format } from "date-fns"
@@ -12,23 +12,27 @@ import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
interface SalesData { interface SalesData {
totalOrders: number totalOrders: number
totalUnitsSold: number totalUnitsSold: number
totalCogs: string totalCogs: number
totalRevenue: string totalRevenue: number
dailySales: { dailySales: {
date: string date: string
units: number units: number
revenue: string revenue: number
cogs: string cogs: number
}[] }[]
} }
function MetricSkeleton() {
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
}
export function SalesMetrics() { export function SalesMetrics() {
const [dateRange, setDateRange] = useState<DateRange>({ const [dateRange, setDateRange] = useState<DateRange>({
from: addDays(new Date(), -30), from: addDays(new Date(), -30),
to: new Date(), to: new Date(),
}); });
const { data } = useQuery<SalesData>({ const { data, isError, isLoading } = useQuery<SalesData>({
queryKey: ["sales-metrics", dateRange], queryKey: ["sales-metrics", dateRange],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -36,9 +40,7 @@ export function SalesMetrics() {
endDate: dateRange.to?.toISOString() || "", endDate: dateRange.to?.toISOString() || "",
}); });
const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`) const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`)
if (!response.ok) { if (!response.ok) throw new Error("Failed to fetch sales metrics");
throw new Error("Failed to fetch sales metrics")
}
return response.json() return response.json()
}, },
}) })
@@ -58,69 +60,89 @@ export function SalesMetrics() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="py-0 -mb-2"> <CardContent className="py-0 -mb-2">
<div className="flex flex-col gap-4"> {isError ? (
<div className="flex items-baseline justify-between"> <p className="text-sm text-destructive">Failed to load sales metrics</p>
<div className="flex items-center gap-2"> ) : (
<ClipboardList className="h-4 w-4 text-muted-foreground" /> <>
<p className="text-sm font-medium text-muted-foreground">Total Orders</p> <div className="flex flex-col gap-4">
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Total Orders</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.totalOrders.toLocaleString()}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Units Sold</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.totalUnitsSold.toLocaleString()}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(Number(data.totalCogs))}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Revenue</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(Number(data.totalRevenue))}</p>
)}
</div>
</div> </div>
<p className="text-lg font-bold">{data?.totalOrders.toLocaleString() || 0}</p>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Units Sold</p>
</div>
<p className="text-lg font-bold">{data?.totalUnitsSold.toLocaleString() || 0}</p>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
</div>
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalCogs) || 0)}</p>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Revenue</p>
</div>
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalRevenue) || 0)}</p>
</div>
</div>
<div className="h-[250px] w-full"> <div className="h-[250px] w-full">
<ResponsiveContainer width="100%" height="100%"> {isLoading ? (
<AreaChart <div className="flex h-full items-center justify-center">
data={data?.dailySales || []} <div className="h-[200px] w-full animate-pulse rounded bg-muted" />
margin={{ top: 30, right: 0, left: -60, bottom: 0 }} </div>
> ) : (
<XAxis <ResponsiveContainer width="100%" height="100%">
dataKey="date" <AreaChart
tickLine={false} data={data?.dailySales || []}
axisLine={false} margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
tick={false} >
/> <XAxis
<YAxis dataKey="date"
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tick={false} tick={false}
/> />
<Tooltip <YAxis
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]} tickLine={false}
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')} axisLine={false}
/> tick={false}
<Area />
type="monotone" <Tooltip
dataKey="revenue" formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
name="Revenue" labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
stroke="#00C49F" />
fill="#00C49F" <Area
fillOpacity={0.2} type="monotone"
/> dataKey="revenue"
</AreaChart> name="Revenue"
</ResponsiveContainer> stroke="#00C49F"
</div> fill="#00C49F"
fillOpacity={0.2}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</>
)}
</CardContent> </CardContent>
</> </>
) )

View File

@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts" import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react" import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react"
import { useState } from "react" import { useState } from "react"
@@ -10,14 +10,14 @@ interface StockMetricsData {
totalProducts: number totalProducts: number
productsInStock: number productsInStock: number
totalStockUnits: number totalStockUnits: number
totalStockCost: string totalStockCost: number
totalStockRetail: string totalStockRetail: number
brandStock: { brandStock: {
brand: string brand: string
variants: number variants: number
units: number units: number
cost: string cost: number
retail: string retail: number
}[] }[]
} }
@@ -91,111 +91,127 @@ const renderActiveShape = (props: any) => {
fill="#000000" fill="#000000"
className="text-base font-medium" className="text-base font-medium"
> >
{formatCurrency(Number(retail))} {formatCurrency(retail)}
</text> </text>
</g> </g>
); );
}; };
function MetricSkeleton() {
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
}
export function StockMetrics() { export function StockMetrics() {
const [activeIndex, setActiveIndex] = useState<number | undefined>(); const [activeIndex, setActiveIndex] = useState<number | undefined>();
const { data, error, isLoading } = useQuery<StockMetricsData>({ const { data, isError, isLoading } = useQuery<StockMetricsData>({
queryKey: ["stock-metrics"], queryKey: ["stock-metrics"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`); const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`);
if (!response.ok) { if (!response.ok) throw new Error('Failed to fetch stock metrics');
const text = await response.text(); return response.json();
console.error('API Error:', text);
throw new Error(`Failed to fetch stock metrics: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data;
}, },
}); });
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading stock metrics</div>;
return ( return (
<> <>
<CardHeader> <CardHeader>
<CardTitle className="text-xl font-medium">Stock</CardTitle> <CardTitle className="text-xl font-medium">Stock</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex justify-between gap-8"> {isError ? (
<div className="flex-1"> <p className="text-sm text-destructive">Failed to load stock metrics</p>
<div className="flex flex-col gap-4"> ) : (
<div className="flex items-baseline justify-between"> <div className="flex justify-between gap-8">
<div className="flex items-center gap-2"> <div className="flex-1">
<Package className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-col gap-4">
<p className="text-sm font-medium text-muted-foreground">Products</p> <div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Products</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.totalProducts.toLocaleString()}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Products In Stock</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.productsInStock.toLocaleString()}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Stock Units</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{data.totalStockUnits.toLocaleString()}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Stock Cost</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.totalStockCost)}</p>
)}
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Stock Retail</p>
</div>
{isLoading || !data ? <MetricSkeleton /> : (
<p className="text-lg font-bold">{formatCurrency(data.totalStockRetail)}</p>
)}
</div> </div>
<p className="text-lg font-bold">{data?.totalProducts.toLocaleString() || 0}</p>
</div> </div>
<div className="flex items-baseline justify-between"> </div>
<div className="flex items-center gap-2"> <div className="flex-1">
<Layers className="h-4 w-4 text-muted-foreground" /> <div className="flex flex-col gap-1">
<p className="text-sm font-medium text-muted-foreground">Products In Stock</p> <div className="text-md flex justify-center font-medium">Stock Retail By Brand</div>
<div className="h-[180px]">
{isLoading || !data ? (
<div className="flex h-full items-center justify-center">
<div className="h-[160px] w-[160px] animate-pulse rounded-full bg-muted" />
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data.brandStock}
dataKey="retail"
nameKey="brand"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={1}
activeIndex={activeIndex}
activeShape={renderActiveShape}
onMouseEnter={(_, index) => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(undefined)}
>
{data.brandStock.map((entry, index) => (
<Cell
key={entry.brand}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
)}
</div> </div>
<p className="text-lg font-bold">{data?.productsInStock.toLocaleString() || 0}</p>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Stock Units</p>
</div>
<p className="text-lg font-bold">{data?.totalStockUnits.toLocaleString() || 0}</p>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Stock Cost</p>
</div>
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockCost) || 0)}</p>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Stock Retail</p>
</div>
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockRetail) || 0)}</p>
</div> </div>
</div> </div>
</div> </div>
<div className="flex-1"> )}
<div className="flex flex-col gap-1">
<div className="text-md flex justify-center font-medium">Stock Retail By Brand</div>
<div className="h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data?.brandStock || []}
dataKey="retail"
nameKey="brand"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={1}
activeIndex={activeIndex}
activeShape={renderActiveShape}
onMouseEnter={(_, index) => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(undefined)}
>
{data?.brandStock?.map((entry, index) => (
<Cell
key={entry.brand}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
</CardContent> </CardContent>
</> </>
) )

View File

@@ -3,7 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/utils/formatCurrency"
interface Product { interface Product {
pid: number; pid: number;
@@ -15,14 +15,22 @@ interface Product {
excess_retail: number; excess_retail: number;
} }
function TableSkeleton() {
return (
<div className="space-y-3 p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
))}
</div>
);
}
export function TopOverstockedProducts() { export function TopOverstockedProducts() {
const { data } = useQuery<Product[]>({ const { data, isError, isLoading } = useQuery<Product[]>({
queryKey: ["top-overstocked-products"], queryKey: ["top-overstocked-products"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`) const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`)
if (!response.ok) { if (!response.ok) throw new Error("Failed to fetch overstocked products");
throw new Error("Failed to fetch overstocked products")
}
return response.json() return response.json()
}, },
}) })
@@ -33,40 +41,46 @@ export function TopOverstockedProducts() {
<CardTitle className="text-xl font-medium">Top Overstocked Products</CardTitle> <CardTitle className="text-xl font-medium">Top Overstocked Products</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ScrollArea className="h-[300px] w-full"> {isError ? (
<Table> <p className="text-sm text-destructive">Failed to load overstocked products</p>
<TableHeader> ) : isLoading ? (
<TableRow> <TableSkeleton />
<TableHead>Product</TableHead> ) : (
<TableHead className="text-right">Stock</TableHead> <ScrollArea className="h-[300px] w-full">
<TableHead className="text-right">Excess</TableHead> <Table>
<TableHead className="text-right">Cost</TableHead> <TableHeader>
<TableHead className="text-right">Retail</TableHead> <TableRow>
</TableRow> <TableHead>Product</TableHead>
</TableHeader> <TableHead className="text-right">Stock</TableHead>
<TableBody> <TableHead className="text-right">Excess</TableHead>
{data?.map((product) => ( <TableHead className="text-right">Cost</TableHead>
<TableRow key={product.pid}> <TableHead className="text-right">Retail</TableHead>
<TableCell>
<a
href={`https://backend.acherryontop.com/product/${product.pid}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{product.title}
</a>
<div className="text-sm text-muted-foreground">{product.sku}</div>
</TableCell>
<TableCell className="text-right">{product.stock_quantity}</TableCell>
<TableCell className="text-right">{product.overstocked_amt}</TableCell>
<TableCell className="text-right">{formatCurrency(product.excess_cost)}</TableCell>
<TableCell className="text-right">{formatCurrency(product.excess_retail)}</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {data?.map((product) => (
</ScrollArea> <TableRow key={product.pid}>
<TableCell>
<a
href={`https://backend.acherryontop.com/product/${product.pid}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{product.title}
</a>
<div className="text-sm text-muted-foreground">{product.sku}</div>
</TableCell>
<TableCell className="text-right">{product.stock_quantity}</TableCell>
<TableCell className="text-right">{product.overstocked_amt}</TableCell>
<TableCell className="text-right">{formatCurrency(Number(product.excess_cost))}</TableCell>
<TableCell className="text-right">{formatCurrency(Number(product.excess_retail))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</CardContent> </CardContent>
</> </>
) )

View File

@@ -3,6 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import config from "@/config" import config from "@/config"
import { format } from "date-fns"
interface Product { interface Product {
pid: number; pid: number;
@@ -14,14 +15,22 @@ interface Product {
last_purchase_date: string | null; last_purchase_date: string | null;
} }
function TableSkeleton() {
return (
<div className="space-y-3 p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
))}
</div>
);
}
export function TopReplenishProducts() { export function TopReplenishProducts() {
const { data } = useQuery<Product[]>({ const { data, isError, isLoading } = useQuery<Product[]>({
queryKey: ["top-replenish-products"], queryKey: ["top-replenish-products"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`) const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`)
if (!response.ok) { if (!response.ok) throw new Error("Failed to fetch products to replenish");
throw new Error("Failed to fetch products to replenish")
}
return response.json() return response.json()
}, },
}) })
@@ -32,40 +41,46 @@ export function TopReplenishProducts() {
<CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle> <CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ScrollArea className="max-h-[530px] w-full overflow-y-auto"> {isError ? (
<Table> <p className="text-sm text-destructive">Failed to load replenish products</p>
<TableHeader> ) : isLoading ? (
<TableRow> <TableSkeleton />
<TableHead>Product</TableHead> ) : (
<TableHead className="text-right">Stock</TableHead> <ScrollArea className="max-h-[530px] w-full overflow-y-auto">
<TableHead className="text-right">Daily Sales</TableHead> <Table>
<TableHead className="text-right">Reorder Qty</TableHead> <TableHeader>
<TableHead>Last Purchase</TableHead> <TableRow>
</TableRow> <TableHead>Product</TableHead>
</TableHeader> <TableHead className="text-right">Stock</TableHead>
<TableBody> <TableHead className="text-right">Daily Sales</TableHead>
{data?.map((product) => ( <TableHead className="text-right">Reorder Qty</TableHead>
<TableRow key={product.pid}> <TableHead>Last Purchase</TableHead>
<TableCell>
<a
href={`https://backend.acherryontop.com/product/${product.pid}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{product.title}
</a>
<div className="text-sm text-muted-foreground">{product.sku}</div>
</TableCell>
<TableCell className="text-right">{product.stock_quantity}</TableCell>
<TableCell className="text-right">{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
<TableCell className="text-right">{product.reorder_qty}</TableCell>
<TableCell>{product.last_purchase_date ? product.last_purchase_date : '-'}</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {data?.map((product) => (
</ScrollArea> <TableRow key={product.pid}>
<TableCell>
<a
href={`https://backend.acherryontop.com/product/${product.pid}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{product.title}
</a>
<div className="text-sm text-muted-foreground">{product.sku}</div>
</TableCell>
<TableCell className="text-right">{product.stock_quantity}</TableCell>
<TableCell className="text-right">{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
<TableCell className="text-right">{product.reorder_qty}</TableCell>
<TableCell className="whitespace-nowrap">{product.last_purchase_date ? format(new Date(product.last_purchase_date), 'M/dd/yyyy') : '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</CardContent> </CardContent>
</> </>
) )

View File

@@ -1,79 +0,0 @@
import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Progress } from "@/components/ui/progress"
import config from "@/config"
interface VendorMetrics {
vendor: string
avg_lead_time: number
on_time_delivery_rate: number
avg_fill_rate: number
total_orders: number
active_orders: number
overdue_orders: number
}
export function VendorPerformance() {
const { data: vendors } = useQuery<VendorMetrics[]>({
queryKey: ["vendor-metrics"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/vendor/performance`)
if (!response.ok) {
throw new Error("Failed to fetch vendor metrics")
}
return response.json()
},
})
// Sort vendors by on-time delivery rate
const sortedVendors = vendors
?.sort((a, b) => b.on_time_delivery_rate - a.on_time_delivery_rate)
return (
<>
<CardHeader>
<CardTitle className="text-lg font-medium">Top Vendor Performance</CardTitle>
</CardHeader>
<CardContent className="max-h-[400px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Vendor</TableHead>
<TableHead>On-Time</TableHead>
<TableHead className="text-right">Fill Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedVendors?.map((vendor) => (
<TableRow key={vendor.vendor}>
<TableCell className="font-medium">{vendor.vendor}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress
value={vendor.on_time_delivery_rate}
className="h-2"
/>
<span className="w-10 text-sm">
{vendor.on_time_delivery_rate.toFixed(0)}%
</span>
</div>
</TableCell>
<TableCell className="text-right">
{vendor.avg_fill_rate.toFixed(0)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</>
)
}

View File

@@ -106,7 +106,7 @@ export function Analytics() {
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Stock Cover</CardTitle> <CardTitle className="text-sm font-medium">Median Stock Cover</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>