2 Commits

Author SHA1 Message Date
12cc7a4639 Fixes for metrics calculations 2026-02-07 21:34:42 -05:00
9b2f9016f6 Redo analytics page 2026-02-07 13:44:51 -05:00
35 changed files with 2251 additions and 1912 deletions

View File

@@ -17,6 +17,33 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
const startTime = Date.now(); const startTime = Date.now();
const skippedOrders = new Set(); const skippedOrders = new Set();
const missingProducts = new Set(); const missingProducts = new Set();
// Map order status codes to text values (consistent with PO status mapping in purchase-orders.js)
const orderStatusMap = {
0: 'created',
10: 'unfinished',
15: 'canceled',
16: 'combined',
20: 'placed',
22: 'placed_incomplete',
30: 'canceled',
40: 'awaiting_payment',
50: 'awaiting_products',
55: 'shipping_later',
56: 'shipping_together',
60: 'ready',
61: 'flagged',
62: 'fix_before_pick',
65: 'manual_picking',
70: 'in_pt',
80: 'picked',
90: 'awaiting_shipment',
91: 'remote_wait',
92: 'awaiting_pickup',
93: 'fix_before_ship',
95: 'shipped_confirmed',
100: 'shipped'
};
let recordsAdded = 0; let recordsAdded = 0;
let recordsUpdated = 0; let recordsUpdated = 0;
let processedCount = 0; let processedCount = 0;
@@ -284,7 +311,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
new Date(order.date), // Convert to TIMESTAMP WITH TIME ZONE new Date(order.date), // Convert to TIMESTAMP WITH TIME ZONE
order.customer, order.customer,
toTitleCase(order.customer_name) || '', toTitleCase(order.customer_name) || '',
order.status.toString(), // Convert status to TEXT orderStatusMap[order.status] || order.status.toString(), // Map numeric status to text
order.canceled, order.canceled,
order.summary_discount || 0, order.summary_discount || 0,
order.summary_subtotal || 0, order.summary_subtotal || 0,
@@ -587,17 +614,14 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
oi.price, oi.price,
oi.quantity, oi.quantity,
( (
-- Part 1: Sale Savings for the Line -- Prorated Points Discount (e.g. loyalty points applied at order level)
(oi.base_discount * oi.quantity)
+
-- Part 2: Prorated Points Discount (if applicable)
CASE CASE
WHEN om.summary_discount_subtotal > 0 AND om.summary_subtotal > 0 THEN WHEN om.summary_discount_subtotal > 0 AND om.summary_subtotal > 0 THEN
COALESCE(ROUND((om.summary_discount_subtotal * (oi.price * oi.quantity)) / NULLIF(om.summary_subtotal, 0), 4), 0) COALESCE(ROUND((om.summary_discount_subtotal * (oi.price * oi.quantity)) / NULLIF(om.summary_subtotal, 0), 4), 0)
ELSE 0 ELSE 0
END END
+ +
-- Part 3: Specific Item-Level Discount (only if parent discount affected subtotal) -- Specific Item-Level Promo Discount (coupon codes, etc.)
COALESCE(ot.promo_discount_sum, 0) COALESCE(ot.promo_discount_sum, 0)
)::NUMERIC(14, 4) as discount, )::NUMERIC(14, 4) as discount,
COALESCE(ot.total_tax, 0)::NUMERIC(14, 4) as tax, COALESCE(ot.total_tax, 0)::NUMERIC(14, 4) as tax,
@@ -654,7 +678,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
o.shipping, o.shipping,
o.customer, o.customer,
o.customer_name, o.customer_name,
o.status.toString(), // Convert status to TEXT o.status, // Already mapped to text via orderStatusMap
o.canceled, o.canceled,
o.costeach o.costeach
]); ]);

View File

