Compare commits
2 Commits
8044771301
...
12cc7a4639
| Author | SHA1 | Date | |
|---|---|---|---|
| 12cc7a4639 | |||
| 9b2f9016f6 |
@@ -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
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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.
|
||||||
@@ -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 $$;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
130
inventory/src/components/analytics/AgingSellThrough.tsx
Normal file
130
inventory/src/components/analytics/AgingSellThrough.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
inventory/src/components/analytics/CapitalEfficiency.tsx
Normal file
163
inventory/src/components/analytics/CapitalEfficiency.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
146
inventory/src/components/analytics/DiscountImpact.tsx
Normal file
146
inventory/src/components/analytics/DiscountImpact.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
inventory/src/components/analytics/GrowthMomentum.tsx
Normal file
160
inventory/src/components/analytics/GrowthMomentum.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
inventory/src/components/analytics/InventoryTrends.tsx
Normal file
127
inventory/src/components/analytics/InventoryTrends.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
inventory/src/components/analytics/PortfolioAnalysis.tsx
Normal file
213
inventory/src/components/analytics/PortfolioAnalysis.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
244
inventory/src/components/analytics/StockHealth.tsx
Normal file
244
inventory/src/components/analytics/StockHealth.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
inventory/src/components/analytics/StockoutRisk.tsx
Normal file
174
inventory/src/components/analytics/StockoutRisk.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"] },
|
||||||
|
|||||||
@@ -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) : '-' },
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
5
inventory/src/utils/formatCurrency.ts
Normal file
5
inventory/src/utils/formatCurrency.ts
Normal 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
Reference in New Issue
Block a user