Clean up inventory overview page
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ 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(`
|
||||||
|
WITH agg AS (
|
||||||
SELECT
|
SELECT
|
||||||
SUM(current_stock_cost) AS stock_investment,
|
SUM(current_stock_cost) AS stock_investment,
|
||||||
SUM(on_order_cost) AS on_order_value,
|
SUM(on_order_cost) AS on_order_value,
|
||||||
@@ -184,17 +185,23 @@ router.get('/inventory-summary', async (req, res) => {
|
|||||||
THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12
|
THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END AS gmroi,
|
END AS gmroi,
|
||||||
CASE
|
|
||||||
WHEN SUM(CASE WHEN sales_velocity_daily > 0 THEN 1 ELSE 0 END) > 0
|
|
||||||
THEN SUM(CASE WHEN sales_velocity_daily > 0 THEN stock_cover_in_days ELSE 0 END)
|
|
||||||
/ SUM(CASE WHEN sales_velocity_daily > 0 THEN 1 ELSE 0 END)
|
|
||||||
ELSE 0
|
|
||||||
END AS avg_stock_cover_days,
|
|
||||||
COUNT(*) FILTER (WHERE current_stock > 0) AS products_in_stock,
|
COUNT(*) FILTER (WHERE current_stock > 0) AS products_in_stock,
|
||||||
COUNT(*) FILTER (WHERE is_old_stock = true) AS dead_stock_products,
|
COUNT(*) FILTER (WHERE is_old_stock = true AND current_stock > 0) AS dead_stock_products,
|
||||||
SUM(CASE WHEN is_old_stock = true THEN current_stock_cost ELSE 0 END) AS dead_stock_value
|
SUM(CASE WHEN is_old_stock = true AND current_stock > 0 THEN current_stock_cost ELSE 0 END) AS dead_stock_value
|
||||||
FROM product_metrics
|
FROM product_metrics
|
||||||
WHERE is_visible = true
|
WHERE is_visible = true
|
||||||
|
),
|
||||||
|
cover AS (
|
||||||
|
SELECT
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY stock_cover_in_days) AS median_stock_cover_days
|
||||||
|
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
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (<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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,6 +72,12 @@ export function BestSellers() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{isError ? (
|
||||||
|
<p className="text-sm text-destructive">Failed to load best sellers</p>
|
||||||
|
) : isLoading ? (
|
||||||
|
<TableSkeleton />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<TabsContent value="products">
|
<TabsContent value="products">
|
||||||
<ScrollArea className="h-[385px] w-full">
|
<ScrollArea className="h-[385px] w-full">
|
||||||
<Table>
|
<Table>
|
||||||
@@ -128,7 +141,9 @@ export function BestSellers() {
|
|||||||
{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 ? (
|
||||||
|
<>{Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}%</>
|
||||||
|
) : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -168,6 +183,8 @@ export function BestSellers() {
|
|||||||
</Table>
|
</Table>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{isError ? (
|
||||||
|
<p className="text-sm text-destructive">Failed to load overstock metrics</p>
|
||||||
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Products</p>
|
<p className="text-sm font-medium text-muted-foreground">Overstocked Products</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{data?.overstockedProducts.toLocaleString() || 0}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{data.overstockedProducts.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Units</p>
|
<p className="text-sm font-medium text-muted-foreground">Overstocked Units</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{data?.total_excess_units.toLocaleString() || 0}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{data.total_excess_units.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Cost</p>
|
<p className="text-sm font-medium text-muted-foreground">Overstocked Cost</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(data?.total_excess_cost || 0)}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data.total_excess_cost)}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p>
|
<p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(data?.total_excess_retail || 0)}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data.total_excess_retail)}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,77 +51,45 @@ 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>
|
||||||
|
{isError ? (
|
||||||
|
<p className="text-sm text-destructive">Failed to load purchase metrics</p>
|
||||||
|
) : (
|
||||||
<div className="flex justify-between gap-8">
|
<div className="flex justify-between gap-8">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -131,35 +98,45 @@ export function PurchaseMetrics() {
|
|||||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Active Purchase Orders</p>
|
<p className="text-sm font-medium text-muted-foreground">Active Purchase Orders</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{data?.activePurchaseOrders.toLocaleString() || 0}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{data.activePurchaseOrders.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Overdue Purchase Orders</p>
|
<p className="text-sm font-medium text-muted-foreground">Overdue Purchase Orders</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{data?.overduePurchaseOrders.toLocaleString() || 0}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{data.overduePurchaseOrders.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">On Order Units</p>
|
<p className="text-sm font-medium text-muted-foreground">On Order Units</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{data?.onOrderUnits.toLocaleString() || 0}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{data.onOrderUnits.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">On Order Cost</p>
|
<p className="text-sm font-medium text-muted-foreground">On Order Cost</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(data?.onOrderCost || 0)}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data.onOrderCost)}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">On Order Retail</p>
|
<p className="text-sm font-medium text-muted-foreground">On Order Retail</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(data?.onOrderRetail || 0)}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data.onOrderRetail)}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,10 +144,15 @@ export function PurchaseMetrics() {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="text-md flex justify-center font-medium">Purchase Orders By Vendor</div>
|
<div className="text-md flex justify-center font-medium">Purchase Orders By Vendor</div>
|
||||||
<div className="h-[180px]">
|
<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%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={data?.vendorOrders || []}
|
data={data.vendorOrders}
|
||||||
dataKey="cost"
|
dataKey="cost"
|
||||||
nameKey="vendor"
|
nameKey="vendor"
|
||||||
cx="50%"
|
cx="50%"
|
||||||
@@ -183,7 +165,7 @@ export function PurchaseMetrics() {
|
|||||||
onMouseEnter={(_, index) => setActiveIndex(index)}
|
onMouseEnter={(_, index) => setActiveIndex(index)}
|
||||||
onMouseLeave={() => setActiveIndex(undefined)}
|
onMouseLeave={() => setActiveIndex(undefined)}
|
||||||
>
|
>
|
||||||
{data?.vendorOrders?.map((entry, index) => (
|
{data.vendorOrders.map((entry, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={entry.vendor}
|
key={entry.vendor}
|
||||||
fill={COLORS[index % COLORS.length]}
|
fill={COLORS[index % COLORS.length]}
|
||||||
@@ -192,10 +174,12 @@ export function PurchaseMetrics() {
|
|||||||
</Pie>
|
</Pie>
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{isError ? (
|
||||||
|
<p className="text-sm text-destructive">Failed to load replenishment metrics</p>
|
||||||
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Units to Replenish</p>
|
<p className="text-sm font-medium text-muted-foreground">Units to Replenish</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{data.unitsToReplenish.toLocaleString() || 0}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{data.unitsToReplenish.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Replenishment Cost</p>
|
<p className="text-sm font-medium text-muted-foreground">Replenishment Cost</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(data.replenishmentCost || 0)}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data.replenishmentCost)}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Replenishment Retail</p>
|
<p className="text-sm font-medium text-muted-foreground">Replenishment Retail</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(data.replenishmentRetail || 0)}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data.replenishmentRetail)}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,38 +60,55 @@ export function SalesMetrics() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="py-0 -mb-2">
|
<CardContent className="py-0 -mb-2">
|
||||||
|
{isError ? (
|
||||||
|
<p className="text-sm text-destructive">Failed to load sales metrics</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Total Orders</p>
|
<p className="text-sm font-medium text-muted-foreground">Total Orders</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{data?.totalOrders.toLocaleString() || 0}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{data.totalOrders.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Units Sold</p>
|
<p className="text-sm font-medium text-muted-foreground">Units Sold</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{data?.totalUnitsSold.toLocaleString() || 0}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{data.totalUnitsSold.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
|
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalCogs) || 0)}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(Number(data.totalCogs))}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Revenue</p>
|
<p className="text-sm font-medium text-muted-foreground">Revenue</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalRevenue) || 0)}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(Number(data.totalRevenue))}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-[250px] w-full">
|
<div className="h-[250px] w-full">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="h-[200px] w-full animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart
|
<AreaChart
|
||||||
data={data?.dailySales || []}
|
data={data?.dailySales || []}
|
||||||
@@ -120,7 +139,10 @@ export function SalesMetrics() {
|
|||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,38 +91,37 @@ 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>
|
||||||
|
{isError ? (
|
||||||
|
<p className="text-sm text-destructive">Failed to load stock metrics</p>
|
||||||
|
) : (
|
||||||
<div className="flex justify-between gap-8">
|
<div className="flex justify-between gap-8">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -131,35 +130,45 @@ export function StockMetrics() {
|
|||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Products</p>
|
<p className="text-sm font-medium text-muted-foreground">Products</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{data?.totalProducts.toLocaleString() || 0}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{data.totalProducts.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Products In Stock</p>
|
<p className="text-sm font-medium text-muted-foreground">Products In Stock</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{data?.productsInStock.toLocaleString() || 0}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{data.productsInStock.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Stock Units</p>
|
<p className="text-sm font-medium text-muted-foreground">Stock Units</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{data?.totalStockUnits.toLocaleString() || 0}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{data.totalStockUnits.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Stock Cost</p>
|
<p className="text-sm font-medium text-muted-foreground">Stock Cost</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockCost) || 0)}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data.totalStockCost)}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Stock Retail</p>
|
<p className="text-sm font-medium text-muted-foreground">Stock Retail</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockRetail) || 0)}</p>
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data.totalStockRetail)}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,10 +176,15 @@ export function StockMetrics() {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="text-md flex justify-center font-medium">Stock Retail By Brand</div>
|
<div className="text-md flex justify-center font-medium">Stock Retail By Brand</div>
|
||||||
<div className="h-[180px]">
|
<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%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={data?.brandStock || []}
|
data={data.brandStock}
|
||||||
dataKey="retail"
|
dataKey="retail"
|
||||||
nameKey="brand"
|
nameKey="brand"
|
||||||
cx="50%"
|
cx="50%"
|
||||||
@@ -183,7 +197,7 @@ export function StockMetrics() {
|
|||||||
onMouseEnter={(_, index) => setActiveIndex(index)}
|
onMouseEnter={(_, index) => setActiveIndex(index)}
|
||||||
onMouseLeave={() => setActiveIndex(undefined)}
|
onMouseLeave={() => setActiveIndex(undefined)}
|
||||||
>
|
>
|
||||||
{data?.brandStock?.map((entry, index) => (
|
{data.brandStock.map((entry, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={entry.brand}
|
key={entry.brand}
|
||||||
fill={COLORS[index % COLORS.length]}
|
fill={COLORS[index % COLORS.length]}
|
||||||
@@ -192,10 +206,12 @@ export function StockMetrics() {
|
|||||||
</Pie>
|
</Pie>
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,6 +41,11 @@ 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>
|
||||||
|
{isError ? (
|
||||||
|
<p className="text-sm text-destructive">Failed to load overstocked products</p>
|
||||||
|
) : isLoading ? (
|
||||||
|
<TableSkeleton />
|
||||||
|
) : (
|
||||||
<ScrollArea className="h-[300px] w-full">
|
<ScrollArea className="h-[300px] w-full">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -60,13 +73,14 @@ export function TopOverstockedProducts() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||||
<TableCell className="text-right">{product.overstocked_amt}</TableCell>
|
<TableCell className="text-right">{product.overstocked_amt}</TableCell>
|
||||||
<TableCell className="text-right">{formatCurrency(product.excess_cost)}</TableCell>
|
<TableCell className="text-right">{formatCurrency(Number(product.excess_cost))}</TableCell>
|
||||||
<TableCell className="text-right">{formatCurrency(product.excess_retail)}</TableCell>
|
<TableCell className="text-right">{formatCurrency(Number(product.excess_retail))}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,6 +41,11 @@ 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>
|
||||||
|
{isError ? (
|
||||||
|
<p className="text-sm text-destructive">Failed to load replenish products</p>
|
||||||
|
) : isLoading ? (
|
||||||
|
<TableSkeleton />
|
||||||
|
) : (
|
||||||
<ScrollArea className="max-h-[530px] w-full overflow-y-auto">
|
<ScrollArea className="max-h-[530px] w-full overflow-y-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -60,12 +74,13 @@ export function TopReplenishProducts() {
|
|||||||
<TableCell className="text-right">{product.stock_quantity}</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">{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
|
||||||
<TableCell className="text-right">{product.reorder_qty}</TableCell>
|
<TableCell className="text-right">{product.reorder_qty}</TableCell>
|
||||||
<TableCell>{product.last_purchase_date ? product.last_purchase_date : '-'}</TableCell>
|
<TableCell className="whitespace-nowrap">{product.last_purchase_date ? format(new Date(product.last_purchase_date), 'M/dd/yyyy') : '-'}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user