@@ -77,7 +77,6 @@ async function setupTemporaryTables(connection) {
created_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE,
date_online TIMESTAMP WITH TIME ZONE, date_online TIMESTAMP WITH TIME ZONE,
first_received TIMESTAMP WITH TIME ZONE, first_received TIMESTAMP WITH TIME ZONE,
landing_cost_price NUMERIC(14, 4),
barcode TEXT, barcode TEXT,
harmonized_tariff_code TEXT, harmonized_tariff_code TEXT,
updated_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE,
@@ -172,7 +171,6 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
) )
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1) ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
END AS cost_price, END AS cost_price,
NULL as landing_cost_price,
s.companyname AS vendor, s.companyname AS vendor,
CASE CASE
WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber
@@ -242,8 +240,8 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
const batch = prodData.slice(i, i + BATCH_SIZE); const batch = prodData.slice(i, i + BATCH_SIZE);
const placeholders = batch.map((_, idx) => { const placeholders = batch.map((_, idx) => {
const base = idx * 50; // 50 columns const base = idx * 49; // 49 columns
return `(${Array.from({ length: 50 }, (_, i) => `$${base + i + 1}`).join(', ')})`; return `(${Array.from({ length: 49 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
}).join(','); }).join(',');
const values = batch.flatMap(row => { const values = batch.flatMap(row => {
@@ -270,7 +268,6 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
validateDate(row.date_created), validateDate(row.date_created),
validateDate(row.date_ol), validateDate(row.date_ol),
validateDate(row.first_received), validateDate(row.first_received),
row.landing_cost_price,
row.barcode, row.barcode,
row.harmonized_tariff_code, row.harmonized_tariff_code,
validateDate(row.updated_at), validateDate(row.updated_at),
@@ -308,7 +305,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count, pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
price, regular_price, cost_price, vendor, vendor_reference, notions_reference, price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
brand, line, subline, artist, categories, created_at, date_online, first_received, brand, line, subline, artist, categories, created_at, date_online, first_received,
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible, barcode, harmonized_tariff_code, updated_at, visible,
managing_stock, replenishable, permalink, moq, uom, rating, reviews, managing_stock, replenishable, permalink, moq, uom, rating, reviews,
weight, length, width, height, country_of_origin, location, total_sold, weight, length, width, height, country_of_origin, location, total_sold,
baskets, notifies, date_last_sold, shop_score, primary_iid, image, image_175, image_full, options, tags baskets, notifies, date_last_sold, shop_score, primary_iid, image, image_175, image_full, options, tags
@@ -382,7 +379,6 @@ async function materializeCalculations(prodConnection, localConnection, incremen
) )
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1) ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
END AS cost_price, END AS cost_price,
NULL as landing_cost_price,
s.companyname AS vendor, s.companyname AS vendor,
CASE CASE
WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber
@@ -457,8 +453,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen
await withRetry(async () => { await withRetry(async () => {
const placeholders = batch.map((_, idx) => { const placeholders = batch.map((_, idx) => {
const base = idx * 50; // 50 columns const base = idx * 49; // 49 columns
return `(${Array.from({ length: 50 }, (_, i) => `$${base + i + 1}`).join(', ')})`; return `(${Array.from({ length: 49 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
}).join(','); }).join(',');
const values = batch.flatMap(row => { const values = batch.flatMap(row => {
@@ -485,7 +481,6 @@ async function materializeCalculations(prodConnection, localConnection, incremen
validateDate(row.date_created), validateDate(row.date_created),
validateDate(row.date_ol), validateDate(row.date_ol),
validateDate(row.first_received), validateDate(row.first_received),
row.landing_cost_price,
row.barcode, row.barcode,
row.harmonized_tariff_code, row.harmonized_tariff_code,
validateDate(row.updated_at), validateDate(row.updated_at),
@@ -522,7 +517,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count, pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
price, regular_price, cost_price, vendor, vendor_reference, notions_reference, price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
brand, line, subline, artist, categories, created_at, date_online, first_received, brand, line, subline, artist, categories, created_at, date_online, first_received,
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible, barcode, harmonized_tariff_code, updated_at, visible,
managing_stock, replenishable, permalink, moq, uom, rating, reviews, managing_stock, replenishable, permalink, moq, uom, rating, reviews,
weight, length, width, height, country_of_origin, location, total_sold, weight, length, width, height, country_of_origin, location, total_sold,
baskets, notifies, date_last_sold, shop_score, primary_iid, image, image_175, image_full, options, tags baskets, notifies, date_last_sold, shop_score, primary_iid, image, image_175, image_full, options, tags
@@ -547,7 +542,6 @@ async function materializeCalculations(prodConnection, localConnection, incremen
created_at = EXCLUDED.created_at, created_at = EXCLUDED.created_at,
date_online = EXCLUDED.date_online, date_online = EXCLUDED.date_online,
first_received = EXCLUDED.first_received, first_received = EXCLUDED.first_received,
landing_cost_price = EXCLUDED.landing_cost_price,
barcode = EXCLUDED.barcode, barcode = EXCLUDED.barcode,
harmonized_tariff_code = EXCLUDED.harmonized_tariff_code, harmonized_tariff_code = EXCLUDED.harmonized_tariff_code,
updated_at = EXCLUDED.updated_at, updated_at = EXCLUDED.updated_at,
@@ -702,7 +696,6 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
t.created_at, t.created_at,
t.date_online, t.date_online,
t.first_received, t.first_received,
t.landing_cost_price,
t.barcode, t.barcode,
t.harmonized_tariff_code, t.harmonized_tariff_code,
t.updated_at, t.updated_at,
@@ -742,8 +735,8 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
const batch = products.rows.slice(i, i + BATCH_SIZE); const batch = products.rows.slice(i, i + BATCH_SIZE);
const placeholders = batch.map((_, idx) => { const placeholders = batch.map((_, idx) => {
const base = idx * 49; // 49 columns const base = idx * 48; // 48 columns (no primary_iid in this INSERT)
return `(${Array.from({ length: 49 }, (_, i) => `$${base + i + 1}`).join(', ')})`; return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
}).join(','); }).join(',');
const values = batch.flatMap(row => { const values = batch.flatMap(row => {
@@ -770,7 +763,6 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
validateDate(row.created_at), validateDate(row.created_at),
validateDate(row.date_online), validateDate(row.date_online),
validateDate(row.first_received), validateDate(row.first_received),
row.landing_cost_price,
row.barcode, row.barcode,
row.harmonized_tariff_code, row.harmonized_tariff_code,
validateDate(row.updated_at), validateDate(row.updated_at),
@@ -807,7 +799,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count, pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
price, regular_price, cost_price, vendor, vendor_reference, notions_reference, price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
brand, line, subline, artist, categories, created_at, date_online, first_received, brand, line, subline, artist, categories, created_at, date_online, first_received,
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible, barcode, harmonized_tariff_code, updated_at, visible,
managing_stock, replenishable, permalink, moq, uom, rating, reviews, managing_stock, replenishable, permalink, moq, uom, rating, reviews,
weight, length, width, height, country_of_origin, location, total_sold, weight, length, width, height, country_of_origin, location, total_sold,
baskets, notifies, date_last_sold, shop_score, image, image_175, image_full, options, tags baskets, notifies, date_last_sold, shop_score, image, image_175, image_full, options, tags
@@ -833,7 +825,6 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
created_at = EXCLUDED.created_at, created_at = EXCLUDED.created_at,
date_online = EXCLUDED.date_online, date_online = EXCLUDED.date_online,
first_received = EXCLUDED.first_received, first_received = EXCLUDED.first_received,
landing_cost_price = EXCLUDED.landing_cost_price,
barcode = EXCLUDED.barcode, barcode = EXCLUDED.barcode,
harmonized_tariff_code = EXCLUDED.harmonized_tariff_code, harmonized_tariff_code = EXCLUDED.harmonized_tariff_code,
updated_at = EXCLUDED.updated_at, updated_at = EXCLUDED.updated_at,

View File

@@ -27,7 +27,7 @@ BEGIN
p.visible as is_visible, p.replenishable, p.visible as is_visible, p.replenishable,
COALESCE(p.price, 0.00) as current_price, COALESCE(p.regular_price, 0.00) as current_regular_price, COALESCE(p.price, 0.00) as current_price, COALESCE(p.regular_price, 0.00) as current_regular_price,
COALESCE(p.cost_price, 0.00) as current_cost_price, COALESCE(p.cost_price, 0.00) as current_cost_price,
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as current_effective_cost, -- Use landing if available, else cost COALESCE(p.cost_price, 0.00) as current_effective_cost,
p.stock_quantity as current_stock, -- Use actual current stock for forecast base p.stock_quantity as current_stock, -- Use actual current stock for forecast base
p.created_at, p.first_received, p.date_last_sold, p.created_at, p.first_received, p.date_last_sold,
p.moq, p.moq,

View File

@@ -10,7 +10,7 @@ DECLARE
_date DATE; _date DATE;
_count INT; _count INT;
_total_records INT := 0; _total_records INT := 0;
_begin_date DATE := (SELECT MIN(date)::date FROM orders WHERE date >= '2024-01-01'); -- Starting point for data rebuild _begin_date DATE := (SELECT MIN(date)::date FROM orders WHERE date >= '2020-01-01'); -- Starting point: captures all historical order data
_end_date DATE := CURRENT_DATE; _end_date DATE := CURRENT_DATE;
BEGIN BEGIN
RAISE NOTICE 'Beginning daily snapshots rebuild from % to %. Starting at %', _begin_date, _end_date, _start_time; RAISE NOTICE 'Beginning daily snapshots rebuild from % to %. Starting at %', _begin_date, _end_date, _start_time;
@@ -36,7 +36,7 @@ BEGIN
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold, COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted, COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts, COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.landing_cost_price, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs, COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue, COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue,
-- Aggregate Returns (Quantity < 0 or Status = Returned) -- Aggregate Returns (Quantity < 0 or Status = Returned)
@@ -68,7 +68,7 @@ BEGIN
SELECT SELECT
p.pid, p.pid,
p.stock_quantity, p.stock_quantity,
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as effective_cost_price, COALESCE(p.cost_price, 0.00) as effective_cost_price,
COALESCE(p.price, 0.00) as current_price, COALESCE(p.price, 0.00) as current_price,
COALESCE(p.regular_price, 0.00) as current_regular_price COALESCE(p.regular_price, 0.00) as current_regular_price
FROM public.products p FROM public.products p
@@ -111,7 +111,7 @@ BEGIN
COALESCE(sd.gross_revenue_unadjusted, 0.00), COALESCE(sd.gross_revenue_unadjusted, 0.00),
COALESCE(sd.discounts, 0.00), COALESCE(sd.discounts, 0.00),
COALESCE(sd.returns_revenue, 0.00), COALESCE(sd.returns_revenue, 0.00),
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue, COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00) AS net_revenue,
COALESCE(sd.cogs, 0.00), COALESCE(sd.cogs, 0.00),
COALESCE(sd.gross_regular_revenue, 0.00), COALESCE(sd.gross_regular_revenue, 0.00),
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit, (COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,

View File

@@ -28,8 +28,8 @@ BEGIN
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d, COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d, SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d, SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d, SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d, SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d, COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d, SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,

View File

@@ -28,8 +28,8 @@ BEGIN
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d, SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d, SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d, SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d, SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d, SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d, SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d, SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales, SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
@@ -38,58 +38,56 @@ BEGIN
JOIN public.product_metrics pm ON pc.pid = pm.pid JOIN public.product_metrics pm ON pc.pid = pm.pid
GROUP BY pc.cat_id GROUP BY pc.cat_id
), ),
-- Calculate rolled-up metrics (including all descendant categories) -- Map each category to ALL distinct products in it or any descendant.
-- Uses the path array from category_hierarchy: for product P in category C,
-- P contributes to C and every ancestor in C's path.
-- DISTINCT ensures each (ancestor, pid) pair appears only once, preventing
-- double-counting when a product belongs to multiple categories under the same parent.
CategoryProducts AS (
SELECT DISTINCT
ancestor_cat_id,
pc.pid
FROM public.product_categories pc
JOIN category_hierarchy ch ON pc.cat_id = ch.cat_id
CROSS JOIN LATERAL unnest(ch.path) AS ancestor_cat_id
),
-- Calculate rolled-up metrics using deduplicated product sets
RolledUpMetrics AS ( RolledUpMetrics AS (
SELECT SELECT
ch.cat_id, cp.ancestor_cat_id AS cat_id,
-- Sum metrics from this category and all its descendants COUNT(DISTINCT cp.pid) AS product_count,
SUM(dcm.product_count) AS product_count, COUNT(DISTINCT CASE WHEN pm.is_visible THEN cp.pid END) AS active_product_count,
SUM(dcm.active_product_count) AS active_product_count, COUNT(DISTINCT CASE WHEN pm.is_replenishable THEN cp.pid END) AS replenishable_product_count,
SUM(dcm.replenishable_product_count) AS replenishable_product_count, SUM(pm.current_stock) AS current_stock_units,
SUM(dcm.current_stock_units) AS current_stock_units, SUM(pm.current_stock_cost) AS current_stock_cost,
SUM(dcm.current_stock_cost) AS current_stock_cost, SUM(pm.current_stock_retail) AS current_stock_retail,
SUM(dcm.current_stock_retail) AS current_stock_retail, SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
SUM(dcm.sales_7d) AS sales_7d, SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
SUM(dcm.revenue_7d) AS revenue_7d, SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
SUM(dcm.sales_30d) AS sales_30d, SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
SUM(dcm.revenue_30d) AS revenue_30d, SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
SUM(dcm.cogs_30d) AS cogs_30d, SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
SUM(dcm.profit_30d) AS profit_30d, SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
SUM(dcm.sales_365d) AS sales_365d, SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
SUM(dcm.revenue_365d) AS revenue_365d, SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
SUM(dcm.lifetime_sales) AS lifetime_sales, SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
SUM(dcm.lifetime_revenue) AS lifetime_revenue FROM CategoryProducts cp
FROM category_hierarchy ch JOIN public.product_metrics pm ON cp.pid = pm.pid
LEFT JOIN DirectCategoryMetrics dcm ON GROUP BY cp.ancestor_cat_id
dcm.cat_id = ch.cat_id OR
dcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
GROUP BY ch.cat_id
),
PreviousPeriodCategoryMetrics AS (
-- Get previous period metrics for growth calculation
SELECT
pc.cat_id,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.units_sold ELSE 0 END) AS sales_prev_30d,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d
FROM public.daily_product_snapshots dps
JOIN public.product_categories pc ON dps.pid = pc.pid
GROUP BY pc.cat_id
), ),
-- Previous period rolled up using same deduplicated product sets
RolledUpPreviousPeriod AS ( RolledUpPreviousPeriod AS (
-- Calculate rolled-up previous period metrics
SELECT SELECT
ch.cat_id, cp.ancestor_cat_id AS cat_id,
SUM(ppcm.sales_prev_30d) AS sales_prev_30d, SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
SUM(ppcm.revenue_prev_30d) AS revenue_prev_30d AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
FROM category_hierarchy ch THEN dps.units_sold ELSE 0 END) AS sales_prev_30d,
LEFT JOIN PreviousPeriodCategoryMetrics ppcm ON SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
ppcm.cat_id = ch.cat_id OR AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
ppcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids)) THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d
GROUP BY ch.cat_id FROM CategoryProducts cp
JOIN public.daily_product_snapshots dps ON cp.pid = dps.pid
GROUP BY cp.ancestor_cat_id
), ),
AllCategories AS ( AllCategories AS (
-- Ensure all categories are included -- Ensure all categories are included

View File

@@ -29,8 +29,8 @@ BEGIN
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d, COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d, SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d, SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d, SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d, SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d, COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d, SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
@@ -72,7 +72,7 @@ BEGIN
END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs
FROM public.purchase_orders po FROM public.purchase_orders po
-- Join to receivings table to find when items were received -- Join to receivings table to find when items were received
LEFT JOIN public.receivings r ON r.pid = po.pid LEFT JOIN public.receivings r ON r.pid = po.pid AND r.supplier_id = po.supplier_id
WHERE po.vendor IS NOT NULL AND po.vendor <> '' WHERE po.vendor IS NOT NULL AND po.vendor <> ''
AND po.date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year AND po.date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year
AND po.status = 'done' -- Only calculate lead time on completed POs AND po.status = 'done' -- Only calculate lead time on completed POs

View File

@@ -0,0 +1,38 @@
-- Migration: Map existing numeric order statuses to text values
-- Run this ONCE on the production PostgreSQL database after deploying the updated orders import.
-- This updates ~2.88M rows. On a busy system, consider running during low-traffic hours.
-- The WHERE clause ensures idempotency - only rows with numeric statuses are updated.
UPDATE orders SET status = CASE status
WHEN '0' THEN 'created'
WHEN '10' THEN 'unfinished'
WHEN '15' THEN 'canceled'
WHEN '16' THEN 'combined'
WHEN '20' THEN 'placed'
WHEN '22' THEN 'placed_incomplete'
WHEN '30' THEN 'canceled'
WHEN '40' THEN 'awaiting_payment'
WHEN '50' THEN 'awaiting_products'
WHEN '55' THEN 'shipping_later'
WHEN '56' THEN 'shipping_together'
WHEN '60' THEN 'ready'
WHEN '61' THEN 'flagged'
WHEN '62' THEN 'fix_before_pick'
WHEN '65' THEN 'manual_picking'
WHEN '70' THEN 'in_pt'
WHEN '80' THEN 'picked'
WHEN '90' THEN 'awaiting_shipment'
WHEN '91' THEN 'remote_wait'
WHEN '92' THEN 'awaiting_pickup'
WHEN '93' THEN 'fix_before_ship'
WHEN '95' THEN 'shipped_confirmed'
WHEN '100' THEN 'shipped'
ELSE status
END
WHERE status ~ '^\d+$'; -- Only update rows that still have numeric statuses
-- Verify the migration
SELECT status, COUNT(*) as count
FROM orders
GROUP BY status
ORDER BY count DESC;

View File

@@ -0,0 +1,51 @@
-- Migration 002: Fix discount double-counting in orders
--
-- PROBLEM: The orders import was calculating discount as:
-- discount = (prod_price_reg - prod_price) * quantity <-- "sale savings" (WRONG)
-- + prorated points discount
-- + item-level promo discounts
--
-- Since `price` in the orders table already IS the sale price (prod_price, not prod_price_reg),
-- the "sale savings" component double-counted the markdown. This resulted in inflated discounts
-- and near-zero net_revenue for products sold on sale.
--
-- Example: Product with regular_price=$30, sale_price=$15, qty=2
-- BEFORE (buggy): discount = ($30-$15)*2 + 0 + 0 = $30.00
-- net_revenue = $15*2 - $30 = $0.00 (WRONG!)
-- AFTER (fixed): discount = 0 + 0 + 0 = $0.00
-- net_revenue = $15*2 - $0 = $30.00 (CORRECT!)
--
-- FIX: This cannot be fixed with a pure SQL migration because PostgreSQL doesn't store
-- prod_price_reg. The discount column has the inflated value baked in, and we can't
-- decompose which portion was the base_discount vs actual promo discounts.
--
-- REQUIRED ACTION: Run a FULL (non-incremental) orders re-import after deploying the
-- fixed orders.js. This will recalculate all discounts using the corrected formula.
--
-- Steps:
-- 1. Deploy updated orders.js (base_discount removed from discount calculation)
-- 2. Run: node scripts/import/orders.js --full
-- (or trigger a full sync through whatever mechanism is used)
-- 3. After re-import, run the daily snapshots rebuild to propagate corrected revenue:
-- psql -f scripts/metrics-new/backfill/rebuild_daily_snapshots.sql
-- 4. Re-run metrics calculation:
-- node scripts/metrics-new/calculate-metrics-new.js
--
-- VERIFICATION: After re-import, check the previously-affected products:
SELECT
o.pid,
p.title,
o.order_number,
o.price,
o.quantity,
o.discount,
(o.price * o.quantity) as gross_revenue,
(o.price * o.quantity - o.discount) as net_revenue
FROM orders o
JOIN products p ON o.pid = p.pid
WHERE o.pid IN (624756, 614513)
ORDER BY o.date DESC
LIMIT 10;
-- Expected: discount should be 0 (or small promo amount) for regular sales,
-- and net_revenue should be close to gross_revenue.

View File

@@ -1,75 +1,73 @@
-- Description: Calculates and updates daily aggregated product data for recent days. -- Description: Calculates and updates daily aggregated product data.
-- Uses UPSERT (INSERT ON CONFLICT UPDATE) for idempotency. -- Self-healing: automatically detects and fills gaps in snapshot history.
-- Always reprocesses recent days to pick up new orders and data corrections.
-- Dependencies: Core import tables (products, orders, purchase_orders), calculate_status table. -- Dependencies: Core import tables (products, orders, purchase_orders), calculate_status table.
-- Frequency: Hourly (Run ~5-10 minutes after hourly data import completes). -- Frequency: Hourly (Run ~5-10 minutes after hourly data import completes).
DO $$ DO $$
DECLARE DECLARE
_module_name TEXT := 'daily_snapshots'; _module_name TEXT := 'daily_snapshots';
_start_time TIMESTAMPTZ := clock_timestamp(); -- Time execution started _start_time TIMESTAMPTZ := clock_timestamp();
_last_calc_time TIMESTAMPTZ; _target_date DATE;
_target_date DATE; -- Will be set in the loop
_total_records INT := 0; _total_records INT := 0;
_has_orders BOOLEAN := FALSE; _days_processed INT := 0;
_process_days INT := 5; -- Number of days to check/process (today plus previous 4 days) _max_backfill_days INT := 90; -- Safety cap: max days to backfill per run
_day_counter INT; _recent_recheck_days INT := 2; -- Always reprocess this many recent days (today + yesterday)
_missing_days INT[] := ARRAY[]::INT[]; -- Array to store days with missing or incomplete data _latest_snapshot DATE;
_backfill_start DATE;
BEGIN BEGIN
-- Get the timestamp before the last successful run of this module
SELECT last_calculation_timestamp INTO _last_calc_time
FROM public.calculate_status
WHERE module_name = _module_name;
RAISE NOTICE 'Running % script. Start Time: %', _module_name, _start_time; RAISE NOTICE 'Running % script. Start Time: %', _module_name, _start_time;
-- First, check which days need processing by comparing orders data with snapshot data
FOR _day_counter IN 0..(_process_days-1) LOOP
_target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day');
-- Check if this date needs updating by comparing orders to snapshot data
-- If the date has orders but not enough snapshots, or if snapshots show zero sales but orders exist, it's incomplete
SELECT
CASE WHEN (
-- We have orders for this date but not enough snapshots, or snapshots with wrong total
(EXISTS (SELECT 1 FROM public.orders WHERE date::date = _target_date) AND
(
-- No snapshots exist for this date
NOT EXISTS (SELECT 1 FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) OR
-- Or snapshots show zero sales but orders exist
(SELECT COALESCE(SUM(units_sold), 0) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) = 0 OR
-- Or the count of snapshot records is significantly less than distinct products in orders
(SELECT COUNT(*) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) <
(SELECT COUNT(DISTINCT pid) FROM public.orders WHERE date::date = _target_date) * 0.8
)
)
) THEN TRUE ELSE FALSE END
INTO _has_orders;
IF _has_orders THEN
-- This day needs processing - add to our array
_missing_days := _missing_days || _day_counter;
RAISE NOTICE 'Day % needs updating (incomplete or missing data)', _target_date;
END IF;
END LOOP;
-- If no days need updating, exit early
IF array_length(_missing_days, 1) IS NULL THEN
RAISE NOTICE 'No days need updating - all snapshot data appears complete';
-- Still update the calculate_status to record this run
UPDATE public.calculate_status
SET last_calculation_timestamp = _start_time
WHERE module_name = _module_name;
RETURN;
END IF;
RAISE NOTICE 'Need to update % days with missing or incomplete data', array_length(_missing_days, 1);
-- Process only the days that need updating -- Find the latest existing snapshot date to determine where gaps begin
FOREACH _day_counter IN ARRAY _missing_days LOOP SELECT MAX(snapshot_date) INTO _latest_snapshot
_target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day'); FROM public.daily_product_snapshots;
RAISE NOTICE 'Processing date: %', _target_date;
-- Determine how far back to look for gaps, capped at _max_backfill_days
_backfill_start := GREATEST(
COALESCE(_latest_snapshot + 1, CURRENT_DATE - _max_backfill_days),
CURRENT_DATE - _max_backfill_days
);
IF _latest_snapshot IS NULL THEN
RAISE NOTICE 'No existing snapshots found. Backfilling up to % days.', _max_backfill_days;
ELSIF _backfill_start > _latest_snapshot + 1 THEN
RAISE NOTICE 'Latest snapshot: %. Gap exceeds % day cap — backfilling from %. Use rebuild script for full history.',
_latest_snapshot, _max_backfill_days, _backfill_start;
ELSE
RAISE NOTICE 'Latest snapshot: %. Checking for gaps from %.', _latest_snapshot, _backfill_start;
END IF;
-- Process all dates that need snapshots:
-- 1. Gap fill: dates with orders/receivings but no snapshots (older than recent window)
-- 2. Recent recheck: last N days always reprocessed (picks up new orders, corrections)
FOR _target_date IN
SELECT d FROM (
-- Gap fill: find dates with activity but missing snapshots
SELECT activity_dates.d
FROM (
SELECT DISTINCT date::date AS d FROM public.orders
WHERE date::date >= _backfill_start AND date::date < CURRENT_DATE - _recent_recheck_days
UNION
SELECT DISTINCT received_date::date AS d FROM public.receivings
WHERE received_date::date >= _backfill_start AND received_date::date < CURRENT_DATE - _recent_recheck_days
) activity_dates
WHERE NOT EXISTS (
SELECT 1 FROM public.daily_product_snapshots dps WHERE dps.snapshot_date = activity_dates.d
)
UNION
-- Recent days: always reprocess
SELECT d::date
FROM generate_series(
(CURRENT_DATE - _recent_recheck_days)::timestamp,
CURRENT_DATE::timestamp,
'1 day'::interval
) d
) dates_to_process
ORDER BY d
LOOP
_days_processed := _days_processed + 1;
RAISE NOTICE 'Processing date: % [%/%]', _target_date, _days_processed,
_days_processed; -- count not known ahead of time, but shows progress
-- IMPORTANT: First delete any existing data for this date to prevent duplication -- IMPORTANT: First delete any existing data for this date to prevent duplication
DELETE FROM public.daily_product_snapshots DELETE FROM public.daily_product_snapshots
@@ -90,7 +88,6 @@ BEGIN
COALESCE( COALESCE(
o.costeach, -- First use order-specific cost if available o.costeach, -- First use order-specific cost if available
get_weighted_avg_cost(p.pid, o.date::date), -- Then use weighted average cost get_weighted_avg_cost(p.pid, o.date::date), -- Then use weighted average cost
p.landing_cost_price, -- Fallback to landing cost
p.cost_price -- Final fallback to current cost p.cost_price -- Final fallback to current cost
) * o.quantity ) * o.quantity
ELSE 0 END), 0.00) AS cogs, ELSE 0 END), 0.00) AS cogs,
@@ -128,7 +125,7 @@ BEGIN
SELECT SELECT
pid, pid,
stock_quantity, stock_quantity,
COALESCE(landing_cost_price, cost_price, 0.00) as effective_cost_price, COALESCE(cost_price, 0.00) as effective_cost_price,
COALESCE(price, 0.00) as current_price, COALESCE(price, 0.00) as current_price,
COALESCE(regular_price, 0.00) as current_regular_price COALESCE(regular_price, 0.00) as current_regular_price
FROM public.products FROM public.products
@@ -181,7 +178,7 @@ BEGIN
COALESCE(sd.gross_revenue_unadjusted, 0.00), COALESCE(sd.gross_revenue_unadjusted, 0.00),
COALESCE(sd.discounts, 0.00), COALESCE(sd.discounts, 0.00),
COALESCE(sd.returns_revenue, 0.00), COALESCE(sd.returns_revenue, 0.00),
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue, COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00) AS net_revenue,
COALESCE(sd.cogs, 0.00), COALESCE(sd.cogs, 0.00),
COALESCE(sd.gross_regular_revenue, 0.00), COALESCE(sd.gross_regular_revenue, 0.00),
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit, -- Basic profit: Net Revenue - COGS (COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit, -- Basic profit: Net Revenue - COGS
@@ -201,12 +198,18 @@ BEGIN
RAISE NOTICE 'Created % daily snapshot records for % with sales/receiving activity', _total_records, _target_date; RAISE NOTICE 'Created % daily snapshot records for % with sales/receiving activity', _total_records, _target_date;
END LOOP; END LOOP;
-- Update the status table with the timestamp from the START of this run IF _days_processed = 0 THEN
UPDATE public.calculate_status RAISE NOTICE 'No days need updating — all snapshot data is current.';
SET last_calculation_timestamp = _start_time ELSE
WHERE module_name = _module_name; RAISE NOTICE 'Processed % days total.', _days_processed;
END IF;
RAISE NOTICE 'Finished % processing for multiple dates. Duration: %', _module_name, clock_timestamp() - _start_time; -- Update the status table with the timestamp from the START of this run
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
VALUES (_module_name, _start_time)
ON CONFLICT (module_name) DO UPDATE SET last_calculation_timestamp = _start_time;
RAISE NOTICE 'Finished % script. Duration: %', _module_name, clock_timestamp() - _start_time;
END $$; END $$;

View File

@@ -21,20 +21,30 @@ BEGIN
RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time; RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time;
-- 1. Calculate Average Lead Time -- 1. Calculate Average Lead Time
-- For each completed PO, find the earliest receiving from the same supplier
-- within 180 days, then average those per-PO lead times per product.
RAISE NOTICE 'Calculating Average Lead Time...'; RAISE NOTICE 'Calculating Average Lead Time...';
WITH LeadTimes AS ( WITH po_first_receiving AS (
SELECT SELECT
po.pid, po.pid,
-- Calculate lead time by looking at when items ordered on POs were received po.po_id,
AVG(GREATEST(1, (r.received_date::date - po.date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days po.date::date AS po_date,
MIN(r.received_date::date) AS first_receive_date
FROM public.purchase_orders po FROM public.purchase_orders po
-- Join to receivings table to find actual receipts JOIN public.receivings r
JOIN public.receivings r ON r.pid = po.pid ON r.pid = po.pid
WHERE po.status = 'done' -- Only include completed POs AND r.supplier_id = po.supplier_id -- same supplier
AND r.received_date >= po.date -- Ensure received date is not before order date AND r.received_date >= po.date -- received after order
-- Optional: add check to make sure receiving is related to PO if you have source_po_id AND r.received_date <= po.date + INTERVAL '180 days' -- within reasonable window
-- AND (r.source_po_id = po.po_id OR r.source_po_id IS NULL) WHERE po.status = 'done'
GROUP BY po.pid GROUP BY po.pid, po.po_id, po.date
),
LeadTimes AS (
SELECT
pid,
ROUND(AVG(GREATEST(1, first_receive_date - po_date))) AS avg_days
FROM po_first_receiving
GROUP BY pid
) )
UPDATE public.product_metrics pm UPDATE public.product_metrics pm
SET avg_lead_time_days = lt.avg_days::int SET avg_lead_time_days = lt.avg_days::int

View File

@@ -52,7 +52,7 @@ BEGIN
COALESCE(p.price, 0.00) as current_price, COALESCE(p.price, 0.00) as current_price,
COALESCE(p.regular_price, 0.00) as current_regular_price, COALESCE(p.regular_price, 0.00) as current_regular_price,
COALESCE(p.cost_price, 0.00) as current_cost_price, COALESCE(p.cost_price, 0.00) as current_cost_price,
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as current_effective_cost, -- Use landing if available, else cost COALESCE(p.cost_price, 0.00) as current_effective_cost,
p.stock_quantity as current_stock, p.stock_quantity as current_stock,
p.created_at, p.created_at,
p.first_received, p.first_received,
@@ -321,10 +321,10 @@ BEGIN
(GREATEST(0, ci.historical_total_sold - COALESCE(lr.lifetime_units_from_orders, 0)) * (GREATEST(0, ci.historical_total_sold - COALESCE(lr.lifetime_units_from_orders, 0)) *
COALESCE( COALESCE(
-- Use oldest known price from snapshots as proxy -- Use oldest known price from snapshots as proxy
(SELECT revenue_7d / NULLIF(sales_7d, 0) (SELECT net_revenue / NULLIF(units_sold, 0)
FROM daily_product_snapshots FROM daily_product_snapshots
WHERE pid = ci.pid AND sales_7d > 0 WHERE pid = ci.pid AND units_sold > 0
ORDER BY snapshot_date ASC ORDER BY snapshot_date ASC
LIMIT 1), LIMIT 1),
ci.current_price ci.current_price
)) ))

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,6 @@ const COLUMN_MAP = {
currentPrice: 'pm.current_price', currentPrice: 'pm.current_price',
currentRegularPrice: 'pm.current_regular_price', currentRegularPrice: 'pm.current_regular_price',
currentCostPrice: 'pm.current_cost_price', currentCostPrice: 'pm.current_cost_price',
currentLandingCostPrice: 'pm.current_landing_cost_price',
currentStock: 'pm.current_stock', currentStock: 'pm.current_stock',
currentStockCost: 'pm.current_stock_cost', currentStockCost: 'pm.current_stock_cost',
currentStockRetail: 'pm.current_stock_retail', currentStockRetail: 'pm.current_stock_retail',
@@ -176,7 +175,7 @@ const COLUMN_MAP = {
const COLUMN_TYPES = { const COLUMN_TYPES = {
// Numeric columns (use numeric operators and sorting) // Numeric columns (use numeric operators and sorting)
numeric: [ numeric: [
'pid', 'currentPrice', 'currentRegularPrice', 'currentCostPrice', 'currentLandingCostPrice', 'pid', 'currentPrice', 'currentRegularPrice', 'currentCostPrice',
'currentStock', 'currentStockCost', 'currentStockRetail', 'currentStockGross', 'currentStock', 'currentStockCost', 'currentStockRetail', 'currentStockGross',
'onOrderQty', 'onOrderCost', 'onOrderRetail', 'ageDays', 'onOrderQty', 'onOrderCost', 'onOrderRetail', 'ageDays',
'sales7d', 'revenue7d', 'sales14d', 'revenue14d', 'sales30d', 'revenue30d', 'sales7d', 'revenue7d', 'sales14d', 'revenue14d', 'sales30d', 'revenue30d',

View File

@@ -145,7 +145,6 @@ router.get('/', async (req, res) => {
stock: 'p.stock_quantity', stock: 'p.stock_quantity',
price: 'p.price', price: 'p.price',
costPrice: 'p.cost_price', costPrice: 'p.cost_price',
landingCost: 'p.landing_cost_price',
dailySalesAvg: 'pm.daily_sales_avg', dailySalesAvg: 'pm.daily_sales_avg',
weeklySalesAvg: 'pm.weekly_sales_avg', weeklySalesAvg: 'pm.weekly_sales_avg',
monthlySalesAvg: 'pm.monthly_sales_avg', monthlySalesAvg: 'pm.monthly_sales_avg',
@@ -621,7 +620,6 @@ router.get('/:id', async (req, res) => {
price: parseFloat(productRows[0].price), price: parseFloat(productRows[0].price),
regular_price: parseFloat(productRows[0].regular_price), regular_price: parseFloat(productRows[0].regular_price),
cost_price: parseFloat(productRows[0].cost_price), cost_price: parseFloat(productRows[0].cost_price),
landing_cost_price: parseFloat(productRows[0].landing_cost_price),
stock_quantity: parseInt(productRows[0].stock_quantity), stock_quantity: parseInt(productRows[0].stock_quantity),
moq: parseInt(productRows[0].moq), moq: parseInt(productRows[0].moq),
uom: parseInt(productRows[0].uom), uom: parseInt(productRows[0].uom),

View File

@@ -0,0 +1,130 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Cell,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface AgingCohort {
cohort: string;
productCount: number;
avgSellThrough: number;
stockCost: number;
revenue: number;
unitsSold: number;
}
function getSellThroughColor(rate: number): string {
if (rate >= 30) return METRIC_COLORS.revenue;
if (rate >= 15) return METRIC_COLORS.orders;
if (rate >= 5) return METRIC_COLORS.comparison;
return '#ef4444';
}
export function AgingSellThrough() {
const { data, isLoading } = useQuery<AgingCohort[]>({
queryKey: ['aging-sell-through'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/aging`);
if (!response.ok) throw new Error('Failed to fetch aging data');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Aging & Sell-Through</CardTitle></CardHeader>
<CardContent>
<div className="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading aging data...</div>
</div>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Sell-Through Rate by Age</CardTitle>
<p className="text-xs text-muted-foreground">
Avg 30-day sell-through % for products by age since first received
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="cohort" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as AgingCohort;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">Age: {d.cohort}</p>
<p>Sell-through: <span className="font-medium">{d.avgSellThrough}%</span></p>
<p>{d.productCount.toLocaleString()} products</p>
<p>Stock value: {formatCurrency(d.stockCost)}</p>
</div>
);
}}
/>
<Bar dataKey="avgSellThrough" name="Sell-Through %" radius={[4, 4, 0, 0]}>
{data.map((entry, i) => (
<Cell key={i} fill={getSellThroughColor(entry.avgSellThrough)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Capital Tied Up by Age</CardTitle>
<p className="text-xs text-muted-foreground">
Stock investment distribution across product age cohorts
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="cohort" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={formatCurrency} tick={{ fontSize: 11 }} />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as AgingCohort;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">Age: {d.cohort}</p>
<p>Stock cost: {formatCurrency(d.stockCost)}</p>
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
<p>{d.productCount.toLocaleString()} products</p>
<p>{d.unitsSold.toLocaleString()} units sold (30d)</p>
</div>
);
}}
/>
<Bar dataKey="stockCost" name="Stock Investment" fill={METRIC_COLORS.aov} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ScatterChart,
Scatter,
ZAxis,
Cell,
ReferenceLine,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface VendorData {
vendor: string;
productCount: number;
stockCost: number;
profit30d: number;
revenue30d: number;
gmroi: number;
}
interface EfficiencyData {
vendors: VendorData[];
}
function getGmroiColor(gmroi: number): string {
if (gmroi >= 1) return METRIC_COLORS.revenue; // emerald — good
if (gmroi >= 0.3) return METRIC_COLORS.comparison; // amber — ok
return '#ef4444'; // red — poor
}
export function CapitalEfficiency() {
const { data, isLoading } = useQuery<EfficiencyData>({
queryKey: ['capital-efficiency'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/efficiency`);
if (!response.ok) throw new Error('Failed to fetch capital efficiency');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Capital Efficiency</CardTitle></CardHeader>
<CardContent>
<div className="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading efficiency...</div>
</div>
</CardContent>
</Card>
);
}
// Top 15 by GMROI for bar chart
const sortedGmroi = [...data.vendors].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15);
return (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>GMROI by Vendor</CardTitle>
<p className="text-xs text-muted-foreground">
Gross margin return on inventory investment (top vendors by stock value)
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={sortedGmroi} layout="vertical">
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis
type="category"
dataKey="vendor"
width={140}
tick={{ fontSize: 11 }}
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as VendorData;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.vendor}</p>
<p>GMROI: <span className="font-medium">{d.gmroi.toFixed(2)}</span></p>
<p>Stock Investment: {formatCurrency(d.stockCost)}</p>
<p>Profit (30d): {formatCurrency(d.profit30d)}</p>
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
<p>{d.productCount} products</p>
</div>
);
}}
/>
<ReferenceLine x={1} stroke="#9ca3af" strokeDasharray="3 3" label={{ value: '1.0', position: 'top', fontSize: 10 }} />
<Bar dataKey="gmroi" name="GMROI" radius={[0, 4, 4, 0]}>
{sortedGmroi.map((entry, i) => (
<Cell key={i} fill={getGmroiColor(entry.gmroi)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Investment vs Profit by Vendor</CardTitle>
<p className="text-xs text-muted-foreground">
Bubble size = product count. Ideal: high profit, low stock cost.
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="stockCost"
name="Stock Investment"
tickFormatter={formatCurrency}
tick={{ fontSize: 11 }}
type="number"
label={{ value: 'Stock Investment', position: 'insideBottom', offset: -5, fontSize: 12, fill: '#888' }}
/>
<YAxis
dataKey="profit30d"
name="Profit (30d)"
tickFormatter={formatCurrency}
tick={{ fontSize: 11 }}
type="number"
label={{ value: 'Profit (30d)', angle: -90, position: 'insideLeft', offset: 10, fontSize: 12, fill: '#888' }}
/>
<ZAxis dataKey="productCount" range={[40, 400]} name="Products" />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as VendorData;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.vendor}</p>
<p>Stock Investment: {formatCurrency(d.stockCost)}</p>
<p>Profit (30d): {formatCurrency(d.profit30d)}</p>
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
<p>{d.productCount} products</p>
</div>
);
}}
/>
<Scatter data={data.vendors} fill={METRIC_COLORS.orders} fillOpacity={0.6} />
</ScatterChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,202 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, PieChart, Pie, Cell, Legend } from 'recharts';
import config from '../../config';
interface CategoryData {
performance: {
category: string;
categoryPath: string; // Full hierarchy path
revenue: number;
profit: number;
growth: number;
productCount: number;
}[];
distribution: {
category: string;
categoryPath: string; // Full hierarchy path
value: number;
}[];
trends: {
category: string;
categoryPath: string; // Full hierarchy path
month: string;
sales: number;
}[];
}
const COLORS = ['#4ade80', '#60a5fa', '#f87171', '#fbbf24', '#a78bfa', '#f472b6'];
export function CategoryPerformance() {
const { data, isLoading } = useQuery<CategoryData>({
queryKey: ['category-performance'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/categories`);
if (!response.ok) {
throw new Error('Failed to fetch category performance');
}
const rawData = await response.json();
return {
performance: rawData.performance.map((item: any) => ({
category: item.category || '',
categoryPath: item.categoryPath || item.categorypath || item.category || '',
revenue: Number(item.revenue) || 0,
profit: Number(item.profit) || 0,
growth: Number(item.growth) || 0,
productCount: Number(item.productCount) || Number(item.productcount) || 0
})),
distribution: rawData.distribution.map((item: any) => ({
category: item.category || '',
categoryPath: item.categoryPath || item.categorypath || item.category || '',
value: Number(item.value) || 0
})),
trends: rawData.trends.map((item: any) => ({
category: item.category || '',
categoryPath: item.categoryPath || item.categorypath || item.category || '',
month: item.month || '',
sales: Number(item.sales) || 0
}))
};
},
});
if (isLoading || !data) {
return <div>Loading category performance...</div>;
}
const formatGrowth = (growth: number) => {
const value = growth >= 0 ? `+${growth.toFixed(1)}%` : `${growth.toFixed(1)}%`;
const color = growth >= 0 ? 'text-green-500' : 'text-red-500';
return <span className={color}>{value}</span>;
};
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Category Revenue Distribution</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data.distribution}
dataKey="value"
nameKey="categoryPath"
cx="50%"
cy="50%"
outerRadius={100}
fill="#8884d8"
label={({ categoryPath }) => getShortCategoryName(categoryPath)}
>
{data.distribution.map((entry, index) => (
<Cell
key={`${entry.category}-${entry.value}-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number, _: string, props: any) => [
`$${value.toLocaleString()}`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
<div className="mt-1">Revenue</div>
</div>
]}
/>
<Legend
formatter={(value) => getShortCategoryName(value)}
wrapperStyle={{ fontSize: '12px' }}
/>
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Category Growth Rates</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.performance}>
<XAxis
dataKey="categoryPath"
tick={({ x, y, payload }) => (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={16}
textAnchor="end"
fill="#888888"
transform="rotate(-35)"
>
{getShortCategoryName(payload.value)}
</text>
</g>
)}
/>
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
formatter={(value: number, _: string, props: any) => [
`${value.toFixed(1)}%`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
<div className="mt-1">Growth Rate</div>
</div>
]}
/>
<Bar
dataKey="growth"
fill="#4ade80"
name="Growth Rate"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Category Performance Details</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.performance.map((category) => (
<div key={`${category.category}-${category.revenue}`} className="flex items-center">
<div className="flex-1">
<div className="space-y-1">
<p className="text-sm font-medium">{getShortCategoryName(category.categoryPath)}</p>
<p className="text-xs text-muted-foreground">{category.categoryPath}</p>
</div>
<p className="text-sm text-muted-foreground mt-1">
{category.productCount} products
</p>
</div>
<div className="ml-4 text-right space-y-1">
<p className="text-sm font-medium">
${category.revenue.toLocaleString()} revenue
</p>
<p className="text-sm text-muted-foreground">
${category.profit.toLocaleString()} profit
</p>
<p className="text-sm text-muted-foreground">
Growth: {formatGrowth(category.growth)}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Legend,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface DiscountRow {
abcClass: string;
discountBucket: string;
productCount: number;
avgSellThrough: number;
revenue: number;
discountAmount: number;
profit: number;
}
const CLASS_COLORS: Record<string, string> = {
A: METRIC_COLORS.revenue,
B: METRIC_COLORS.orders,
C: METRIC_COLORS.comparison,
};
export function DiscountImpact() {
const { data, isLoading } = useQuery<DiscountRow[]>({
queryKey: ['discount-impact'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/discounts`);
if (!response.ok) throw new Error('Failed to fetch discount data');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Discount Impact</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading discount data...</div>
</div>
</CardContent>
</Card>
);
}
// Pivot: for each discount bucket, show avg sell-through by ABC class
const buckets = ['No Discount', '1-10%', '11-20%', '21-30%', '30%+'];
const chartData = buckets.map(bucket => {
const row: Record<string, string | number> = { bucket };
['A', 'B', 'C'].forEach(cls => {
const match = data.find(d => d.discountBucket === bucket && d.abcClass === cls);
row[`Class ${cls}`] = match?.avgSellThrough || 0;
});
return row;
});
// Summary by ABC class
const classSummary = ['A', 'B', 'C'].map(cls => {
const rows = data.filter(d => d.abcClass === cls);
return {
abcClass: cls,
totalProducts: rows.reduce((s, r) => s + r.productCount, 0),
totalDiscounts: rows.reduce((s, r) => s + r.discountAmount, 0),
totalRevenue: rows.reduce((s, r) => s + r.revenue, 0),
totalProfit: rows.reduce((s, r) => s + r.profit, 0),
};
});
return (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Sell-Through by Discount Level</CardTitle>
<p className="text-xs text-muted-foreground">
Avg 30-day sell-through % at each discount bracket, by ABC class
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="bucket" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(value: number) => [`${value}%`]} />
<Legend />
<Bar dataKey="Class A" fill={CLASS_COLORS.A} radius={[2, 2, 0, 0]} />
<Bar dataKey="Class B" fill={CLASS_COLORS.B} radius={[2, 2, 0, 0]} />
<Bar dataKey="Class C" fill={CLASS_COLORS.C} radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Discount Leakage by Class</CardTitle>
<p className="text-xs text-muted-foreground">
How much discount is given relative to revenue per ABC class
</p>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-2 text-left font-medium">Class</th>
<th className="px-4 py-2 text-right font-medium">Products</th>
<th className="px-4 py-2 text-right font-medium">Revenue</th>
<th className="px-4 py-2 text-right font-medium">Discounts</th>
<th className="px-4 py-2 text-right font-medium">Disc %</th>
<th className="px-4 py-2 text-right font-medium">Profit</th>
</tr>
</thead>
<tbody>
{classSummary.map((row) => (
<tr key={row.abcClass} className="border-b">
<td className="px-4 py-2 font-medium">Class {row.abcClass}</td>
<td className="px-4 py-2 text-right">{row.totalProducts.toLocaleString()}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.totalRevenue)}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.totalDiscounts)}</td>
<td className="px-4 py-2 text-right">
{row.totalRevenue > 0
? ((row.totalDiscounts / (row.totalRevenue + row.totalDiscounts)) * 100).toFixed(1)
: '0'}%
</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.totalProfit)}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Legend,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { TrendingUp, TrendingDown } from 'lucide-react';
interface GrowthRow {
abcClass: string;
growthBucket: string;
productCount: number;
revenue: number;
stockCost: number;
}
interface GrowthSummary {
totalWithYoy: number;
growingCount: number;
decliningCount: number;
avgGrowth: number;
medianGrowth: number;
}
interface GrowthData {
byClass: GrowthRow[];
summary: GrowthSummary;
}
const GROWTH_COLORS: Record<string, string> = {
'Strong Growth (>50%)': METRIC_COLORS.revenue,
'Growing (0-50%)': '#34d399',
'Declining (0-50%)': METRIC_COLORS.comparison,
'Sharp Decline (>50%)': '#ef4444',
};
export function GrowthMomentum() {
const { data, isLoading } = useQuery<GrowthData>({
queryKey: ['growth-momentum'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/growth`);
if (!response.ok) throw new Error('Failed to fetch growth data');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>YoY Growth Momentum</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading growth data...</div>
</div>
</CardContent>
</Card>
);
}
const { summary } = data;
const growthPct = summary.totalWithYoy > 0
? ((summary.growingCount / summary.totalWithYoy) * 100).toFixed(0)
: '0';
// Pivot: for each ABC class, show product counts by growth bucket
const classes = ['A', 'B', 'C'];
const buckets = ['Strong Growth (>50%)', 'Growing (0-50%)', 'Declining (0-50%)', 'Sharp Decline (>50%)'];
const chartData = classes.map(cls => {
const row: Record<string, string | number> = { abcClass: `Class ${cls}` };
buckets.forEach(bucket => {
const match = data.byClass.find(d => d.abcClass === cls && d.growthBucket === bucket);
row[bucket] = match?.productCount || 0;
});
return row;
});
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-green-500/10">
<TrendingUp className="h-4 w-4 text-green-500" />
</div>
<div>
<p className="text-sm font-medium">Growing</p>
<p className="text-xl font-bold">{growthPct}%</p>
<p className="text-xs text-muted-foreground">{summary.growingCount.toLocaleString()} products</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-red-500/10">
<TrendingDown className="h-4 w-4 text-red-500" />
</div>
<div>
<p className="text-sm font-medium">Declining</p>
<p className="text-xl font-bold">{summary.decliningCount.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">products</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">Avg YoY Growth</p>
<p className={`text-2xl font-bold ${summary.avgGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{summary.avgGrowth > 0 ? '+' : ''}{summary.avgGrowth}%
</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">Median YoY Growth</p>
<p className={`text-2xl font-bold ${summary.medianGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{summary.medianGrowth > 0 ? '+' : ''}{summary.medianGrowth}%
</p>
<p className="text-xs text-muted-foreground">{summary.totalWithYoy.toLocaleString()} products tracked</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Growth Distribution by ABC Class</CardTitle>
<p className="text-xs text-muted-foreground">
Year-over-year sales growth segmented by product importance
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="abcClass" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 11 }} label={{ value: 'Products', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
{buckets.map(bucket => (
<Bar
key={bucket}
dataKey={bucket}
stackId="growth"
fill={GROWTH_COLORS[bucket]}
/>
))}
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Line,
ComposedChart,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
interface TrendPoint {
date: string;
stockoutCount: number;
unitsSold: number;
}
type Period = 30 | 90 | 365;
function formatDate(dateStr: string, period: Period): string {
const d = new Date(dateStr);
if (period === 365) return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
if (period === 90) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
export function InventoryTrends() {
const [period, setPeriod] = useState<Period>(90);
const { data, isLoading } = useQuery<TrendPoint[]>({
queryKey: ['inventory-trends', period],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/inventory-trends?period=${period}`);
if (!response.ok) throw new Error('Failed to fetch inventory trends');
return response.json();
},
});
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Daily Sales Activity & Stockouts</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Units sold per day with stockout product count overlay
</p>
</div>
<div className="flex gap-1">
{([30, 90, 365] as Period[]).map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
period === p
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{p === 365 ? '1Y' : `${p}D`}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent>
{isLoading || !data ? (
<div className="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading trends...</div>
</div>
) : (
<ResponsiveContainer width="100%" height={350}>
<ComposedChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tickFormatter={(v) => formatDate(v, period)}
tick={{ fontSize: 12 }}
interval={period === 365 ? 29 : period === 90 ? 6 : 2}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 12 }}
width={60}
label={{ value: 'Units Sold', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 12 }}
width={60}
label={{ value: 'Stockouts', angle: 90, position: 'insideRight', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<Tooltip
labelFormatter={(v) => new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
/>
<Bar
yAxisId="left"
dataKey="unitsSold"
fill={METRIC_COLORS.orders}
name="Units Sold"
radius={[2, 2, 0, 0]}
opacity={0.7}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="stockoutCount"
stroke="#ef4444"
name="Products Stocked Out"
strokeWidth={1.5}
dot={false}
strokeDasharray="4 2"
/>
</ComposedChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,213 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
CartesianGrid,
} from 'recharts';
import config from '../../config';
import { PackageX, Archive } from 'lucide-react';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface AbcItem {
abcClass: string;
productCount: number;
revenue: number;
stockCost: number;
profit: number;
unitsSold: number;
}
interface StockIssues {
deadStockCount: number;
deadStockCost: number;
deadStockRetail: number;
overstockCount: number;
overstockCost: number;
overstockRetail: number;
}
interface PortfolioData {
abcBreakdown: AbcItem[];
stockIssues: StockIssues;
}
export function PortfolioAnalysis() {
const { data, isLoading } = useQuery<PortfolioData>({
queryKey: ['portfolio-analysis'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/portfolio`);
if (!response.ok) throw new Error('Failed to fetch portfolio analysis');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Portfolio & ABC Analysis</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading portfolio...</div>
</div>
</CardContent>
</Card>
);
}
// Include all classes — rename N/A to "Unclassified"
const allClasses = data.abcBreakdown.map(r => ({
...r,
abcClass: r.abcClass === 'N/A' ? 'Unclassified' : r.abcClass,
}));
const totalRevenue = allClasses.reduce((s, r) => s + r.revenue, 0);
const totalStockCost = allClasses.reduce((s, r) => s + r.stockCost, 0);
const totalProducts = allClasses.reduce((s, r) => s + r.productCount, 0);
// Compute percentage data for the grouped bar chart
const chartData = allClasses.map(r => ({
abcClass: r.abcClass === 'Unclassified' ? 'Unclassified' : `Class ${r.abcClass}`,
'% of Products': totalProducts > 0 ? Number(((r.productCount / totalProducts) * 100).toFixed(1)) : 0,
'% of Revenue': totalRevenue > 0 ? Number(((r.revenue / totalRevenue) * 100).toFixed(1)) : 0,
'% of Stock Investment': totalStockCost > 0 ? Number(((r.stockCost / totalStockCost) * 100).toFixed(1)) : 0,
}));
const abcOnly = allClasses.filter(r => ['A', 'B', 'C'].includes(r.abcClass));
const abcRevenue = abcOnly.reduce((s, r) => s + r.revenue, 0);
const aClass = allClasses.find(r => r.abcClass === 'A');
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>ABC Class Distribution</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="abcClass" tick={{ fontSize: 12 }} />
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 12 }} />
<Tooltip formatter={(value: number) => [`${value}%`]} />
<Legend />
<Bar dataKey="% of Products" fill={METRIC_COLORS.orders} radius={[2, 2, 0, 0]} />
<Bar dataKey="% of Revenue" fill={METRIC_COLORS.revenue} radius={[2, 2, 0, 0]} />
<Bar dataKey="% of Stock Investment" fill={METRIC_COLORS.aov} radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<div className="grid gap-4 grid-rows-3">
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="rounded-full p-2 bg-green-500/10">
<TrendingUpIcon className="h-5 w-5 text-green-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">A-Class Products</p>
<p className="text-xs text-muted-foreground">
{aClass ? aClass.productCount.toLocaleString() : 0} products
</p>
</div>
<div className="text-right">
<p className="text-sm font-bold">
{abcRevenue > 0 && aClass ? ((aClass.revenue / abcRevenue) * 100).toFixed(0) : 0}% of classified revenue
</p>
<p className="text-xs text-muted-foreground">
{formatCurrency(aClass?.revenue || 0)} (30d)
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="rounded-full p-2 bg-amber-500/10">
<Archive className="h-5 w-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Dead Stock</p>
<p className="text-xs text-muted-foreground">
{data.stockIssues.deadStockCount.toLocaleString()} products
</p>
</div>
<div className="text-right">
<p className="text-sm font-bold text-amber-500">
{formatCurrency(data.stockIssues.deadStockCost)}
</p>
<p className="text-xs text-muted-foreground">capital tied up</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="rounded-full p-2 bg-red-500/10">
<PackageX className="h-5 w-5 text-red-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Overstock</p>
<p className="text-xs text-muted-foreground">
{data.stockIssues.overstockCount.toLocaleString()} products
</p>
</div>
<div className="text-right">
<p className="text-sm font-bold text-red-500">
{formatCurrency(data.stockIssues.overstockCost)}
</p>
<p className="text-xs text-muted-foreground">excess investment</p>
</div>
</CardContent>
</Card>
</div>
</div>
{/* ABC breakdown table */}
<Card>
<CardContent className="pt-6">
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-2 text-left font-medium">Class</th>
<th className="px-4 py-2 text-right font-medium">Products</th>
<th className="px-4 py-2 text-right font-medium">Revenue (30d)</th>
<th className="px-4 py-2 text-right font-medium">Profit (30d)</th>
<th className="px-4 py-2 text-right font-medium">Stock Cost</th>
<th className="px-4 py-2 text-right font-medium">Units Sold (30d)</th>
</tr>
</thead>
<tbody>
{allClasses.map((row) => (
<tr key={row.abcClass} className="border-b">
<td className="px-4 py-2 font-medium">{row.abcClass === 'Unclassified' ? 'Unclassified' : `Class ${row.abcClass}`}</td>
<td className="px-4 py-2 text-right">{row.productCount.toLocaleString()}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.revenue)}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.profit)}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.stockCost)}</td>
<td className="px-4 py-2 text-right">{row.unitsSold.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}
function TrendingUpIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
<polyline points="16 7 22 7 22 13" />
</svg>
);
}

View File

@@ -1,232 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, ScatterChart, Scatter, XAxis, YAxis, Tooltip, ZAxis, LineChart, Line } from 'recharts';
import config from '../../config';
interface PriceData {
pricePoints: {
price: number;
salesVolume: number;
revenue: number;
category: string;
}[];
elasticity: {
date: string;
price: number;
demand: number;
}[];
recommendations: {
product: string;
currentPrice: number;
recommendedPrice: number;
potentialRevenue: number;
confidence: number;
}[];
}
export function PriceAnalysis() {
const { data, isLoading, error } = useQuery<PriceData>({
queryKey: ['price-analysis'],
queryFn: async () => {
try {
const response = await fetch(`${config.apiUrl}/analytics/pricing`);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const rawData = await response.json();
if (!rawData || !rawData.pricePoints) {
return {
pricePoints: [],
elasticity: [],
recommendations: []
};
}
return {
pricePoints: (rawData.pricePoints || []).map((item: any) => ({
price: Number(item.price) || 0,
salesVolume: Number(item.salesVolume || item.salesvolume) || 0,
revenue: Number(item.revenue) || 0,
category: item.category || ''
})),
elasticity: (rawData.elasticity || []).map((item: any) => ({
date: item.date || '',
price: Number(item.price) || 0,
demand: Number(item.demand) || 0
})),
recommendations: (rawData.recommendations || []).map((item: any) => ({
product: item.product || '',
currentPrice: Number(item.currentPrice || item.currentprice) || 0,
recommendedPrice: Number(item.recommendedPrice || item.recommendedprice) || 0,
potentialRevenue: Number(item.potentialRevenue || item.potentialrevenue) || 0,
confidence: Number(item.confidence) || 0
}))
};
} catch (err) {
console.error('Error fetching price data:', err);
throw err;
}
},
retry: 1
});
if (isLoading) {
return <div>Loading price analysis...</div>;
}
if (error || !data) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Price Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-red-500">
Unable to load price analysis. The price metrics may need to be set up in the database.
</p>
</CardContent>
</Card>
);
}
// Early return if no data to display
if (
data.pricePoints.length === 0 &&
data.elasticity.length === 0 &&
data.recommendations.length === 0
) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Price Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
No price data available. This may be because the price metrics haven't been calculated yet.
</p>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Price Point Analysis</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<ScatterChart>
<XAxis
dataKey="price"
name="Price"
tickFormatter={(value) => `$${value}`}
/>
<YAxis
dataKey="salesVolume"
name="Sales Volume"
/>
<ZAxis
dataKey="revenue"
range={[50, 400]}
name="Revenue"
/>
<Tooltip
formatter={(value: number, name: string) => {
if (name === 'Price') return [`$${value}`, name];
if (name === 'Sales Volume') return [value.toLocaleString(), name];
if (name === 'Revenue') return [`$${value.toLocaleString()}`, name];
return [value, name];
}}
/>
<Scatter
data={data.pricePoints}
fill="#a78bfa"
name="Products"
/>
</ScatterChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Price Elasticity</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data.elasticity}>
<XAxis
dataKey="date"
tickFormatter={(value) => new Date(value).toLocaleDateString()}
/>
<YAxis yAxisId="left" orientation="left" stroke="#a78bfa" />
<YAxis
yAxisId="right"
orientation="right"
stroke="#4ade80"
tickFormatter={(value) => `$${value}`}
/>
<Tooltip
labelFormatter={(label) => new Date(label).toLocaleDateString()}
formatter={(value: number, name: string) => {
if (name === 'Price') return [`$${value}`, name];
return [value.toLocaleString(), name];
}}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="demand"
stroke="#a78bfa"
name="Demand"
/>
<Line
yAxisId="right"
type="monotone"
dataKey="price"
stroke="#4ade80"
name="Price"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Price Optimization Recommendations</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.recommendations.map((item) => (
<div key={`${item.product}-${item.currentPrice}`} className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium">{item.product}</p>
<p className="text-sm text-muted-foreground">
Current Price: ${item.currentPrice.toFixed(2)}
</p>
</div>
<div className="ml-4 text-right space-y-1">
<p className="text-sm font-medium">
Recommended: ${item.recommendedPrice.toFixed(2)}
</p>
<p className="text-sm text-muted-foreground">
Potential Revenue: ${item.potentialRevenue.toLocaleString()}
</p>
<p className="text-sm text-muted-foreground">
Confidence: {item.confidence}%
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,180 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts';
import config from '../../config';
interface ProfitData {
byCategory: {
category: string;
categoryPath: string; // Full hierarchy path
profitMargin: number;
revenue: number;
cost: number;
}[];
overTime: {
date: string;
profitMargin: number;
revenue: number;
cost: number;
}[];
topProducts: {
product: string;
category: string;
categoryPath: string; // Full hierarchy path
profitMargin: number;
revenue: number;
cost: number;
}[];
}
export function ProfitAnalysis() {
const { data, isLoading } = useQuery<ProfitData>({
queryKey: ['profit-analysis'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/profit`);
if (!response.ok) {
throw new Error('Failed to fetch profit analysis');
}
const rawData = await response.json();
return {
byCategory: rawData.byCategory.map((item: any) => ({
category: item.category || '',
categoryPath: item.categorypath || item.category || '',
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0
})),
overTime: rawData.overTime.map((item: any) => ({
date: item.date || '',
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0
})),
topProducts: rawData.topProducts.map((item: any) => ({
product: item.product || '',
category: item.category || '',
categoryPath: item.categorypath || item.category || '',
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0
}))
};
},
});
if (isLoading || !data) {
return <div>Loading profit analysis...</div>;
}
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Profit Margins by Category</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.byCategory}>
<XAxis
dataKey="categoryPath"
tick={({ x, y, payload }) => (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={16}
textAnchor="end"
fill="#888888"
transform="rotate(-35)"
>
{getShortCategoryName(payload.value)}
</text>
</g>
)}
/>
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
formatter={(value: number, _: string, props: any) => [
`${value.toFixed(1)}%`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
<div className="mt-1">Profit Margin</div>
</div>
]}
/>
<Bar
dataKey="profitMargin"
fill="#4ade80"
name="Profit Margin"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profit Margin Trend</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data.overTime}>
<XAxis
dataKey="date"
tickFormatter={(value) => new Date(value).toLocaleDateString()}
/>
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
labelFormatter={(label) => new Date(label).toLocaleDateString()}
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']}
/>
<Line
type="monotone"
dataKey="profitMargin"
stroke="#4ade80"
name="Profit Margin"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Top Performing Products by Profit Margin</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.topProducts.map((product) => (
<div key={`${product.product}-${product.category}`} className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium">{product.product}</p>
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium">Category:</p>
<p>{product.categoryPath}</p>
</div>
<p className="text-sm text-muted-foreground mt-1">
Revenue: ${product.revenue.toLocaleString()}
</p>
</div>
<div className="ml-4 text-right">
<p className="text-sm font-medium">
{product.profitMargin.toFixed(1)}% margin
</p>
<p className="text-sm text-muted-foreground">
Cost: ${product.cost.toLocaleString()}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,227 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts';
import { Badge } from '@/components/ui/badge';
import config from '../../config';
interface StockData {
turnoverByCategory: {
category: string;
turnoverRate: number;
averageStock: number;
totalSales: number;
}[];
stockLevels: {
date: string;
inStock: number;
lowStock: number;
outOfStock: number;
}[];
criticalItems: {
product: string;
sku: string;
stockQuantity: number;
reorderPoint: number;
turnoverRate: number;
daysUntilStockout: number;
}[];
}
export function StockAnalysis() {
const { data, isLoading, error } = useQuery<StockData>({
queryKey: ['stock-analysis'],
queryFn: async () => {
try {
const response = await fetch(`${config.apiUrl}/analytics/stock`);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const rawData = await response.json();
if (!rawData || !rawData.turnoverByCategory) {
return {
turnoverByCategory: [],
stockLevels: [],
criticalItems: []
};
}
return {
turnoverByCategory: (rawData.turnoverByCategory || []).map((item: any) => ({
category: item.category || '',
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
averageStock: Number(item.averageStock || item.averagestock) || 0,
totalSales: Number(item.totalSales || item.totalsales) || 0
})),
stockLevels: (rawData.stockLevels || []).map((item: any) => ({
date: item.date || '',
inStock: Number(item.inStock || item.instock) || 0,
lowStock: Number(item.lowStock || item.lowstock) || 0,
outOfStock: Number(item.outOfStock || item.outofstock) || 0
})),
criticalItems: (rawData.criticalItems || []).map((item: any) => ({
product: item.product || '',
sku: item.sku || '',
stockQuantity: Number(item.stockQuantity || item.stockquantity) || 0,
reorderPoint: Number(item.reorderPoint || item.reorderpoint) || 0,
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
daysUntilStockout: Number(item.daysUntilStockout || item.daysuntilstockout) || 0
}))
};
} catch (err) {
console.error('Error fetching stock data:', err);
throw err;
}
},
retry: 1
});
if (isLoading) {
return <div>Loading stock analysis...</div>;
}
if (error || !data) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Stock Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-red-500">
Unable to load stock analysis. The stock metrics may need to be set up in the database.
</p>
</CardContent>
</Card>
);
}
// Early return if no data to display
if (
data.turnoverByCategory.length === 0 &&
data.stockLevels.length === 0 &&
data.criticalItems.length === 0
) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Stock Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
No stock data available. This may be because the stock metrics haven't been calculated yet.
</p>
</CardContent>
</Card>
);
}
const getStockStatus = (daysUntilStockout: number) => {
if (daysUntilStockout <= 7) {
return <Badge variant="destructive">Critical</Badge>;
}
if (daysUntilStockout <= 14) {
return <Badge variant="outline">Warning</Badge>;
}
return <Badge variant="secondary">OK</Badge>;
};
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Stock Turnover by Category</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.turnoverByCategory}>
<XAxis dataKey="category" />
<YAxis tickFormatter={(value) => `${value.toFixed(1)}x`} />
<Tooltip
formatter={(value: number) => [`${value.toFixed(1)}x`, 'Turnover Rate']}
/>
<Bar
dataKey="turnoverRate"
fill="#fbbf24"
name="Turnover Rate"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Stock Level Trends</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data.stockLevels}>
<XAxis
dataKey="date"
tickFormatter={(value) => new Date(value).toLocaleDateString()}
/>
<YAxis />
<Tooltip
labelFormatter={(label) => new Date(label).toLocaleDateString()}
/>
<Line
type="monotone"
dataKey="inStock"
stroke="#4ade80"
name="In Stock"
/>
<Line
type="monotone"
dataKey="lowStock"
stroke="#fbbf24"
name="Low Stock"
/>
<Line
type="monotone"
dataKey="outOfStock"
stroke="#f87171"
name="Out of Stock"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Critical Stock Items</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.criticalItems.map((item) => (
<div key={`${item.sku}-${item.product}`} className="flex items-center">
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{item.product}</p>
{getStockStatus(item.daysUntilStockout)}
</div>
<p className="text-sm text-muted-foreground">
SKU: {item.sku}
</p>
</div>
<div className="ml-4 text-right space-y-1">
<p className="text-sm font-medium">
{item.stockQuantity} in stock
</p>
<p className="text-sm text-muted-foreground">
Reorder at: {item.reorderPoint}
</p>
<p className="text-sm text-muted-foreground">
{item.daysUntilStockout} days until stockout
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
PieChart,
Pie,
Cell,
Legend,
} from 'recharts';
import config from '../../config';
import { AlertTriangle, ShieldCheck, DollarSign } from 'lucide-react';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface CoverBucket {
bucket: string;
productCount: number;
stockCost: number;
}
interface DemandPattern {
pattern: string;
productCount: number;
revenue: number;
stockCost: number;
}
interface ServiceStats {
avgFillRate: number;
avgServiceLevel: number;
totalStockoutIncidents: number;
totalLostSalesIncidents: number;
totalLostUnits: number;
totalLostRevenue: number;
productsWithStockouts: number;
avgStockoutRate: number;
}
interface StockHealthData {
coverDistribution: CoverBucket[];
demandPatterns: DemandPattern[];
serviceStats: ServiceStats;
}
// Color palette for demand pattern donut chart
const DEMAND_COLORS = [
METRIC_COLORS.revenue, // emerald
METRIC_COLORS.orders, // blue
METRIC_COLORS.comparison, // amber
METRIC_COLORS.aov, // violet
METRIC_COLORS.secondary, // cyan
];
function getCoverColor(bucket: string): string {
if (bucket.includes('Stockout')) return '#ef4444'; // red
if (bucket.includes('1-7')) return METRIC_COLORS.expense; // orange — critical low
if (bucket.includes('8-14')) return METRIC_COLORS.comparison; // amber — low
if (bucket.includes('15-30')) return '#eab308'; // yellow — watch
if (bucket.includes('31-60')) return METRIC_COLORS.revenue; // emerald — healthy
if (bucket.includes('61-90')) return METRIC_COLORS.orders; // blue — comfortable
if (bucket.includes('91-180')) return METRIC_COLORS.aov; // violet — high
return METRIC_COLORS.secondary; // cyan — excess
}
export function StockHealth() {
const { data, isLoading } = useQuery<StockHealthData>({
queryKey: ['stock-health'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/stock-health`);
if (!response.ok) throw new Error('Failed to fetch stock health');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Demand & Stock Health</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading stock health...</div>
</div>
</CardContent>
</Card>
);
}
const { serviceStats } = data;
return (
<div className="space-y-4">
{/* Service Level Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-green-500/10">
<ShieldCheck className="h-4 w-4 text-green-500" />
</div>
<div>
<p className="text-sm font-medium">Fill Rate</p>
<p className="text-xl font-bold">{serviceStats.avgFillRate}%</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-blue-500/10">
<ShieldCheck className="h-4 w-4 text-blue-500" />
</div>
<div>
<p className="text-sm font-medium">Service Level</p>
<p className="text-xl font-bold">{serviceStats.avgServiceLevel}%</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-red-500/10">
<AlertTriangle className="h-4 w-4 text-red-500" />
</div>
<div>
<p className="text-sm font-medium">Stockout Incidents</p>
<p className="text-xl font-bold">{serviceStats.totalStockoutIncidents.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{serviceStats.productsWithStockouts} products affected</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-amber-500/10">
<DollarSign className="h-4 w-4 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium">Est. Lost Revenue</p>
<p className="text-xl font-bold">{formatCurrency(serviceStats.totalLostRevenue)}</p>
<p className="text-xs text-muted-foreground">{Math.round(serviceStats.totalLostUnits).toLocaleString()} units</p>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* Stock Cover Distribution */}
<Card>
<CardHeader>
<CardTitle>Stock Cover Distribution</CardTitle>
<p className="text-xs text-muted-foreground">
Days of stock cover across active replenishable products
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.coverDistribution}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="bucket"
tick={{ fontSize: 10 }}
angle={-30}
textAnchor="end"
height={60}
/>
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as CoverBucket;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.bucket}</p>
<p>{d.productCount.toLocaleString()} products</p>
<p>Stock value: {formatCurrency(d.stockCost)}</p>
</div>
);
}}
/>
<Bar dataKey="productCount" name="Products" radius={[4, 4, 0, 0]}>
{data.coverDistribution.map((entry, i) => (
<Cell key={i} fill={getCoverColor(entry.bucket)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Demand Pattern Distribution */}
<Card>
<CardHeader>
<CardTitle>Demand Patterns</CardTitle>
<p className="text-xs text-muted-foreground">
Distribution of demand variability across selling products
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data.demandPatterns}
dataKey="productCount"
nameKey="pattern"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
label={({ pattern, productCount }) =>
`${pattern} (${productCount.toLocaleString()})`
}
>
{data.demandPatterns.map((_, i) => (
<Cell key={i} fill={DEMAND_COLORS[i % DEMAND_COLORS.length]} />
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as DemandPattern;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1 capitalize">{d.pattern}</p>
<p>{d.productCount.toLocaleString()} products</p>
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
<p>Stock value: {formatCurrency(d.stockCost)}</p>
</div>
);
}}
/>
<Legend
formatter={(value) => <span className="capitalize text-xs">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
ScatterChart,
Scatter,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ZAxis,
Cell,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface RiskProduct {
title: string;
sku: string;
vendor: string;
leadTimeDays: number;
sellsOutInDays: number;
currentStock: number;
velocityDaily: number;
revenue30d: number;
abcClass: string;
}
interface RiskSummary {
atRiskCount: number;
criticalACount: number;
atRiskRevenue: number;
}
interface StockoutRiskData {
summary: RiskSummary;
products: RiskProduct[];
}
function getRiskColor(product: RiskProduct): string {
const buffer = product.sellsOutInDays - product.leadTimeDays;
if (buffer <= 0) return '#ef4444'; // Already past lead time — critical
if (buffer <= 7) return METRIC_COLORS.comparison; // Within a week — warning
return METRIC_COLORS.revenue; // Healthy buffer
}
export function StockoutRisk() {
const { data, isLoading } = useQuery<StockoutRiskData>({
queryKey: ['stockout-risk'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/stockout-risk`);
if (!response.ok) throw new Error('Failed to fetch stockout risk');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Reorder Risk</CardTitle></CardHeader>
<CardContent>
<div className="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading risk data...</div>
</div>
</CardContent>
</Card>
);
}
const { summary, products } = data;
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">At Risk Products</p>
<p className="text-2xl font-bold text-red-500">{summary.atRiskCount}</p>
<p className="text-xs text-muted-foreground">sells out before lead time</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">Critical A-Class</p>
<p className="text-2xl font-bold text-red-500">{summary.criticalACount}</p>
<p className="text-xs text-muted-foreground">top sellers at risk</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">At-Risk Revenue</p>
<p className="text-2xl font-bold text-amber-500">
{formatCurrency(summary.atRiskRevenue)}
</p>
<p className="text-xs text-muted-foreground">monthly revenue exposed</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Lead Time vs Sell-Out Timeline</CardTitle>
<p className="text-xs text-muted-foreground">
Products below the diagonal line will stock out before replenishment arrives (incl. on-order stock)
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="leadTimeDays"
name="Lead Time"
tick={{ fontSize: 11 }}
type="number"
label={{ value: 'Lead Time (days)', position: 'insideBottom', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<YAxis
dataKey="sellsOutInDays"
name="Sells Out In"
tick={{ fontSize: 11 }}
type="number"
label={{ value: 'Sells Out In (days)', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<ZAxis dataKey="revenue30d" range={[30, 300]} name="Revenue" />
{/* Diagonal risk line (y = x): products below this stock out before replenishment */}
<Scatter
data={(() => {
const max = Math.max(...products.map(d => Math.max(d.leadTimeDays, d.sellsOutInDays)));
return [
{ leadTimeDays: 0, sellsOutInDays: 0, revenue30d: 0 },
{ leadTimeDays: max, sellsOutInDays: max, revenue30d: 0 },
];
})()}
line={{ stroke: '#9ca3af', strokeDasharray: '6 3', strokeWidth: 1.5 }}
shape={() => <circle r={0} />}
legendType="none"
isAnimationActive={false}
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as RiskProduct;
if (!d.title) return null; // skip diagonal line points
const buffer = d.sellsOutInDays - d.leadTimeDays;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm max-w-xs">
<p className="font-medium mb-1 truncate">{d.title}</p>
<p className="text-xs text-muted-foreground mb-1">{d.sku} ({d.abcClass})</p>
<p>Lead time: {d.leadTimeDays}d</p>
<p>Sells out in: {d.sellsOutInDays}d</p>
<p className={buffer <= 0 ? 'text-red-500 font-medium' : ''}>
Buffer: {buffer}d {buffer <= 0 ? '(AT RISK)' : ''}
</p>
<p>Stock: {d.currentStock} units</p>
<p>Velocity: {d.velocityDaily.toFixed(1)}/day</p>
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
</div>
);
}}
/>
<Scatter data={products} fillOpacity={0.7}>
{products.map((entry, i) => (
<Cell key={i} fill={getRiskColor(entry)} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,230 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, ScatterChart, Scatter, ZAxis } from 'recharts';
import config from '../../config';
import { useState, useEffect } from 'react';
interface VendorData {
performance: {
vendor: string;
salesVolume: number;
profitMargin: number;
stockTurnover: number;
productCount: number;
growth: number;
}[];
comparison?: {
vendor: string;
salesPerProduct: number;
averageMargin: number;
size: number;
}[];
trends?: {
vendor: string;
month: string;
sales: number;
}[];
}
export function VendorPerformance() {
const [vendorData, setVendorData] = useState<VendorData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Use plain fetch to bypass cache issues with React Query
const fetchData = async () => {
try {
setIsLoading(true);
// Add cache-busting parameter
const response = await fetch(`${config.apiUrl}/analytics/vendors?nocache=${Date.now()}`, {
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
});
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const rawData = await response.json();
if (!rawData || !rawData.performance) {
throw new Error('Invalid response format');
}
// Create a complete structure even if some parts are missing
const data: VendorData = {
performance: rawData.performance.map((vendor: any) => ({
vendor: vendor.vendor || '',
salesVolume: vendor.salesVolume !== null ? Number(vendor.salesVolume) : 0,
profitMargin: vendor.profitMargin !== null ? Number(vendor.profitMargin) : 0,
stockTurnover: vendor.stockTurnover !== null ? Number(vendor.stockTurnover) : 0,
productCount: Number(vendor.productCount) || 0,
growth: vendor.growth !== null ? Number(vendor.growth) : 0
})),
comparison: rawData.comparison?.map((vendor: any) => ({
vendor: vendor.vendor || '',
salesPerProduct: vendor.salesPerProduct !== null ? Number(vendor.salesPerProduct) : 0,
averageMargin: vendor.averageMargin !== null ? Number(vendor.averageMargin) : 0,
size: Number(vendor.size) || 0
})) || [],
trends: rawData.trends?.map((vendor: any) => ({
vendor: vendor.vendor || '',
month: vendor.month || '',
sales: Number(vendor.sales) || 0
})) || []
};
setVendorData(data);
} catch (err) {
console.error('Error fetching vendor data:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
if (isLoading) {
return <div>Loading vendor performance...</div>;
}
if (error || !vendorData) {
return <div className="text-red-500">Error loading vendor data: {error}</div>;
}
// Ensure we have at least the performance data
const sortedPerformance = vendorData.performance
.sort((a, b) => b.salesVolume - a.salesVolume)
.slice(0, 10);
// Use simplified version if comparison data is missing
const hasComparisonData = vendorData.comparison && vendorData.comparison.length > 0;
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Top Vendors by Sales Volume</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={sortedPerformance}>
<XAxis dataKey="vendor" />
<YAxis tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`} />
<Tooltip
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales Volume']}
/>
<Bar
dataKey="salesVolume"
fill="#60a5fa"
name="Sales Volume"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{hasComparisonData ? (
<Card>
<CardHeader>
<CardTitle>Vendor Performance Matrix</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<ScatterChart>
<XAxis
dataKey="salesPerProduct"
name="Sales per Product"
tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`}
/>
<YAxis
dataKey="averageMargin"
name="Average Margin"
tickFormatter={(value) => `${value.toFixed(0)}%`}
/>
<ZAxis
dataKey="size"
range={[50, 400]}
name="Product Count"
/>
<Tooltip
formatter={(value: number, name: string) => {
if (name === 'Sales per Product') return [`$${value.toLocaleString()}`, name];
if (name === 'Average Margin') return [`${value.toFixed(1)}%`, name];
return [value, name];
}}
/>
<Scatter
data={vendorData.comparison}
fill="#60a5fa"
name="Vendors"
/>
</ScatterChart>
</ResponsiveContainer>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Vendor Profit Margins</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={sortedPerformance}>
<XAxis dataKey="vendor" />
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']}
/>
<Bar
dataKey="profitMargin"
fill="#4ade80"
name="Profit Margin"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
</div>
<Card>
<CardHeader>
<CardTitle>Vendor Performance Details</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{sortedPerformance.map((vendor) => (
<div key={`${vendor.vendor}-${vendor.salesVolume}`} className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium">{vendor.vendor}</p>
<p className="text-sm text-muted-foreground">
{vendor.productCount} products
</p>
</div>
<div className="ml-4 text-right space-y-1">
<p className="text-sm font-medium">
${vendor.salesVolume.toLocaleString()} sales
</p>
<p className="text-sm text-muted-foreground">
{vendor.profitMargin.toFixed(1)}% margin
</p>
<p className="text-sm text-muted-foreground">
{vendor.stockTurnover.toFixed(1)}x turnover
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -220,7 +220,6 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<InfoItem label="Current Price" value={formatCurrency(product.currentPrice)} /> <InfoItem label="Current Price" value={formatCurrency(product.currentPrice)} />
<InfoItem label="Regular Price" value={formatCurrency(product.currentRegularPrice)} /> <InfoItem label="Regular Price" value={formatCurrency(product.currentRegularPrice)} />
<InfoItem label="Cost Price" value={formatCurrency(product.currentCostPrice)} /> <InfoItem label="Cost Price" value={formatCurrency(product.currentCostPrice)} />
<InfoItem label="Landing Cost" value={formatCurrency(product.currentLandingCostPrice)} />
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>

View File

@@ -119,7 +119,6 @@ const BASE_FILTER_OPTIONS: FilterOption[] = [
{ id: "currentPrice", label: "Current Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "currentPrice", label: "Current Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "currentRegularPrice", label: "Regular Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "currentRegularPrice", label: "Regular Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "currentCostPrice", label: "Cost Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "currentCostPrice", label: "Cost Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "currentLandingCostPrice", label: "Landing Cost", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
// Valuation Group // Valuation Group
{ id: "currentStockCost", label: "Current Stock Cost", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "currentStockCost", label: "Current Stock Cost", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },

View File

@@ -95,7 +95,6 @@ export const AVAILABLE_COLUMNS: ColumnDef[] = [
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, { key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentRegularPrice', label: 'Regular Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, { key: 'currentRegularPrice', label: 'Regular Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentCostPrice', label: 'Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, { key: 'currentCostPrice', label: 'Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentLandingCostPrice', label: 'Landing Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentStockCost', label: 'Stock Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, { key: 'currentStockCost', label: 'Stock Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentStockRetail', label: 'Stock Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, { key: 'currentStockRetail', label: 'Stock Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentStockGross', label: 'Stock Gross', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, { key: 'currentStockGross', label: 'Stock Gross', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },

View File

@@ -1,113 +1,142 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs'; import { InventoryTrends } from '../components/analytics/InventoryTrends';
import { ProfitAnalysis } from '../components/analytics/ProfitAnalysis'; import { PortfolioAnalysis } from '../components/analytics/PortfolioAnalysis';
import { VendorPerformance } from '../components/analytics/VendorPerformance'; import { CapitalEfficiency } from '../components/analytics/CapitalEfficiency';
import { StockAnalysis } from '../components/analytics/StockAnalysis'; import { StockHealth } from '../components/analytics/StockHealth';
import { PriceAnalysis } from '../components/analytics/PriceAnalysis'; import { AgingSellThrough } from '../components/analytics/AgingSellThrough';
import { CategoryPerformance } from '../components/analytics/CategoryPerformance'; import { StockoutRisk } from '../components/analytics/StockoutRisk';
import { DiscountImpact } from '../components/analytics/DiscountImpact';
import { GrowthMomentum } from '../components/analytics/GrowthMomentum';
import config from '../config'; import config from '../config';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { DollarSign, RefreshCw, TrendingUp, Calendar } from 'lucide-react';
import { formatCurrency } from '../utils/formatCurrency';
interface AnalyticsStats { interface InventorySummary {
profitMargin: number; stockInvestment: number;
averageMarkup: number; onOrderValue: number;
stockTurnoverRate: number; inventoryTurns: number;
vendorCount: number; gmroi: number;
categoryCount: number; avgStockCoverDays: number;
averageOrderValue: number; productsInStock: number;
deadStockProducts: number;
deadStockValue: number;
} }
export function Analytics() { export function Analytics() {
const { data: stats, isLoading: statsLoading } = useQuery<AnalyticsStats>({ const { data: summary, isLoading } = useQuery<InventorySummary>({
queryKey: ['analytics-stats'], queryKey: ['inventory-summary'],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/stats`); const response = await fetch(`${config.apiUrl}/analytics/inventory-summary`);
if (!response.ok) { if (!response.ok) throw new Error('Failed to fetch inventory summary');
throw new Error('Failed to fetch analytics stats');
}
return response.json(); return response.json();
}, },
}); });
if (statsLoading || !stats) {
return <div className="p-8">Loading analytics...</div>;
}
return ( return (
<motion.div layout className="flex-1 space-y-4 p-8 pt-6"> <motion.div layout className="flex-1 space-y-6 p-8 pt-6">
<div className="flex items-center justify-between space-y-2"> <div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Analytics</h2> <h2 className="text-3xl font-bold tracking-tight">Analytics</h2>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> {/* KPI Summary Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<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"> <CardTitle className="text-sm font-medium">Stock Investment</CardTitle>
Overall Profit Margin <DollarSign className="h-4 w-4 text-muted-foreground" />
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> {isLoading || !summary ? (
{stats.profitMargin.toFixed(1)}% <div className="h-8 w-24 animate-pulse rounded bg-muted" />
</div> ) : (
<>
<div className="text-2xl font-bold">{formatCurrency(summary.stockInvestment)}</div>
<p className="text-xs text-muted-foreground">
{formatCurrency(summary.onOrderValue)} on order
</p>
</>
)}
</CardContent> </CardContent>
</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"> <CardTitle className="text-sm font-medium">Inventory Turns</CardTitle>
Average Markup <RefreshCw className="h-4 w-4 text-muted-foreground" />
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> {isLoading || !summary ? (
{stats.averageMarkup.toFixed(1)}% <div className="h-8 w-24 animate-pulse rounded bg-muted" />
</div> ) : (
<>
<div className="text-2xl font-bold">{summary.inventoryTurns.toFixed(1)}x</div>
<p className="text-xs text-muted-foreground">annualized (30d basis)</p>
</>
)}
</CardContent> </CardContent>
</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"> <CardTitle className="text-sm font-medium">GMROI</CardTitle>
Stock Turnover Rate <TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> {isLoading || !summary ? (
{stats.stockTurnoverRate.toFixed(2)}x <div className="h-8 w-24 animate-pulse rounded bg-muted" />
</div> ) : (
<>
<div className="text-2xl font-bold">{summary.gmroi.toFixed(2)}</div>
<p className="text-xs text-muted-foreground">
profit per $ invested (30d)
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Stock Cover</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoading || !summary ? (
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
) : (
<>
<div className="text-2xl font-bold">{Math.round(summary.avgStockCoverDays)} days</div>
<p className="text-xs text-muted-foreground">
{summary.productsInStock.toLocaleString()} products in stock
</p>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<Tabs defaultValue="profit" className="space-y-4"> {/* Section 2: Inventory Value Trends */}
<TabsList className="grid w-full grid-cols-5 lg:w-[600px]"> <InventoryTrends />
<TabsTrigger value="profit">Profit</TabsTrigger>
<TabsTrigger value="vendors">Vendors</TabsTrigger> {/* Section 3: ABC Portfolio Analysis */}
<TabsTrigger value="stock">Stock</TabsTrigger> <PortfolioAnalysis />
<TabsTrigger value="pricing">Pricing</TabsTrigger>
<TabsTrigger value="categories">Categories</TabsTrigger> {/* Section 4: Capital Efficiency */}
</TabsList> <CapitalEfficiency />
<TabsContent value="profit" className="space-y-4"> {/* Section 5: Demand & Stock Health */}
<ProfitAnalysis /> <StockHealth />
</TabsContent>
{/* Section 6: Aging & Sell-Through */}
<TabsContent value="vendors" className="space-y-4"> <AgingSellThrough />
<VendorPerformance />
</TabsContent> {/* Section 7: Reorder Risk */}
<StockoutRisk />
<TabsContent value="stock" className="space-y-4">
<StockAnalysis /> {/* Section 8: Discount Impact */}
</TabsContent> <DiscountImpact />
<TabsContent value="pricing" className="space-y-4"> {/* Section 9: YoY Growth Momentum */}
<PriceAnalysis /> <GrowthMomentum />
</TabsContent>
<TabsContent value="categories" className="space-y-4">
<CategoryPerformance />
</TabsContent>
</Tabs>
</motion.div> </motion.div>
); );
} }

View File

@@ -6,7 +6,6 @@ export interface Product {
price: string; // numeric(15,3) price: string; // numeric(15,3)
regular_price: string; // numeric(15,3) regular_price: string; // numeric(15,3)
cost_price: string; // numeric(15,3) cost_price: string; // numeric(15,3)
landing_cost_price: string | null; // numeric(15,3)
barcode: string; barcode: string;
vendor: string; vendor: string;
vendor_reference: string; vendor_reference: string;
@@ -126,7 +125,6 @@ export interface ProductMetric {
currentPrice: number | null; currentPrice: number | null;
currentRegularPrice: number | null; currentRegularPrice: number | null;
currentCostPrice: number | null; currentCostPrice: number | null;
currentLandingCostPrice: number | null;
currentStock: number; currentStock: number;
currentStockCost: number | null; currentStockCost: number | null;
currentStockRetail: number | null; currentStockRetail: number | null;
@@ -310,7 +308,6 @@ export type ProductMetricColumnKey =
| 'currentPrice' | 'currentPrice'
| 'currentRegularPrice' | 'currentRegularPrice'
| 'currentCostPrice' | 'currentCostPrice'
| 'currentLandingCostPrice'
| 'configSafetyStock' | 'configSafetyStock'
| 'replenishmentUnits' | 'replenishmentUnits'
| 'stockCoverInDays' | 'stockCoverInDays'

View File

@@ -0,0 +1,5 @@
export function formatCurrency(value: number): string {
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}k`;
return `$${value.toFixed(0)}`;
}

File diff suppressed because one or more lines are too long