3 Commits

29 changed files with 5245 additions and 2893 deletions

View File

@@ -5,9 +5,9 @@ const { Pool } = require('pg'); // Assuming you use 'pg'
// --- Configuration ---
// Toggle these constants to enable/disable specific steps for testing
const RUN_DAILY_SNAPSHOTS = true;
const RUN_PRODUCT_METRICS = true;
const RUN_PERIODIC_METRICS = true;
const RUN_DAILY_SNAPSHOTS = false;
const RUN_PRODUCT_METRICS = false;
const RUN_PERIODIC_METRICS = false;
const RUN_BRAND_METRICS = true;
const RUN_VENDOR_METRICS = true;
const RUN_CATEGORY_METRICS = true;
@@ -401,27 +401,43 @@ async function executeSqlStep(config, progress) {
start_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
end_time TIMESTAMP WITH TIME ZONE,
duration_seconds INTEGER,
status TEXT, -- 'running', 'completed', 'failed', 'cancelled'
status TEXT, -- Will be altered to enum if needed below
error_message TEXT,
additional_info JSONB
);
`);
// Ensure the calculation_status enum type exists if needed
await connection.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'calculation_status') THEN
CREATE TYPE calculation_status AS ENUM ('running', 'completed', 'failed', 'cancelled');
-- If needed, alter the existing table to use the enum
ALTER TABLE calculate_history
ALTER COLUMN status TYPE calculation_status
USING status::calculation_status;
END IF;
END
$$;
`);
// Mark previous runs of this type as cancelled
await connection.query(`
UPDATE calculate_history
SET
status = 'cancelled',
status = 'cancelled'::calculation_status,
end_time = NOW(),
duration_seconds = EXTRACT(EPOCH FROM (NOW() - start_time))::INTEGER,
error_message = 'Previous calculation was not completed properly or was superseded.'
WHERE status = 'running' AND additional_info->>'type' = $1::text;
WHERE status = 'running'::calculation_status AND additional_info->>'type' = $1::text;
`, [config.historyType]);
// Create history record for this run
const historyResult = await connection.query(`
INSERT INTO calculate_history (status, additional_info)
VALUES ('running', jsonb_build_object('type', $1::text, 'sql_file', $2::text))
VALUES ('running'::calculation_status, jsonb_build_object('type', $1::text, 'sql_file', $2::text))
RETURNING id;
`, [config.historyType, config.sqlFile]);
calculateHistoryId = historyResult.rows[0].id;
@@ -502,7 +518,7 @@ async function executeSqlStep(config, progress) {
SET
end_time = NOW(),
duration_seconds = $1::integer,
status = 'completed'
status = 'completed'::calculation_status
WHERE id = $2::integer;
`, [stepDuration, calculateHistoryId]);
@@ -551,7 +567,7 @@ async function executeSqlStep(config, progress) {
SET
end_time = NOW(),
duration_seconds = $1::integer,
status = $2::text,
status = $2::calculation_status,
error_message = $3::text
WHERE id = $4::integer;
`, [errorDuration, finalStatus, errorMessage.substring(0, 1000), calculateHistoryId]); // Limit error message size

View File

@@ -0,0 +1,200 @@
-- Description: Rebuilds daily product snapshots from scratch using real orders data.
-- Fixes issues with duplicated/inflated metrics.
-- Dependencies: Core import tables (products, orders, purchase_orders).
-- Frequency: One-time run to clear out problematic data.
DO $$
DECLARE
_module_name TEXT := 'rebuild_daily_snapshots';
_start_time TIMESTAMPTZ := clock_timestamp();
_date DATE;
_count INT;
_total_records INT := 0;
_begin_date DATE := (SELECT MIN(date)::date FROM orders WHERE date >= '2024-01-01'); -- Starting point for data rebuild
_end_date DATE := CURRENT_DATE;
BEGIN
RAISE NOTICE 'Beginning daily snapshots rebuild from % to %. Starting at %', _begin_date, _end_date, _start_time;
-- First truncate the existing snapshots to ensure a clean slate
TRUNCATE TABLE public.daily_product_snapshots;
RAISE NOTICE 'Cleared existing snapshot data';
-- Now rebuild the snapshots day by day
_date := _begin_date;
WHILE _date <= _end_date LOOP
RAISE NOTICE 'Processing date %...', _date;
-- Create snapshots for this date
WITH SalesData AS (
SELECT
p.pid,
p.sku,
-- Count orders to ensure we only include products with real activity
COUNT(o.id) as order_count,
-- Aggregate Sales (Quantity > 0, Status not Canceled/Returned)
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.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 p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue,
-- Aggregate Returns (Quantity < 0 or Status = Returned)
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN ABS(o.quantity) ELSE 0 END), 0) AS units_returned,
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN o.price * ABS(o.quantity) ELSE 0 END), 0.00) AS returns_revenue
FROM public.products p
LEFT JOIN public.orders o
ON p.pid = o.pid
AND o.date::date = _date
GROUP BY p.pid, p.sku
HAVING COUNT(o.id) > 0 -- Only include products with actual orders for this date
),
ReceivingData AS (
SELECT
po.pid,
-- Count POs to ensure we only include products with real activity
COUNT(po.po_id) as po_count,
-- Calculate received quantity for this day
COALESCE(
-- First try the received field from purchase_orders table (if received on this date)
SUM(CASE WHEN po.date::date = _date THEN po.received ELSE 0 END),
-- Otherwise try receiving_history JSON
SUM(
CASE
WHEN (rh.item->>'date')::date = _date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'received_at')::date = _date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'receipt_date')::date = _date THEN (rh.item->>'qty')::numeric
ELSE 0
END
),
0
) AS units_received,
COALESCE(
-- First try the actual cost_price from purchase_orders
SUM(CASE WHEN po.date::date = _date THEN po.received * po.cost_price ELSE 0 END),
-- Otherwise try receiving_history JSON
SUM(
CASE
WHEN (rh.item->>'date')::date = _date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'received_at')::date = _date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'receipt_date')::date = _date THEN (rh.item->>'qty')::numeric
ELSE 0
END
* COALESCE((rh.item->>'cost')::numeric, po.cost_price)
),
0.00
) AS cost_received
FROM public.purchase_orders po
LEFT JOIN LATERAL jsonb_array_elements(po.receiving_history) AS rh(item) ON
jsonb_typeof(po.receiving_history) = 'array' AND
jsonb_array_length(po.receiving_history) > 0 AND
(
(rh.item->>'date')::date = _date OR
(rh.item->>'received_at')::date = _date OR
(rh.item->>'receipt_date')::date = _date
)
-- Include POs with the current date or relevant receiving_history
WHERE
po.date::date = _date OR
jsonb_typeof(po.receiving_history) = 'array' AND
jsonb_array_length(po.receiving_history) > 0
GROUP BY po.pid
HAVING COUNT(po.po_id) > 0 OR SUM(
CASE
WHEN (rh.item->>'date')::date = _date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'received_at')::date = _date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'receipt_date')::date = _date THEN (rh.item->>'qty')::numeric
ELSE 0
END
) > 0
),
-- Get stock quantities for the day - note this is approximate since we're using current products data
StockData AS (
SELECT
p.pid,
p.stock_quantity,
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as effective_cost_price,
COALESCE(p.price, 0.00) as current_price,
COALESCE(p.regular_price, 0.00) as current_regular_price
FROM public.products p
)
INSERT INTO public.daily_product_snapshots (
snapshot_date,
pid,
sku,
eod_stock_quantity,
eod_stock_cost,
eod_stock_retail,
eod_stock_gross,
stockout_flag,
units_sold,
units_returned,
gross_revenue,
discounts,
returns_revenue,
net_revenue,
cogs,
gross_regular_revenue,
profit,
units_received,
cost_received,
calculation_timestamp
)
SELECT
_date AS snapshot_date,
COALESCE(sd.pid, rd.pid) AS pid,
sd.sku,
-- Use current stock as approximation, since historical stock data may not be available
s.stock_quantity AS eod_stock_quantity,
s.stock_quantity * s.effective_cost_price AS eod_stock_cost,
s.stock_quantity * s.current_price AS eod_stock_retail,
s.stock_quantity * s.current_regular_price AS eod_stock_gross,
(s.stock_quantity <= 0) AS stockout_flag,
-- Sales metrics
COALESCE(sd.units_sold, 0),
COALESCE(sd.units_returned, 0),
COALESCE(sd.gross_revenue_unadjusted, 0.00),
COALESCE(sd.discounts, 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.cogs, 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,
-- Receiving metrics
COALESCE(rd.units_received, 0),
COALESCE(rd.cost_received, 0.00),
_start_time
FROM SalesData sd
FULL OUTER JOIN ReceivingData rd ON sd.pid = rd.pid
LEFT JOIN StockData s ON COALESCE(sd.pid, rd.pid) = s.pid
WHERE (COALESCE(sd.order_count, 0) > 0 OR COALESCE(rd.po_count, 0) > 0);
-- Get record count for this day
GET DIAGNOSTICS _count = ROW_COUNT;
_total_records := _total_records + _count;
RAISE NOTICE 'Added % snapshot records for date %', _count, _date;
-- Move to next day
_date := _date + INTERVAL '1 day';
END LOOP;
RAISE NOTICE 'Rebuilding daily snapshots complete. Added % total records across % days.', _total_records, (_end_date - _begin_date)::integer + 1;
-- Update the status table for daily_snapshots
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
VALUES ('daily_snapshots', _start_time)
ON CONFLICT (module_name) DO UPDATE SET last_calculation_timestamp = _start_time;
-- Now update product_metrics based on the rebuilt snapshots
RAISE NOTICE 'Triggering update of product_metrics table...';
-- Call the update_product_metrics procedure directly
-- Your system might use a different method to trigger this update
PERFORM pg_notify('recalculate_metrics', 'product_metrics');
RAISE NOTICE 'Rebuild complete. Duration: %', clock_timestamp() - _start_time;
END $$;

View File

@@ -6,6 +6,7 @@ DO $$
DECLARE
_module_name VARCHAR := 'brand_metrics';
_start_time TIMESTAMPTZ := clock_timestamp();
_min_revenue NUMERIC := 50.00; -- Minimum revenue threshold for margin calculation
BEGIN
RAISE NOTICE 'Running % calculation...', _module_name;
@@ -19,14 +20,26 @@ BEGIN
SUM(pm.current_stock) AS current_stock_units,
SUM(pm.current_stock_cost) AS current_stock_cost,
SUM(pm.current_stock_retail) AS current_stock_retail,
SUM(pm.sales_7d) AS sales_7d, SUM(pm.revenue_7d) AS revenue_7d,
SUM(pm.sales_30d) AS sales_30d, SUM(pm.revenue_30d) AS revenue_30d,
SUM(pm.profit_30d) AS profit_30d, SUM(pm.cogs_30d) AS cogs_30d,
SUM(pm.sales_365d) AS sales_365d, SUM(pm.revenue_365d) AS revenue_365d,
SUM(pm.lifetime_sales) AS lifetime_sales, SUM(pm.lifetime_revenue) AS lifetime_revenue
-- Only include products with valid sales data in each time period
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
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.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(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
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.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
FROM public.product_metrics pm
JOIN public.products p ON pm.pid = p.pid
-- WHERE p.visible = true -- Optional: filter only visible products for brand metrics?
GROUP BY brand_group
),
AllBrands AS (
@@ -58,8 +71,14 @@ BEGIN
COALESCE(ba.profit_30d, 0.00), COALESCE(ba.cogs_30d, 0.00),
COALESCE(ba.sales_365d, 0), COALESCE(ba.revenue_365d, 0.00),
COALESCE(ba.lifetime_sales, 0), COALESCE(ba.lifetime_revenue, 0.00),
-- KPIs
(ba.profit_30d / NULLIF(ba.revenue_30d, 0)) * 100.0
-- KPIs - Calculate margin only for brands with significant revenue
CASE
WHEN COALESCE(ba.revenue_30d, 0) >= _min_revenue THEN
-- Directly calculate margin from revenue and cogs for consistency
-- This is mathematically equivalent to profit/revenue but more explicit
((COALESCE(ba.revenue_30d, 0) - COALESCE(ba.cogs_30d, 0)) / COALESCE(ba.revenue_30d, 1)) * 100.0
ELSE NULL -- No margin for low/no revenue brands
END
FROM AllBrands b
LEFT JOIN BrandAggregates ba ON b.brand_group = ba.brand_group

View File

@@ -9,9 +9,44 @@ DECLARE
BEGIN
RAISE NOTICE 'Running % calculation...', _module_name;
WITH CategoryAggregates AS (
WITH
-- Identify the hierarchy depth for each category
CategoryDepth AS (
WITH RECURSIVE CategoryTree AS (
-- Base case: Start with categories without parents (root categories)
SELECT cat_id, name, parent_id, 0 AS depth
FROM public.categories
WHERE parent_id IS NULL
UNION ALL
-- Recursive step: Add child categories with incremented depth
SELECT c.cat_id, c.name, c.parent_id, ct.depth + 1
FROM public.categories c
JOIN CategoryTree ct ON c.parent_id = ct.cat_id
)
SELECT cat_id, depth
FROM CategoryTree
),
-- For each product, find the most specific (deepest) category it belongs to
ProductDeepestCategory AS (
SELECT
pc.cat_id,
pc.pid,
pc.cat_id
FROM public.product_categories pc
JOIN CategoryDepth cd ON pc.cat_id = cd.cat_id
-- This is the key part: for each product, select only the category with maximum depth
WHERE (pc.pid, cd.depth) IN (
SELECT pc2.pid, MAX(cd2.depth)
FROM public.product_categories pc2
JOIN CategoryDepth cd2 ON pc2.cat_id = cd2.cat_id
GROUP BY pc2.pid
)
),
-- Calculate metrics only at the most specific category level for each product
CategoryAggregates AS (
SELECT
pdc.cat_id,
-- Counts
COUNT(DISTINCT pm.pid) AS product_count,
COUNT(DISTINCT CASE WHEN pm.is_visible THEN pm.pid END) AS active_product_count,
@@ -20,19 +55,74 @@ BEGIN
SUM(pm.current_stock) AS current_stock_units,
SUM(pm.current_stock_cost) AS current_stock_cost,
SUM(pm.current_stock_retail) AS current_stock_retail,
-- Rolling Periods (Sum directly from product_metrics)
SUM(pm.sales_7d) AS sales_7d, SUM(pm.revenue_7d) AS revenue_7d,
SUM(pm.sales_30d) AS sales_30d, SUM(pm.revenue_30d) AS revenue_30d,
SUM(pm.profit_30d) AS profit_30d, SUM(pm.cogs_30d) AS cogs_30d,
SUM(pm.sales_365d) AS sales_365d, SUM(pm.revenue_365d) AS revenue_365d,
SUM(pm.lifetime_sales) AS lifetime_sales, SUM(pm.lifetime_revenue) AS lifetime_revenue,
-- Data for KPIs
SUM(pm.avg_stock_units_30d) AS total_avg_stock_units_30d -- Sum of averages (use cautiously)
-- Rolling Periods - Only include products with actual sales in each period
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_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.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(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
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.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue,
-- Data for KPIs - Only average stock for products with stock
SUM(CASE WHEN pm.avg_stock_units_30d > 0 THEN pm.avg_stock_units_30d ELSE 0 END) AS total_avg_stock_units_30d
FROM public.product_metrics pm
JOIN public.product_categories pc ON pm.pid = pc.pid
-- Optional: JOIN products p ON pm.pid = p.pid if needed for filtering (e.g., only visible products)
-- WHERE p.visible = true -- Example filter
GROUP BY pc.cat_id
JOIN ProductDeepestCategory pdc ON pm.pid = pdc.pid
GROUP BY pdc.cat_id
),
-- Use a flat approach to build the complete category tree with aggregate values
CategoryTree AS (
WITH RECURSIVE CategoryHierarchy AS (
SELECT
c.cat_id,
c.name,
c.parent_id,
c.cat_id as leaf_id, -- Track original leaf category
ARRAY[c.cat_id] as path
FROM public.categories c
UNION ALL
SELECT
p.cat_id,
p.name,
p.parent_id,
ch.leaf_id, -- Keep track of the original leaf
p.cat_id || ch.path
FROM public.categories p
JOIN CategoryHierarchy ch ON p.cat_id = ch.parent_id
)
SELECT
ch.cat_id,
ch.leaf_id
FROM CategoryHierarchy ch
),
-- Now aggregate by maintaining the link between leaf categories and ancestors
RollupMetrics AS (
SELECT
ct.cat_id,
SUM(ca.product_count) AS product_count,
SUM(ca.active_product_count) AS active_product_count,
SUM(ca.replenishable_product_count) AS replenishable_product_count,
SUM(ca.current_stock_units) AS current_stock_units,
SUM(ca.current_stock_cost) AS current_stock_cost,
SUM(ca.current_stock_retail) AS current_stock_retail,
SUM(ca.sales_7d) AS sales_7d,
SUM(ca.revenue_7d) AS revenue_7d,
SUM(ca.sales_30d) AS sales_30d,
SUM(ca.revenue_30d) AS revenue_30d,
SUM(ca.cogs_30d) AS cogs_30d,
SUM(ca.profit_30d) AS profit_30d,
SUM(ca.sales_365d) AS sales_365d,
SUM(ca.revenue_365d) AS revenue_365d,
SUM(ca.lifetime_sales) AS lifetime_sales,
SUM(ca.lifetime_revenue) AS lifetime_revenue,
SUM(ca.total_avg_stock_units_30d) AS total_avg_stock_units_30d
FROM CategoryTree ct
JOIN CategoryAggregates ca ON ct.leaf_id = ca.cat_id
GROUP BY ct.cat_id
)
INSERT INTO public.category_metrics (
category_id, category_name, category_type, parent_id, last_calculated,
@@ -49,22 +139,22 @@ BEGIN
c.parent_id,
_start_time,
-- Base Aggregates
COALESCE(ca.product_count, 0),
COALESCE(ca.active_product_count, 0),
COALESCE(ca.replenishable_product_count, 0),
COALESCE(ca.current_stock_units, 0),
COALESCE(ca.current_stock_cost, 0.00),
COALESCE(ca.current_stock_retail, 0.00),
COALESCE(ca.sales_7d, 0), COALESCE(ca.revenue_7d, 0.00),
COALESCE(ca.sales_30d, 0), COALESCE(ca.revenue_30d, 0.00),
COALESCE(ca.profit_30d, 0.00), COALESCE(ca.cogs_30d, 0.00),
COALESCE(ca.sales_365d, 0), COALESCE(ca.revenue_365d, 0.00),
COALESCE(ca.lifetime_sales, 0), COALESCE(ca.lifetime_revenue, 0.00),
COALESCE(rm.product_count, 0),
COALESCE(rm.active_product_count, 0),
COALESCE(rm.replenishable_product_count, 0),
COALESCE(rm.current_stock_units, 0),
COALESCE(rm.current_stock_cost, 0.00),
COALESCE(rm.current_stock_retail, 0.00),
COALESCE(rm.sales_7d, 0), COALESCE(rm.revenue_7d, 0.00),
COALESCE(rm.sales_30d, 0), COALESCE(rm.revenue_30d, 0.00),
COALESCE(rm.profit_30d, 0.00), COALESCE(rm.cogs_30d, 0.00),
COALESCE(rm.sales_365d, 0), COALESCE(rm.revenue_365d, 0.00),
COALESCE(rm.lifetime_sales, 0), COALESCE(rm.lifetime_revenue, 0.00),
-- KPIs
(ca.profit_30d / NULLIF(ca.revenue_30d, 0)) * 100.0,
ca.sales_30d / NULLIF(ca.total_avg_stock_units_30d, 0) -- Simple unit-based turnover
(rm.profit_30d / NULLIF(rm.revenue_30d, 0)) * 100.0,
rm.sales_30d / NULLIF(rm.total_avg_stock_units_30d, 0) -- Simple unit-based turnover
FROM public.categories c -- Start from categories to include those with no products yet
LEFT JOIN CategoryAggregates ca ON c.cat_id = ca.cat_id
LEFT JOIN RollupMetrics rm ON c.cat_id = rm.cat_id
ON CONFLICT (category_id) DO UPDATE SET
category_name = EXCLUDED.category_name,

View File

@@ -21,11 +21,24 @@ BEGIN
SUM(pm.current_stock_retail) AS current_stock_retail,
SUM(pm.on_order_qty) AS on_order_units,
SUM(pm.on_order_cost) AS on_order_cost,
SUM(pm.sales_7d) AS sales_7d, SUM(pm.revenue_7d) AS revenue_7d,
SUM(pm.sales_30d) AS sales_30d, SUM(pm.revenue_30d) AS revenue_30d,
SUM(pm.profit_30d) AS profit_30d, SUM(pm.cogs_30d) AS cogs_30d,
SUM(pm.sales_365d) AS sales_365d, SUM(pm.revenue_365d) AS revenue_365d,
SUM(pm.lifetime_sales) AS lifetime_sales, SUM(pm.lifetime_revenue) AS lifetime_revenue
-- Only include products with valid sales data in each time period
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
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.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(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
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.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
FROM public.product_metrics pm
JOIN public.products p ON pm.pid = p.pid
WHERE p.vendor IS NOT NULL AND p.vendor <> ''

View File

@@ -9,6 +9,8 @@ DECLARE
_start_time TIMESTAMPTZ := clock_timestamp(); -- Time execution started
_last_calc_time TIMESTAMPTZ;
_target_date DATE := CURRENT_DATE; -- Always recalculate today for simplicity with hourly runs
_total_records INT := 0;
_has_orders BOOLEAN := FALSE;
BEGIN
-- Get the timestamp before the last successful run of this module
SELECT last_calculation_timestamp INTO _last_calc_time
@@ -17,11 +19,48 @@ BEGIN
RAISE NOTICE 'Running % for date %. Start Time: %', _module_name, _target_date, _start_time;
-- Use CTEs to aggregate data for the target date
-- CRITICAL FIX: Check if we have any orders or receiving activity for today
-- to prevent creating artificial records when no real activity exists
SELECT EXISTS (
SELECT 1 FROM public.orders WHERE date::date = _target_date
UNION
SELECT 1 FROM public.purchase_orders
WHERE date::date = _target_date
OR EXISTS (
SELECT 1 FROM jsonb_array_elements(receiving_history) AS rh
WHERE jsonb_typeof(receiving_history) = 'array'
AND (
(rh->>'date')::date = _target_date OR
(rh->>'received_at')::date = _target_date OR
(rh->>'receipt_date')::date = _target_date
)
)
LIMIT 1
) INTO _has_orders;
-- If no orders or receiving activity found for today, log and exit
IF NOT _has_orders THEN
RAISE NOTICE 'No orders or receiving activity found for % - skipping daily snapshot creation', _target_date;
-- Still update the calculate_status to prevent repeated attempts
UPDATE public.calculate_status
SET last_calculation_timestamp = _start_time
WHERE module_name = _module_name;
RETURN; -- Exit without creating snapshots
END IF;
-- IMPORTANT: First delete any existing data for this date to prevent duplication
DELETE FROM public.daily_product_snapshots
WHERE snapshot_date = _target_date;
-- Proceed with calculating daily metrics only for products with actual activity
WITH SalesData AS (
SELECT
p.pid,
p.sku,
-- Track number of orders to ensure we have real data
COUNT(o.id) as order_count,
-- Aggregate Sales (Quantity > 0, Status not Canceled/Returned)
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, -- Before discount
@@ -37,10 +76,13 @@ BEGIN
ON p.pid = o.pid
AND o.date::date = _target_date -- Cast to date to ensure compatibility regardless of original type
GROUP BY p.pid, p.sku
HAVING COUNT(o.id) > 0 -- CRITICAL: Only include products with actual orders
),
ReceivingData AS (
SELECT
po.pid,
-- Track number of POs to ensure we have real data
COUNT(po.po_id) as po_count,
-- Prioritize the actual table fields over the JSON data
COALESCE(
-- First try the received field from purchase_orders table
@@ -89,6 +131,15 @@ BEGIN
jsonb_typeof(po.receiving_history) = 'array' AND
jsonb_array_length(po.receiving_history) > 0
GROUP BY po.pid
-- CRITICAL: Only include products with actual receiving activity
HAVING COUNT(po.po_id) > 0 OR SUM(
CASE
WHEN (rh.item->>'date')::date = _target_date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'received_at')::date = _target_date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'receipt_date')::date = _target_date THEN (rh.item->>'qty')::numeric
ELSE 0
END
) > 0
),
CurrentStock AS (
-- Select current stock values directly from products table
@@ -100,7 +151,7 @@ BEGIN
COALESCE(regular_price, 0.00) as current_regular_price
FROM public.products
)
-- Upsert into the daily snapshots table
-- Now insert records, but ONLY for products with actual activity
INSERT INTO public.daily_product_snapshots (
snapshot_date,
pid,
@@ -125,8 +176,8 @@ BEGIN
)
SELECT
_target_date AS snapshot_date,
p.pid,
p.sku,
COALESCE(sd.pid, rd.pid) AS pid, -- Use sales or receiving PID
COALESCE(sd.sku, p.sku) AS sku, -- Get SKU from sales data or products table
-- Inventory Metrics (Using CurrentStock)
cs.stock_quantity AS eod_stock_quantity,
cs.stock_quantity * cs.effective_cost_price AS eod_stock_cost,
@@ -147,31 +198,15 @@ BEGIN
COALESCE(rd.units_received, 0),
COALESCE(rd.cost_received, 0.00),
_start_time -- Timestamp of this calculation run
FROM public.products p
LEFT JOIN CurrentStock cs ON p.pid = cs.pid
LEFT JOIN SalesData sd ON p.pid = sd.pid
LEFT JOIN ReceivingData rd ON p.pid = rd.pid
WHERE p.pid IS NOT NULL -- Ensure we only insert for existing products
FROM SalesData sd
FULL OUTER JOIN ReceivingData rd ON sd.pid = rd.pid
LEFT JOIN public.products p ON COALESCE(sd.pid, rd.pid) = p.pid
LEFT JOIN CurrentStock cs ON COALESCE(sd.pid, rd.pid) = cs.pid
WHERE p.pid IS NOT NULL; -- Ensure we only insert for existing products
ON CONFLICT (snapshot_date, pid) DO UPDATE SET
sku = EXCLUDED.sku,
eod_stock_quantity = EXCLUDED.eod_stock_quantity,
eod_stock_cost = EXCLUDED.eod_stock_cost,
eod_stock_retail = EXCLUDED.eod_stock_retail,
eod_stock_gross = EXCLUDED.eod_stock_gross,
stockout_flag = EXCLUDED.stockout_flag,
units_sold = EXCLUDED.units_sold,
units_returned = EXCLUDED.units_returned,
gross_revenue = EXCLUDED.gross_revenue,
discounts = EXCLUDED.discounts,
returns_revenue = EXCLUDED.returns_revenue,
net_revenue = EXCLUDED.net_revenue,
cogs = EXCLUDED.cogs,
gross_regular_revenue = EXCLUDED.gross_regular_revenue,
profit = EXCLUDED.profit,
units_received = EXCLUDED.units_received,
cost_received = EXCLUDED.cost_received,
calculation_timestamp = EXCLUDED.calculation_timestamp; -- Use the timestamp from this run
-- Get the total number of records inserted
GET DIAGNOSTICS _total_records = ROW_COUNT;
RAISE NOTICE 'Created % daily snapshot records for % with sales/receiving activity', _total_records, _target_date;
-- Update the status table with the timestamp from the START of this run
UPDATE public.calculate_status

View File

@@ -110,31 +110,37 @@ BEGIN
SUM(units_sold) AS total_units_sold,
SUM(net_revenue) AS total_net_revenue,
-- Specific time windows if we have enough data
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '6 days' THEN units_sold ELSE 0 END) AS sales_7d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '6 days' THEN net_revenue ELSE 0 END) AS revenue_7d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '13 days' THEN units_sold ELSE 0 END) AS sales_14d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '13 days' THEN net_revenue ELSE 0 END) AS revenue_14d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN units_sold ELSE 0 END) AS sales_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN net_revenue ELSE 0 END) AS revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN cogs ELSE 0 END) AS cogs_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN profit ELSE 0 END) AS profit_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN units_returned ELSE 0 END) AS returns_units_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN returns_revenue ELSE 0 END) AS returns_revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN discounts ELSE 0 END) AS discounts_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN gross_revenue ELSE 0 END) AS gross_revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN gross_regular_revenue ELSE 0 END) AS gross_regular_revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND stockout_flag THEN 1 ELSE 0 END) AS stockout_days_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '364 days' THEN units_sold ELSE 0 END) AS sales_365d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '364 days' THEN net_revenue ELSE 0 END) AS revenue_365d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN units_received ELSE 0 END) AS received_qty_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN cost_received ELSE 0 END) AS received_cost_30d,
-- Specific time windows using date range boundaries precisely
-- Use _current_date - INTERVAL '6 days' to include 7 days (today + 6 previous days)
-- This ensures we count exactly the right number of days in each period
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '6 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_7d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '6 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_7d,
-- Averages (check for NULLIF 0 days in period if filtering dates)
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN eod_stock_quantity END) AS avg_stock_units_30d,
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN eod_stock_cost END) AS avg_stock_cost_30d,
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN eod_stock_retail END) AS avg_stock_retail_30d,
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN eod_stock_gross END) AS avg_stock_gross_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '13 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_14d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '13 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_14d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN cogs ELSE 0 END) AS cogs_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN profit ELSE 0 END) AS profit_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN units_returned ELSE 0 END) AS returns_units_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN returns_revenue ELSE 0 END) AS returns_revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN discounts ELSE 0 END) AS discounts_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN gross_revenue ELSE 0 END) AS gross_revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN gross_regular_revenue ELSE 0 END) AS gross_regular_revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date AND stockout_flag THEN 1 ELSE 0 END) AS stockout_days_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '364 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_365d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '364 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_365d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN units_received ELSE 0 END) AS received_qty_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN cost_received ELSE 0 END) AS received_cost_30d,
-- Averages for stock levels - only include dates within the specified period
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_quantity END) AS avg_stock_units_30d,
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_cost END) AS avg_stock_cost_30d,
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_retail END) AS avg_stock_retail_30d,
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_gross END) AS avg_stock_gross_30d,
-- Lifetime - should match total values above
SUM(units_sold) AS lifetime_sales,
@@ -150,14 +156,14 @@ BEGIN
SELECT
pid,
date_first_sold,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '6 days' THEN units_sold ELSE 0 END) AS first_7_days_sales,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '6 days' THEN net_revenue ELSE 0 END) AS first_7_days_revenue,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '29 days' THEN units_sold ELSE 0 END) AS first_30_days_sales,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '29 days' THEN net_revenue ELSE 0 END) AS first_30_days_revenue,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '59 days' THEN units_sold ELSE 0 END) AS first_60_days_sales,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '59 days' THEN net_revenue ELSE 0 END) AS first_60_days_revenue,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '89 days' THEN units_sold ELSE 0 END) AS first_90_days_sales,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '89 days' THEN net_revenue ELSE 0 END) AS first_90_days_revenue
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '6 days' THEN units_sold ELSE 0 END) AS first_7_days_sales,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '6 days' THEN net_revenue ELSE 0 END) AS first_7_days_revenue,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '29 days' THEN units_sold ELSE 0 END) AS first_30_days_sales,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '29 days' THEN net_revenue ELSE 0 END) AS first_30_days_revenue,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '59 days' THEN units_sold ELSE 0 END) AS first_60_days_sales,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '59 days' THEN net_revenue ELSE 0 END) AS first_60_days_revenue,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '89 days' THEN units_sold ELSE 0 END) AS first_90_days_sales,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '89 days' THEN net_revenue ELSE 0 END) AS first_90_days_revenue
FROM public.daily_product_snapshots ds
JOIN HistoricalDates hd USING(pid)
WHERE date_first_sold IS NOT NULL
@@ -246,45 +252,314 @@ BEGIN
(sa.sales_30d / NULLIF(ci.current_stock + sa.sales_30d, 0)) * 100 AS sell_through_30d,
-- Forecasting intermediate values
(sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) AS sales_velocity_daily,
-- CRITICAL FIX: Use safer velocity calculation to prevent extreme values
-- Original problematic calculation: (sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0))
-- Use available days (not stockout days) as denominator with a minimum safety value
(sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d, -- Standard calculation
CASE
WHEN sa.sales_30d > 0 THEN 14.0 -- If we have sales, ensure at least 14 days denominator
ELSE 30.0 -- If no sales, use full period
END
),
0
)
) AS sales_velocity_daily,
s.effective_lead_time AS config_lead_time,
s.effective_days_of_stock AS config_days_of_stock,
s.effective_safety_stock AS config_safety_stock,
(s.effective_lead_time + s.effective_days_of_stock) AS planning_period_days,
(sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time AS lead_time_forecast_units,
(sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock AS days_of_stock_forecast_units,
((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock) AS planning_period_forecast_units,
(ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time)) AS lead_time_closing_stock,
((ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time))) - ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock) AS days_of_stock_closing_stock,
(((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) AS replenishment_needed_raw,
-- Apply the same fix to all derived calculations
(sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time AS lead_time_forecast_units,
(sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock AS days_of_stock_forecast_units,
(sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * (s.effective_lead_time + s.effective_days_of_stock) AS planning_period_forecast_units,
(ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time)) AS lead_time_closing_stock,
((ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time))) - ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock) AS days_of_stock_closing_stock,
(((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) AS replenishment_needed_raw,
-- Final Forecasting / Replenishment Metrics (apply CEILING/GREATEST/etc.)
-- Note: These calculations are nested for clarity, can be simplified in prod
CEILING(GREATEST(0, ((((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS replenishment_units,
(CEILING(GREATEST(0, ((((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_effective_cost AS replenishment_cost,
(CEILING(GREATEST(0, ((((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_price AS replenishment_retail,
(CEILING(GREATEST(0, ((((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * (ci.current_price - ci.current_effective_cost) AS replenishment_profit,
CEILING(GREATEST(0, ((((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS replenishment_units,
(CEILING(GREATEST(0, ((((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_effective_cost AS replenishment_cost,
(CEILING(GREATEST(0, ((((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_price AS replenishment_retail,
(CEILING(GREATEST(0, ((((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * (ci.current_price - ci.current_effective_cost) AS replenishment_profit,
-- Placeholder for To Order (Apply MOQ/UOM logic here if needed, otherwise equals replenishment)
CEILING(GREATEST(0, ((((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS to_order_units,
CEILING(GREATEST(0, ((((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS to_order_units,
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time))) AS forecast_lost_sales_units,
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time))) * ci.current_price AS forecast_lost_revenue,
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time))) AS forecast_lost_sales_units,
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time))) * ci.current_price AS forecast_lost_revenue,
ci.current_stock / NULLIF((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)), 0) AS stock_cover_in_days,
COALESCE(ooi.on_order_qty, 0) / NULLIF((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)), 0) AS po_cover_in_days,
(ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)), 0) AS sells_out_in_days,
ci.current_stock / NULLIF((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0) AS stock_cover_in_days,
COALESCE(ooi.on_order_qty, 0) / NULLIF((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0) AS po_cover_in_days,
(ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0) AS sells_out_in_days,
-- Replenish Date: Date when stock is projected to hit safety stock, minus lead time
CASE
WHEN (sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) > 0
THEN _current_date + FLOOR(GREATEST(0, ci.current_stock - s.effective_safety_stock) / (sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)))::int - s.effective_lead_time
WHEN (sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) > 0
THEN _current_date + FLOOR(GREATEST(0, ci.current_stock - s.effective_safety_stock) / (sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
))::int - s.effective_lead_time
ELSE NULL
END AS replenish_date,
GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)))::int AS overstocked_units,
(GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)))) * ci.current_effective_cost AS overstocked_cost,
(GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)))) * ci.current_price AS overstocked_retail,
GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)))::int AS overstocked_units,
(GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)))) * ci.current_effective_cost AS overstocked_cost,
(GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)))) * ci.current_price AS overstocked_retail,
-- Old Stock Flag
(ci.created_at::date < _current_date - INTERVAL '60 day') AND

View File

@@ -0,0 +1,281 @@
const express = require('express');
const router = express.Router();
const { parseValue } = require('../utils/apiHelpers'); // Adjust path if needed
// --- Configuration & Helpers ---
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 200;
// Maps query keys to DB columns in brand_metrics
const COLUMN_MAP = {
brandName: { dbCol: 'bm.brand_name', type: 'string' },
productCount: { dbCol: 'bm.product_count', type: 'number' },
activeProductCount: { dbCol: 'bm.active_product_count', type: 'number' },
replenishableProductCount: { dbCol: 'bm.replenishable_product_count', type: 'number' },
currentStockUnits: { dbCol: 'bm.current_stock_units', type: 'number' },
currentStockCost: { dbCol: 'bm.current_stock_cost', type: 'number' },
currentStockRetail: { dbCol: 'bm.current_stock_retail', type: 'number' },
sales7d: { dbCol: 'bm.sales_7d', type: 'number' },
revenue7d: { dbCol: 'bm.revenue_7d', type: 'number' },
sales30d: { dbCol: 'bm.sales_30d', type: 'number' },
revenue30d: { dbCol: 'bm.revenue_30d', type: 'number' },
profit30d: { dbCol: 'bm.profit_30d', type: 'number' },
cogs30d: { dbCol: 'bm.cogs_30d', type: 'number' },
sales365d: { dbCol: 'bm.sales_365d', type: 'number' },
revenue365d: { dbCol: 'bm.revenue_365d', type: 'number' },
lifetimeSales: { dbCol: 'bm.lifetime_sales', type: 'number' },
lifetimeRevenue: { dbCol: 'bm.lifetime_revenue', type: 'number' },
avgMargin30d: { dbCol: 'bm.avg_margin_30d', type: 'number' },
// Add aliases if needed
name: { dbCol: 'bm.brand_name', type: 'string' },
// Add status for filtering
status: { dbCol: 'brand_status', type: 'string' },
};
function getSafeColumnInfo(queryParamKey) {
return COLUMN_MAP[queryParamKey] || null;
}
// --- Route Handlers ---
// GET /brands-aggregate/filter-options (Just brands list for now)
router.get('/filter-options', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /brands-aggregate/filter-options');
try {
// Get brand names
const { rows: brandRows } = await pool.query(`
SELECT DISTINCT brand_name FROM public.brand_metrics ORDER BY brand_name
`);
// Get status values - calculate them since they're derived
const { rows: statusRows } = await pool.query(`
SELECT DISTINCT
CASE
WHEN active_product_count > 0 AND sales_30d > 0 THEN 'active'
WHEN active_product_count > 0 THEN 'inactive'
ELSE 'pending'
END as status
FROM public.brand_metrics
ORDER BY status
`);
res.json({
brands: brandRows.map(r => r.brand_name),
statuses: statusRows.map(r => r.status)
});
} catch(error) {
console.error('Error fetching brand filter options:', error);
res.status(500).json({ error: 'Failed to fetch filter options' });
}
});
// GET /brands-aggregate/stats (Overall brand stats)
router.get('/stats', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /brands-aggregate/stats');
try {
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(*) AS total_brands,
COUNT(CASE WHEN active_product_count > 0 THEN 1 END) AS active_brands,
SUM(active_product_count) AS total_active_products,
SUM(current_stock_cost) AS total_stock_value,
-- Weighted Average Margin
SUM(profit_30d) * 100.0 / NULLIF(SUM(revenue_30d), 0) AS overall_avg_margin_weighted
FROM public.brand_metrics bm
`);
res.json({
totalBrands: parseInt(stats?.total_brands || 0),
activeBrands: parseInt(stats?.active_brands || 0),
totalActiveProducts: parseInt(stats?.total_active_products || 0),
totalValue: parseFloat(stats?.total_stock_value || 0),
avgMargin: parseFloat(stats?.overall_avg_margin_weighted || 0),
});
} catch (error) {
console.error('Error fetching brand stats:', error);
res.status(500).json({ error: 'Failed to fetch brand stats.' });
}
});
// GET /brands-aggregate/ (List brands)
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /brands-aggregate received query:', req.query);
try {
// --- Pagination ---
let page = parseInt(req.query.page, 10) || 1;
let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT;
limit = Math.min(limit, MAX_PAGE_LIMIT);
const offset = (page - 1) * limit;
// --- Sorting ---
const sortQueryKey = req.query.sort || 'brandName'; // Default sort
const sortColumnInfo = getSafeColumnInfo(sortQueryKey);
const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'bm.brand_name';
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST');
const sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`;
// --- Filtering ---
const conditions = [];
const params = [];
let paramCounter = 1;
// Build conditions based on req.query, using COLUMN_MAP and parseValue
for (const key in req.query) {
if (['page', 'limit', 'sort', 'order'].includes(key)) continue;
let filterKey = key;
let operator = '='; // Default operator
const value = req.query[key];
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
if (operatorMatch) {
filterKey = operatorMatch[1];
operator = operatorMatch[2];
}
const columnInfo = getSafeColumnInfo(filterKey);
if (columnInfo) {
const dbColumn = columnInfo.dbCol;
const valueType = columnInfo.type;
try {
let conditionFragment = '';
let needsParam = true;
switch (operator.toLowerCase()) { // Normalize operator
case 'eq': operator = '='; break;
case 'ne': operator = '<>'; break;
case 'gt': operator = '>'; break;
case 'gte': operator = '>='; break;
case 'lt': operator = '<'; break;
case 'lte': operator = '<='; break;
case 'like': operator = 'LIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'ilike': operator = 'ILIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'between':
const [val1, val2] = String(value).split(',');
if (val1 !== undefined && val2 !== undefined) {
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
needsParam = false;
} else continue;
break;
case 'in':
const inValues = String(value).split(',');
if (inValues.length > 0) {
const placeholders = inValues.map(() => `$${paramCounter++}`).join(', ');
conditionFragment = `${dbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType)));
needsParam = false;
} else continue;
break;
default: operator = '='; break;
}
if (needsParam) {
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
params.push(parseValue(value, valueType));
} else if (!conditionFragment) { // For LIKE/ILIKE
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
}
if (conditionFragment) {
conditions.push(`(${conditionFragment})`);
}
} catch (parseError) {
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`);
if (needsParam) paramCounter--;
}
} else {
console.warn(`Invalid filter key ignored: ${key}`);
}
}
// --- Execute Queries ---
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Status calculation similar to vendors
const statusCase = `
CASE
WHEN active_product_count > 0 AND sales_30d > 0 THEN 'active'
WHEN active_product_count > 0 THEN 'inactive'
ELSE 'pending'
END as brand_status
`;
const baseSql = `
FROM (
SELECT
bm.*,
${statusCase}
FROM public.brand_metrics bm
) bm
${whereClause}
`;
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
const dataSql = `
WITH brand_data AS (
SELECT
bm.*,
${statusCase}
FROM public.brand_metrics bm
)
SELECT bm.*
FROM brand_data bm
${whereClause}
${sortClause}
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`;
const dataParams = [...params, limit, offset];
console.log("Count SQL:", countSql, params);
console.log("Data SQL:", dataSql, dataParams);
const [countResult, dataResult] = await Promise.all([
pool.query(countSql, params),
pool.query(dataSql, dataParams)
]);
const total = parseInt(countResult.rows[0].total, 10);
const brands = dataResult.rows.map(row => {
// Create a new object with both snake_case and camelCase keys
const transformedRow = { ...row }; // Start with original data
for (const key in row) {
// Skip null/undefined values
if (row[key] === null || row[key] === undefined) {
continue; // Original already has the null value
}
// Transform keys to match frontend expectations (add camelCase versions)
// First handle cases like sales_7d -> sales7d
let camelKey = key.replace(/_(\d+[a-z])/g, '$1');
// Then handle regular snake_case -> camelCase
camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
if (camelKey !== key) { // Only add if different from original
transformedRow[camelKey] = row[key];
}
}
return transformedRow;
});
// --- Respond ---
res.json({
brands,
pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit },
});
} catch (error) {
console.error('Error fetching brand metrics list:', error);
res.status(500).json({ error: 'Failed to fetch brand metrics.' });
}
});
// GET /brands-aggregate/:name (Get single brand metric)
// Implement if needed, remember to URL-decode the name parameter
module.exports = router;

View File

@@ -1,100 +0,0 @@
const express = require('express');
const router = express.Router();
// Get all categories
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
try {
// Get all categories with metrics and hierarchy info
const { rows: categories } = await pool.query(`
SELECT
c.cat_id,
c.name,
c.type,
c.parent_id,
c.description,
c.status,
p.name as parent_name,
p.type as parent_type,
COALESCE(cm.product_count, 0) as product_count,
COALESCE(cm.active_products, 0) as active_products,
ROUND(COALESCE(cm.total_value, 0)::numeric, 3) as total_value,
COALESCE(cm.avg_margin, 0) as avg_margin,
COALESCE(cm.turnover_rate, 0) as turnover_rate,
COALESCE(cm.growth_rate, 0) as growth_rate
FROM categories c
LEFT JOIN categories p ON c.parent_id = p.cat_id
LEFT JOIN category_metrics cm ON c.cat_id = cm.category_id
ORDER BY
CASE
WHEN c.type = 10 THEN 1 -- sections first
WHEN c.type = 11 THEN 2 -- categories second
WHEN c.type = 12 THEN 3 -- subcategories third
WHEN c.type = 13 THEN 4 -- subsubcategories fourth
WHEN c.type = 20 THEN 5 -- themes fifth
WHEN c.type = 21 THEN 6 -- subthemes last
ELSE 7
END,
c.name ASC
`);
// Get overall stats
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(DISTINCT c.cat_id) as totalCategories,
COUNT(DISTINCT CASE WHEN c.status = 'active' THEN c.cat_id END) as activeCategories,
ROUND(COALESCE(SUM(cm.total_value), 0)::numeric, 3) as totalValue,
COALESCE(ROUND(AVG(NULLIF(cm.avg_margin, 0))::numeric, 1), 0) as avgMargin,
COALESCE(ROUND(AVG(NULLIF(cm.growth_rate, 0))::numeric, 1), 0) as avgGrowth
FROM categories c
LEFT JOIN category_metrics cm ON c.cat_id = cm.category_id
`);
// Get type counts for filtering
const { rows: typeCounts } = await pool.query(`
SELECT
type,
COUNT(*)::integer as count
FROM categories
GROUP BY type
ORDER BY type
`);
res.json({
categories: categories.map(cat => ({
cat_id: cat.cat_id,
name: cat.name,
type: cat.type,
parent_id: cat.parent_id,
parent_name: cat.parent_name,
parent_type: cat.parent_type,
description: cat.description,
status: cat.status,
metrics: {
product_count: parseInt(cat.product_count),
active_products: parseInt(cat.active_products),
total_value: parseFloat(cat.total_value),
avg_margin: parseFloat(cat.avg_margin),
turnover_rate: parseFloat(cat.turnover_rate),
growth_rate: parseFloat(cat.growth_rate)
}
})),
typeCounts: typeCounts.map(tc => ({
type: tc.type,
count: tc.count // Already cast to integer in the query
})),
stats: {
totalCategories: parseInt(stats.totalcategories),
activeCategories: parseInt(stats.activecategories),
totalValue: parseFloat(stats.totalvalue),
avgMargin: parseFloat(stats.avgmargin),
avgGrowth: parseFloat(stats.avggrowth)
}
});
} catch (error) {
console.error('Error fetching categories:', error);
res.status(500).json({ error: 'Failed to fetch categories' });
}
});
module.exports = router;

View File

@@ -0,0 +1,330 @@
const express = require('express');
const router = express.Router();
const { parseValue } = require('../utils/apiHelpers'); // Adjust path if needed
// --- Configuration & Helpers ---
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 5000; // Increase this to allow retrieving all categories in one request
// Maps query keys to DB columns in category_metrics and categories tables
const COLUMN_MAP = {
categoryId: { dbCol: 'cm.category_id', type: 'integer' },
categoryName: { dbCol: 'cm.category_name', type: 'string' }, // From aggregate table
categoryType: { dbCol: 'cm.category_type', type: 'integer' }, // From aggregate table
parentId: { dbCol: 'cm.parent_id', type: 'integer' }, // From aggregate table
parentName: { dbCol: 'p.name', type: 'string' }, // Requires JOIN to categories
productCount: { dbCol: 'cm.product_count', type: 'number' },
activeProductCount: { dbCol: 'cm.active_product_count', type: 'number' },
replenishableProductCount: { dbCol: 'cm.replenishable_product_count', type: 'number' },
currentStockUnits: { dbCol: 'cm.current_stock_units', type: 'number' },
currentStockCost: { dbCol: 'cm.current_stock_cost', type: 'number' },
currentStockRetail: { dbCol: 'cm.current_stock_retail', type: 'number' },
sales7d: { dbCol: 'cm.sales_7d', type: 'number' },
revenue7d: { dbCol: 'cm.revenue_7d', type: 'number' },
sales30d: { dbCol: 'cm.sales_30d', type: 'number' },
revenue30d: { dbCol: 'cm.revenue_30d', type: 'number' },
profit30d: { dbCol: 'cm.profit_30d', type: 'number' },
cogs30d: { dbCol: 'cm.cogs_30d', type: 'number' },
sales365d: { dbCol: 'cm.sales_365d', type: 'number' },
revenue365d: { dbCol: 'cm.revenue_365d', type: 'number' },
lifetimeSales: { dbCol: 'cm.lifetime_sales', type: 'number' },
lifetimeRevenue: { dbCol: 'cm.lifetime_revenue', type: 'number' },
avgMargin30d: { dbCol: 'cm.avg_margin_30d', type: 'number' },
stockTurn30d: { dbCol: 'cm.stock_turn_30d', type: 'number' },
// Add status from the categories table for filtering
status: { dbCol: 'c.status', type: 'string' },
};
function getSafeColumnInfo(queryParamKey) {
return COLUMN_MAP[queryParamKey] || null;
}
// Type Labels (Consider moving to a shared config or fetching from DB)
const TYPE_LABELS = {
10: 'Section', 11: 'Category', 12: 'Subcategory', 13: 'Sub-subcategory',
1: 'Company', 2: 'Line', 3: 'Subline', 40: 'Artist', // From old schema comments
20: 'Theme', 21: 'Subtheme' // Additional types from categories.js
};
// --- Route Handlers ---
// GET /categories-aggregate/filter-options
router.get('/filter-options', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /categories-aggregate/filter-options');
try {
// Fetch distinct types directly from the aggregate table if reliable
// Or join with categories table if source of truth is needed
const { rows: typeRows } = await pool.query(`
SELECT DISTINCT category_type
FROM public.category_metrics
ORDER BY category_type
`);
const typeOptions = typeRows.map(r => ({
value: r.category_type,
label: TYPE_LABELS[r.category_type] || `Type ${r.category_type}` // Add labels
}));
// Add status options for filtering (from categories.js)
const { rows: statusRows } = await pool.query(`
SELECT DISTINCT status FROM public.categories ORDER BY status
`);
// Get type counts (from categories.js)
const { rows: typeCounts } = await pool.query(`
SELECT
type,
COUNT(*)::integer as count
FROM categories
GROUP BY type
ORDER BY type
`);
res.json({
types: typeOptions,
statuses: statusRows.map(r => r.status),
typeCounts: typeCounts.map(tc => ({
type: tc.type,
count: tc.count
}))
});
} catch (error) {
console.error('Error fetching category filter options:', error);
res.status(500).json({ error: 'Failed to fetch filter options' });
}
});
// GET /categories-aggregate/stats
router.get('/stats', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /categories-aggregate/stats');
try {
// Calculate stats directly from the aggregate table
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(*) AS total_categories,
-- Count active based on the source categories table status
COUNT(CASE WHEN c.status = 'active' THEN cm.category_id END) AS active_categories,
SUM(cm.active_product_count) AS total_active_products, -- Sum from aggregates
SUM(cm.current_stock_cost) AS total_stock_value, -- Sum from aggregates
-- Weighted Average Margin (Revenue as weight)
SUM(cm.profit_30d) * 100.0 / NULLIF(SUM(cm.revenue_30d), 0) AS overall_avg_margin_weighted,
-- Simple Average Margin (less accurate if categories vary greatly in size)
AVG(NULLIF(cm.avg_margin_30d, 0)) AS overall_avg_margin_simple
-- Growth rate can be calculated from 30d vs previous 30d revenue if needed
FROM public.category_metrics cm
JOIN public.categories c ON cm.category_id = c.cat_id -- Join to check category status
`);
res.json({
totalCategories: parseInt(stats?.total_categories || 0),
activeCategories: parseInt(stats?.active_categories || 0), // Based on categories.status
totalActiveProducts: parseInt(stats?.total_active_products || 0),
totalValue: parseFloat(stats?.total_stock_value || 0),
// Choose which avg margin calculation to expose
avgMargin: parseFloat(stats?.overall_avg_margin_weighted || stats?.overall_avg_margin_simple || 0)
// Growth rate could be added if we implement the calculation
});
} catch (error) {
console.error('Error fetching category stats:', error);
res.status(500).json({ error: 'Failed to fetch category stats.' });
}
});
// GET /categories-aggregate/ (List categories)
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /categories-aggregate received query:', req.query);
try {
// --- Pagination ---
let page = parseInt(req.query.page, 10) || 1;
let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT;
limit = Math.min(limit, MAX_PAGE_LIMIT);
const offset = (page - 1) * limit;
// --- Sorting ---
const sortQueryKey = req.query.sort || 'categoryName';
const sortColumnInfo = getSafeColumnInfo(sortQueryKey);
// Hierarchical sorting logic from categories.js
const hierarchicalSortOrder = `
ORDER BY
CASE
WHEN cm.category_type = 10 THEN 1 -- sections first
WHEN cm.category_type = 11 THEN 2 -- categories second
WHEN cm.category_type = 12 THEN 3 -- subcategories third
WHEN cm.category_type = 13 THEN 4 -- subsubcategories fourth
WHEN cm.category_type = 20 THEN 5 -- themes fifth
WHEN cm.category_type = 21 THEN 6 -- subthemes last
ELSE 7
END,
cm.category_name ASC
`;
// Use hierarchical sort as default
let sortClause = hierarchicalSortOrder;
// Override with custom sort if specified
if (sortColumnInfo && sortQueryKey !== 'categoryName') {
const sortColumn = sortColumnInfo.dbCol;
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST');
sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`;
}
// --- Filtering ---
const conditions = [];
const params = [];
let paramCounter = 1;
// Add filters based on req.query using COLUMN_MAP and parseValue
for (const key in req.query) {
if (['page', 'limit', 'sort', 'order'].includes(key)) continue;
let filterKey = key;
let operator = '='; // Default operator
const value = req.query[key];
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
if (operatorMatch) {
filterKey = operatorMatch[1];
operator = operatorMatch[2];
}
// Special case for parentName requires join
const requiresJoin = filterKey === 'parentName';
const columnInfo = getSafeColumnInfo(filterKey);
if (columnInfo) {
const dbColumn = columnInfo.dbCol;
const valueType = columnInfo.type;
try {
let conditionFragment = '';
let needsParam = true;
switch (operator.toLowerCase()) {
case 'eq': operator = '='; break;
case 'ne': operator = '<>'; break;
case 'gt': operator = '>'; break;
case 'gte': operator = '>='; break;
case 'lt': operator = '<'; break;
case 'lte': operator = '<='; break;
case 'like': operator = 'LIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'ilike': operator = 'ILIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'between':
const [val1, val2] = String(value).split(',');
if (val1 !== undefined && val2 !== undefined) {
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
needsParam = false;
} else continue;
break;
case 'in':
const inValues = String(value).split(',');
if (inValues.length > 0) {
const placeholders = inValues.map(() => `$${paramCounter++}`).join(', ');
conditionFragment = `${dbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType)));
needsParam = false;
} else continue;
break;
default: operator = '='; break;
}
if (needsParam) {
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
params.push(parseValue(value, valueType));
} else if (!conditionFragment) { // For LIKE/ILIKE where needsParam is false
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; // paramCounter was already incremented in push
}
if (conditionFragment) {
conditions.push(`(${conditionFragment})`);
}
} catch (parseError) {
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`);
if (needsParam) paramCounter--; // Roll back counter if param push failed
}
} else {
console.warn(`Invalid filter key ignored: ${key}`);
}
}
// --- Execute Queries ---
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Need JOIN for parent_name if sorting/filtering by it, or always include for display
const sortColumn = sortColumnInfo?.dbCol;
// Always include the category and parent joins for status and parent_name
const joinSql = `
JOIN public.categories c ON cm.category_id = c.cat_id
LEFT JOIN public.categories p ON cm.parent_id = p.cat_id
`;
const baseSql = `
FROM public.category_metrics cm
${joinSql}
${whereClause}
`;
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
const dataSql = `
SELECT
cm.*,
c.status,
c.description,
p.name as parent_name,
p.type as parent_type
${baseSql}
${sortClause}
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`;
const dataParams = [...params, limit, offset];
console.log("Count SQL:", countSql, params);
console.log("Data SQL:", dataSql, dataParams);
const [countResult, dataResult] = await Promise.all([
pool.query(countSql, params),
pool.query(dataSql, dataParams)
]);
const total = parseInt(countResult.rows[0].total, 10);
const categories = dataResult.rows.map(row => {
// Create a new object with both snake_case and camelCase keys
const transformedRow = { ...row }; // Start with original data
for (const key in row) {
// Skip null/undefined values
if (row[key] === null || row[key] === undefined) {
continue; // Original already has the null value
}
// Transform keys to match frontend expectations (add camelCase versions)
// First handle cases like sales_7d -> sales7d
let camelKey = key.replace(/_(\d+[a-z])/g, '$1');
// Then handle regular snake_case -> camelCase
camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
if (camelKey !== key) { // Only add if different from original
transformedRow[camelKey] = row[key];
}
}
return transformedRow;
});
// --- Respond ---
res.json({
categories,
pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit },
});
} catch (error) {
console.error('Error fetching category metrics list:', error);
res.status(500).json({ error: 'Failed to fetch category metrics.' });
}
});
module.exports = router;

View File

@@ -1,108 +0,0 @@
const express = require('express');
const router = express.Router();
// Get vendors with pagination, filtering, and sorting
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
try {
// Get all vendors with metrics
const { rows: vendors } = await pool.query(`
SELECT DISTINCT
p.vendor as name,
COALESCE(vm.active_products, 0) as active_products,
COALESCE(vm.total_orders, 0) as total_orders,
COALESCE(vm.avg_lead_time_days, 0) as avg_lead_time_days,
COALESCE(vm.on_time_delivery_rate, 0) as on_time_delivery_rate,
COALESCE(vm.order_fill_rate, 0) as order_fill_rate,
CASE
WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75 THEN 'active'
WHEN COALESCE(vm.total_orders, 0) > 0 THEN 'inactive'
ELSE 'pending'
END as status
FROM products p
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
WHERE p.vendor IS NOT NULL AND p.vendor != ''
`);
// Get cost metrics for all vendors
const vendorNames = vendors.map(v => v.name);
const { rows: costMetrics } = await pool.query(`
SELECT
vendor,
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
FROM purchase_orders
WHERE status = 2
AND cost_price IS NOT NULL
AND ordered > 0
AND vendor = ANY($1)
GROUP BY vendor
`, [vendorNames]);
// Create a map of cost metrics by vendor
const costMetricsMap = costMetrics.reduce((acc, curr) => {
acc[curr.vendor] = {
avg_unit_cost: curr.avg_unit_cost,
total_spend: curr.total_spend
};
return acc;
}, {});
// Get overall stats
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(DISTINCT p.vendor) as totalVendors,
COUNT(DISTINCT CASE
WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75
THEN p.vendor
END) as activeVendors,
COALESCE(ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0))::numeric, 1), 0) as avgLeadTime,
COALESCE(ROUND(AVG(NULLIF(vm.order_fill_rate, 0))::numeric, 1), 0) as avgFillRate,
COALESCE(ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0))::numeric, 1), 0) as avgOnTimeDelivery
FROM products p
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
WHERE p.vendor IS NOT NULL AND p.vendor != ''
`);
// Get overall cost metrics
const { rows: [overallCostMetrics] } = await pool.query(`
SELECT
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
FROM purchase_orders
WHERE status = 2
AND cost_price IS NOT NULL
AND ordered > 0
AND vendor IS NOT NULL AND vendor != ''
`);
res.json({
vendors: vendors.map(vendor => ({
vendor_id: vendor.name,
name: vendor.name,
status: vendor.status,
avg_lead_time_days: parseFloat(vendor.avg_lead_time_days),
on_time_delivery_rate: parseFloat(vendor.on_time_delivery_rate),
order_fill_rate: parseFloat(vendor.order_fill_rate),
total_orders: parseInt(vendor.total_orders),
active_products: parseInt(vendor.active_products),
avg_unit_cost: parseFloat(costMetricsMap[vendor.name]?.avg_unit_cost || 0),
total_spend: parseFloat(costMetricsMap[vendor.name]?.total_spend || 0)
})),
stats: {
totalVendors: parseInt(stats.totalvendors),
activeVendors: parseInt(stats.activevendors),
avgLeadTime: parseFloat(stats.avgleadtime),
avgFillRate: parseFloat(stats.avgfillrate),
avgOnTimeDelivery: parseFloat(stats.avgontimedelivery),
avgUnitCost: parseFloat(overallCostMetrics.avg_unit_cost),
totalSpend: parseFloat(overallCostMetrics.total_spend)
}
});
} catch (error) {
console.error('Error fetching vendors:', error);
res.status(500).json({ error: 'Failed to fetch vendors' });
}
});
module.exports = router;

View File

@@ -0,0 +1,320 @@
const express = require('express');
const router = express.Router();
const { parseValue } = require('../utils/apiHelpers'); // Adjust path if needed
// --- Configuration & Helpers ---
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 200;
// Maps query keys to DB columns in vendor_metrics
const COLUMN_MAP = {
vendorName: { dbCol: 'vm.vendor_name', type: 'string' },
productCount: { dbCol: 'vm.product_count', type: 'number' },
activeProductCount: { dbCol: 'vm.active_product_count', type: 'number' },
replenishableProductCount: { dbCol: 'vm.replenishable_product_count', type: 'number' },
currentStockUnits: { dbCol: 'vm.current_stock_units', type: 'number' },
currentStockCost: { dbCol: 'vm.current_stock_cost', type: 'number' },
currentStockRetail: { dbCol: 'vm.current_stock_retail', type: 'number' },
onOrderUnits: { dbCol: 'vm.on_order_units', type: 'number' },
onOrderCost: { dbCol: 'vm.on_order_cost', type: 'number' },
poCount365d: { dbCol: 'vm.po_count_365d', type: 'number' },
avgLeadTimeDays: { dbCol: 'vm.avg_lead_time_days', type: 'number' },
sales7d: { dbCol: 'vm.sales_7d', type: 'number' },
revenue7d: { dbCol: 'vm.revenue_7d', type: 'number' },
sales30d: { dbCol: 'vm.sales_30d', type: 'number' },
revenue30d: { dbCol: 'vm.revenue_30d', type: 'number' },
profit30d: { dbCol: 'vm.profit_30d', type: 'number' },
cogs30d: { dbCol: 'vm.cogs_30d', type: 'number' },
sales365d: { dbCol: 'vm.sales_365d', type: 'number' },
revenue365d: { dbCol: 'vm.revenue_365d', type: 'number' },
lifetimeSales: { dbCol: 'vm.lifetime_sales', type: 'number' },
lifetimeRevenue: { dbCol: 'vm.lifetime_revenue', type: 'number' },
avgMargin30d: { dbCol: 'vm.avg_margin_30d', type: 'number' },
// Add aliases if needed for frontend compatibility
name: { dbCol: 'vm.vendor_name', type: 'string' },
leadTime: { dbCol: 'vm.avg_lead_time_days', type: 'number' },
// Add status for filtering
status: { dbCol: 'vendor_status', type: 'string' },
};
function getSafeColumnInfo(queryParamKey) {
return COLUMN_MAP[queryParamKey] || null;
}
// --- Route Handlers ---
// GET /vendors-aggregate/filter-options (Just vendors list for now)
router.get('/filter-options', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /vendors-aggregate/filter-options');
try {
// Get vendor names
const { rows: vendorRows } = await pool.query(`
SELECT DISTINCT vendor_name FROM public.vendor_metrics ORDER BY vendor_name
`);
// Get status values - calculate them since they're derived
const { rows: statusRows } = await pool.query(`
SELECT DISTINCT
CASE
WHEN po_count_365d > 0 AND sales_30d > 0 THEN 'active'
WHEN po_count_365d > 0 THEN 'inactive'
ELSE 'pending'
END as status
FROM public.vendor_metrics
ORDER BY status
`);
res.json({
vendors: vendorRows.map(r => r.vendor_name),
statuses: statusRows.map(r => r.status)
});
} catch(error) {
console.error('Error fetching vendor filter options:', error);
res.status(500).json({ error: 'Failed to fetch filter options' });
}
});
// GET /vendors-aggregate/stats (Overall vendor stats)
router.get('/stats', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /vendors-aggregate/stats');
try {
// Get basic vendor stats from aggregate table
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(*) AS total_vendors,
SUM(active_product_count) AS total_active_products,
SUM(current_stock_cost) AS total_stock_value,
SUM(on_order_cost) AS total_on_order_value,
AVG(NULLIF(avg_lead_time_days, 0)) AS overall_avg_lead_time
FROM public.vendor_metrics vm
`);
// Count active vendors based on criteria (from old vendors.js)
const { rows: [activeStats] } = await pool.query(`
SELECT
COUNT(DISTINCT CASE
WHEN po_count_365d > 0
THEN vendor_name
END) as active_vendors
FROM public.vendor_metrics
`);
// Get overall cost metrics from purchase orders
const { rows: [overallCostMetrics] } = await pool.query(`
SELECT
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
FROM purchase_orders
WHERE cost_price IS NOT NULL
AND ordered > 0
AND vendor IS NOT NULL AND vendor != ''
`);
res.json({
totalVendors: parseInt(stats?.total_vendors || 0),
activeVendors: parseInt(activeStats?.active_vendors || 0),
totalActiveProducts: parseInt(stats?.total_active_products || 0),
totalValue: parseFloat(stats?.total_stock_value || 0),
totalOnOrderValue: parseFloat(stats?.total_on_order_value || 0),
avgLeadTime: parseFloat(stats?.overall_avg_lead_time || 0),
avgUnitCost: parseFloat(overallCostMetrics?.avg_unit_cost || 0),
totalSpend: parseFloat(overallCostMetrics?.total_spend || 0)
});
} catch (error) {
console.error('Error fetching vendor stats:', error);
res.status(500).json({ error: 'Failed to fetch vendor stats.' });
}
});
// GET /vendors-aggregate/ (List vendors)
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /vendors-aggregate received query:', req.query);
try {
// --- Pagination ---
let page = parseInt(req.query.page, 10) || 1;
let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT;
limit = Math.min(limit, MAX_PAGE_LIMIT);
const offset = (page - 1) * limit;
// --- Sorting ---
const sortQueryKey = req.query.sort || 'vendorName'; // Default sort
const sortColumnInfo = getSafeColumnInfo(sortQueryKey);
const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'vm.vendor_name';
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST');
const sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`;
// --- Filtering ---
const conditions = [];
const params = [];
let paramCounter = 1;
// Build conditions based on req.query, using COLUMN_MAP and parseValue
for (const key in req.query) {
if (['page', 'limit', 'sort', 'order'].includes(key)) continue;
let filterKey = key;
let operator = '='; // Default operator
const value = req.query[key];
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
if (operatorMatch) {
filterKey = operatorMatch[1];
operator = operatorMatch[2];
}
const columnInfo = getSafeColumnInfo(filterKey);
if (columnInfo) {
const dbColumn = columnInfo.dbCol;
const valueType = columnInfo.type;
try {
let conditionFragment = '';
let needsParam = true;
switch (operator.toLowerCase()) { // Normalize operator
case 'eq': operator = '='; break;
case 'ne': operator = '<>'; break;
case 'gt': operator = '>'; break;
case 'gte': operator = '>='; break;
case 'lt': operator = '<'; break;
case 'lte': operator = '<='; break;
case 'like': operator = 'LIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'ilike': operator = 'ILIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'between':
const [val1, val2] = String(value).split(',');
if (val1 !== undefined && val2 !== undefined) {
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
needsParam = false;
} else continue;
break;
case 'in':
const inValues = String(value).split(',');
if (inValues.length > 0) {
const placeholders = inValues.map(() => `$${paramCounter++}`).join(', ');
conditionFragment = `${dbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType)));
needsParam = false;
} else continue;
break;
default: operator = '='; break;
}
if (needsParam) {
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
params.push(parseValue(value, valueType));
} else if (!conditionFragment) { // For LIKE/ILIKE
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
}
if (conditionFragment) {
conditions.push(`(${conditionFragment})`);
}
} catch (parseError) {
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`);
if (needsParam) paramCounter--;
}
} else {
console.warn(`Invalid filter key ignored: ${key}`);
}
}
// --- Execute Queries ---
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Status calculation from vendors.js
const statusCase = `
CASE
WHEN po_count_365d > 0 AND sales_30d > 0 THEN 'active'
WHEN po_count_365d > 0 THEN 'inactive'
ELSE 'pending'
END as vendor_status
`;
const baseSql = `
FROM (
SELECT
vm.*,
${statusCase}
FROM public.vendor_metrics vm
) vm
${whereClause}
`;
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
const dataSql = `
WITH vendor_data AS (
SELECT
vm.*,
${statusCase}
FROM public.vendor_metrics vm
)
SELECT
vm.*,
COALESCE(po.avg_unit_cost, 0) as avg_unit_cost,
COALESCE(po.total_spend, 0) as total_spend
FROM vendor_data vm
LEFT JOIN (
SELECT
vendor,
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
FROM purchase_orders
WHERE cost_price IS NOT NULL AND ordered > 0
GROUP BY vendor
) po ON vm.vendor_name = po.vendor
${whereClause}
${sortClause}
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`;
const dataParams = [...params, limit, offset];
console.log("Count SQL:", countSql, params);
console.log("Data SQL:", dataSql, dataParams);
const [countResult, dataResult] = await Promise.all([
pool.query(countSql, params),
pool.query(dataSql, dataParams)
]);
const total = parseInt(countResult.rows[0].total, 10);
const vendors = dataResult.rows.map(row => {
// Create a new object with both snake_case and camelCase keys
const transformedRow = { ...row }; // Start with original data
for (const key in row) {
// Skip null/undefined values
if (row[key] === null || row[key] === undefined) {
continue; // Original already has the null value
}
// Transform keys to match frontend expectations (add camelCase versions)
// First handle cases like sales_7d -> sales7d
let camelKey = key.replace(/_(\d+[a-z])/g, '$1');
// Then handle regular snake_case -> camelCase
camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
if (camelKey !== key) { // Only add if different from original
transformedRow[camelKey] = row[key];
}
}
return transformedRow;
});
// --- Respond ---
res.json({
vendors,
pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit },
});
} catch (error) {
console.error('Error fetching vendor metrics list:', error);
res.status(500).json({ error: 'Failed to fetch vendor metrics.' });
}
});
// GET /vendors-aggregate/:name (Get single vendor metric)
// Implement if needed, remember to URL-decode the name parameter
module.exports = router;

View File

@@ -13,13 +13,14 @@ const analyticsRouter = require('./routes/analytics');
const purchaseOrdersRouter = require('./routes/purchase-orders');
const configRouter = require('./routes/config');
const metricsRouter = require('./routes/metrics');
const vendorsRouter = require('./routes/vendors');
const categoriesRouter = require('./routes/categories');
const importRouter = require('./routes/import');
const aiValidationRouter = require('./routes/ai-validation');
const templatesRouter = require('./routes/templates');
const aiPromptsRouter = require('./routes/ai-prompts');
const reusableImagesRouter = require('./routes/reusable-images');
const categoriesAggregateRouter = require('./routes/categoriesAggregate');
const vendorsAggregateRouter = require('./routes/vendorsAggregate');
const brandsAggregateRouter = require('./routes/brandsAggregate');
// Get the absolute path to the .env file
const envPath = '/var/www/html/inventory/.env';
@@ -100,8 +101,13 @@ async function startServer() {
app.use('/api/purchase-orders', purchaseOrdersRouter);
app.use('/api/config', configRouter);
app.use('/api/metrics', metricsRouter);
app.use('/api/vendors', vendorsRouter);
app.use('/api/categories', categoriesRouter);
// Use only the aggregate routes for vendors and categories
app.use('/api/vendors', vendorsAggregateRouter);
app.use('/api/categories', categoriesAggregateRouter);
// Keep the aggregate-specific endpoints for backward compatibility
app.use('/api/categories-aggregate', categoriesAggregateRouter);
app.use('/api/vendors-aggregate', vendorsAggregateRouter);
app.use('/api/brands-aggregate', brandsAggregateRouter);
app.use('/api/import', importRouter);
app.use('/api/ai-validation', aiValidationRouter);
app.use('/api/templates', templatesRouter);

View File

@@ -0,0 +1,35 @@
/**
* Parses a query parameter value based on its expected type.
* Throws error for invalid formats. Adjust date handling as needed.
*/
function parseValue(value, type) {
if (value === null || value === undefined || value === '') return null;
switch (type) {
case 'number':
const num = parseFloat(value);
if (isNaN(num)) throw new Error(`Invalid number format: "${value}"`);
return num;
case 'integer': // Specific type for integer IDs etc.
const int = parseInt(value, 10);
if (isNaN(int)) throw new Error(`Invalid integer format: "${value}"`);
return int;
case 'boolean':
if (String(value).toLowerCase() === 'true') return true;
if (String(value).toLowerCase() === 'false') return false;
throw new Error(`Invalid boolean format: "${value}"`);
case 'date':
// Basic ISO date format validation (YYYY-MM-DD)
if (!String(value).match(/^\d{4}-\d{2}-\d{2}$/)) {
console.warn(`Potentially invalid date format passed: "${value}"`);
// Optionally throw an error or return null depending on strictness
// throw new Error(`Invalid date format (YYYY-MM-DD expected): "${value}"`);
}
return String(value); // Send as string, let DB handle casting/comparison
case 'string':
default:
return String(value);
}
}
module.exports = { parseValue };

View File

@@ -61,6 +61,7 @@
"react-chartjs-2": "^5.3.0",
"react-data-grid": "^7.0.0-beta.13",
"react-day-picker": "^8.10.1",
"react-debounce-input": "^3.3.0",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-hook-form": "^7.54.2",
@@ -6043,6 +6044,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -6919,6 +6926,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-debounce-input": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/react-debounce-input/-/react-debounce-input-3.3.0.tgz",
"integrity": "sha512-VEqkvs8JvY/IIZvh71Z0TC+mdbxERvYF33RcebnodlsUZ8RSgyKe2VWaHXv4+/8aoOgXLxWrdsYs2hDhcwbUgA==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": "^15.3.0 || 16 || 17 || 18"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",

View File

@@ -63,6 +63,7 @@
"react-chartjs-2": "^5.3.0",
"react-data-grid": "^7.0.0-beta.13",
"react-day-picker": "^8.10.1",
"react-debounce-input": "^3.3.0",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-hook-form": "^7.54.2",

View File

@@ -18,7 +18,7 @@ import { Import } from '@/pages/Import';
import { AuthProvider } from './contexts/AuthContext';
import { Protected } from './components/auth/Protected';
import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage';
import { Brands } from '@/pages/Brands';
const queryClient = new QueryClient();
function App() {
@@ -108,6 +108,11 @@ function App() {
<Vendors />
</Protected>
} />
<Route path="/brands" element={
<Protected page="brands">
<Brands />
</Protected>
} />
<Route path="/purchase-orders" element={
<Protected page="purchase_orders">
<PurchaseOrders />

View File

@@ -0,0 +1,7 @@
const config = {
// API base URL - update based on your actual API endpoint
apiUrl: '/api',
// Add other config values as needed
};
export default config;

View File

@@ -5,9 +5,10 @@ import {
Settings,
ClipboardList,
LogOut,
Users,
Tags,
FileSpreadsheet,
ShoppingBag,
Truck,
} from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react";
import {
@@ -57,9 +58,15 @@ const items = [
url: "/categories",
permission: "access:categories"
},
{
title: "Brands",
icon: ShoppingBag,
url: "/brands",
permission: "access:brands"
},
{
title: "Vendors",
icon: Users,
icon: Truck,
url: "/vendors",
permission: "access:vendors"
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
import * as React from "react";
import { SortAsc, SortDesc } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
@@ -14,10 +13,11 @@ import {
DndContext,
DragEndEvent,
DragStartEvent,
MouseSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
@@ -26,36 +26,38 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Product } from "@/types/products";
export type ColumnKey = keyof Product | 'image';
import { ProductMetric, ProductMetricColumnKey } from "@/types/products";
import { Skeleton } from "@/components/ui/skeleton";
import { getStatusBadge } from "@/utils/productUtils";
// Column definition
interface ColumnDef {
key: ColumnKey;
key: ProductMetricColumnKey;
label: string;
group: string;
format?: (value: any) => string | number;
width?: string;
noLabel?: boolean;
width?: string;
format?: (value: any, product?: ProductMetric) => React.ReactNode;
}
interface ProductTableProps {
products: Product[];
onSort: (column: ColumnKey) => void;
sortColumn: ColumnKey;
products: ProductMetric[];
onSort: (column: ProductMetricColumnKey) => void;
sortColumn: ProductMetricColumnKey;
sortDirection: 'asc' | 'desc';
visibleColumns: Set<ColumnKey>;
visibleColumns: Set<ProductMetricColumnKey>;
columnDefs: ColumnDef[];
columnOrder: ColumnKey[];
onColumnOrderChange?: (columns: ColumnKey[]) => void;
onRowClick?: (product: Product) => void;
columnOrder: ProductMetricColumnKey[];
onColumnOrderChange?: (columns: ProductMetricColumnKey[]) => void;
onRowClick?: (product: ProductMetric) => void;
isLoading?: boolean;
}
interface SortableHeaderProps {
column: ColumnKey;
column: ProductMetricColumnKey;
columnDef?: ColumnDef;
onSort: (column: ColumnKey) => void;
sortColumn: ColumnKey;
onSort: (column: ProductMetricColumnKey) => void;
sortColumn: ProductMetricColumnKey;
sortDirection: 'asc' | 'desc';
}
@@ -73,10 +75,22 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : 1,
position: 'relative' as const,
touchAction: 'none' as const,
};
// Skip rendering content for 'noLabel' columns (like image)
if (columnDef?.noLabel) {
return <TableHead ref={setNodeRef} style={style} />;
return (
<TableHead
ref={setNodeRef}
style={{...style, width: columnDef?.width || 'auto', padding: '0.5rem' }}
className={cn(columnDef?.width, "select-none")}
{...attributes}
{...listeners}
/>
);
}
return (
@@ -84,7 +98,7 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
ref={setNodeRef}
style={style}
className={cn(
"cursor-pointer select-none",
"cursor-pointer select-none group",
columnDef?.width,
sortColumn === column && "bg-accent/50"
)}
@@ -114,196 +128,103 @@ export function ProductTable({
columnOrder = columnDefs.map(col => col.key),
onColumnOrderChange,
onRowClick,
isLoading = false,
}: ProductTableProps) {
const [, setActiveId] = React.useState<ColumnKey | null>(null);
const [activeId, setActiveId] = React.useState<ProductMetricColumnKey | null>(null);
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 200,
tolerance: 8,
},
activationConstraint: { delay: 250, tolerance: 5 },
})
);
// Get ordered visible columns
const orderedColumns = React.useMemo(() => {
// Filter columnOrder to only include visible columns for SortableContext
const orderedVisibleColumns = React.useMemo(() => {
return columnOrder.filter(col => visibleColumns.has(col));
}, [columnOrder, visibleColumns]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as ColumnKey);
setActiveId(event.active.id as ProductMetricColumnKey);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (over && active.id !== over.id) {
const oldIndex = orderedColumns.indexOf(active.id as ColumnKey);
const newIndex = orderedColumns.indexOf(over.id as ColumnKey);
if (over && active.id !== over.id && onColumnOrderChange) {
const oldIndex = orderedVisibleColumns.indexOf(active.id as ProductMetricColumnKey);
const newIndex = orderedVisibleColumns.indexOf(over.id as ProductMetricColumnKey);
const newOrder = arrayMove(orderedColumns, oldIndex, newIndex);
onColumnOrderChange?.(newOrder);
if (oldIndex !== -1 && newIndex !== -1) {
const newVisibleOrder = arrayMove(orderedVisibleColumns, oldIndex, newIndex);
onColumnOrderChange(newVisibleOrder);
}
}
};
const formatColumnValue = (product: ProductMetric, columnKey: ProductMetricColumnKey) => {
const columnDef = columnDefs.find(def => def.key === columnKey);
const value = product[columnKey as keyof ProductMetric];
const getStockStatus = (status: string | undefined) => {
if (!status) return null;
const normalizedStatus = status.toLowerCase().replace(/-/g, ' ');
switch (normalizedStatus) {
case 'critical':
return <Badge variant="destructive">Critical</Badge>;
case 'reorder':
return <Badge variant="secondary">Reorder</Badge>;
case 'healthy':
return <Badge variant="default">Healthy</Badge>;
case 'overstocked':
return <Badge variant="secondary">Overstocked</Badge>;
case 'new':
return <Badge variant="default">New</Badge>;
case 'out of stock':
return <Badge variant="destructive">Out of Stock</Badge>;
case 'at risk':
return <Badge variant="secondary">At Risk</Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
if (columnKey === 'status') {
return <div dangerouslySetInnerHTML={{ __html: getStatusBadge(product.status || 'Unknown') }} />;
}
};
const getABCClass = (abcClass: string | undefined) => {
if (!abcClass) return null;
switch (abcClass.toUpperCase()) {
case 'A':
return <Badge variant="default">A</Badge>;
case 'B':
return <Badge variant="secondary">B</Badge>;
case 'C':
return <Badge variant="outline">C</Badge>;
default:
return null;
if (columnDef?.format) {
return columnDef.format(value, product);
}
};
const getLeadTimeStatus = (status: string | undefined) => {
if (!status) return null;
switch (status.toLowerCase()) {
case 'critical':
return <Badge variant="destructive">Critical</Badge>;
case 'warning':
return <Badge variant="secondary">Warning</Badge>;
case 'good':
return <Badge variant="default">Good</Badge>;
default:
return null;
// Default formatting for common types if no formatter provided
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
};
const formatColumnValue = (product: Product, column: ColumnKey) => {
const columnDef = columnDefs.find(def => def.key === column);
let value: any = product[column as keyof Product];
switch (column) {
case 'image':
return product.image ? (
<div className="flex items-center justify-center w-[60px]">
<img
src={product.image}
alt={product.title}
className="h-12 w-12 object-contain bg-white"
/>
</div>
) : null;
case 'title':
return (
<div className="min-w-[200px]">
<div className="font-medium">{product.title}</div>
<div className="text-sm text-muted-foreground">{product.SKU}</div>
</div>
);
case 'categories':
return (
<div className="flex flex-wrap gap-1">
{Array.from(new Set(value as string[])).map((category) => (
<Badge key={`${product.pid}-${category}`} variant="outline">{category}</Badge>
)) || '-'}
</div>
);
case 'dimensions':
if (value) {
return `${value.length}×${value.width}×${value.height}`;
// Handle date strings consistently
if (value && typeof value === 'string' &&
(columnKey.toLowerCase().includes('date') || columnKey === 'replenishDate')) {
try {
return new Date(value).toLocaleDateString();
} catch (e) {
return String(value);
}
}
if (value === null || value === undefined || value === '') {
return '-';
case 'stock_status':
return getStockStatus(product.stock_status);
case 'abc_class':
return getABCClass(product.abc_class);
case 'lead_time_status':
return getLeadTimeStatus(product.lead_time_status);
case 'visible':
return value ? (
<Badge variant="secondary">Active</Badge>
) : (
<Badge variant="outline">Hidden</Badge>
);
case 'replenishable':
return value ? (
<Badge variant="secondary">Replenishable</Badge>
) : (
<Badge variant="outline">Non-Replenishable</Badge>
);
case 'rating':
if (value === undefined || value === null) return '-';
return (
<div className="flex items-center">
{value.toFixed(1)}
<span className="ml-1 text-yellow-500"></span>
</div>
);
default:
if (columnDef?.format && value !== undefined && value !== null) {
// For numeric formats (those using toFixed), ensure the value is a number
if (typeof value === 'string') {
const num = parseFloat(value);
if (!isNaN(num)) {
return columnDef.format(num);
}
}
// If the value is already a number, format it directly
if (typeof value === 'number') {
return columnDef.format(value);
}
// For other formats (e.g., date formatting), pass the value as is
return columnDef.format(value);
}
return value ?? '-';
}
// Fallback to string conversion
return String(value);
};
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveId(null)}
>
<div className="rounded-md border">
<Table>
<TableHeader>
<div className="rounded-md border overflow-x-auto relative">
{isLoading && (
<div className="absolute inset-0 bg-background/70 flex items-center justify-center z-20">
<Skeleton className="h-8 w-32" />
</div>
)}
<Table className={isLoading ? 'opacity-50' : ''}>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<SortableContext
items={orderedColumns}
items={orderedVisibleColumns}
strategy={horizontalListSortingStrategy}
>
{orderedColumns.map((column) => (
{orderedVisibleColumns.map((columnKey) => (
<SortableHeader
key={column}
column={column}
columnDef={columnDefs.find(def => def.key === column)}
key={columnKey}
column={columnKey}
columnDef={columnDefs.find(def => def.key === columnKey)}
onSort={onSort}
sortColumn={sortColumn}
sortDirection={sortDirection}
@@ -313,29 +234,55 @@ export function ProductTable({
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
{products.length === 0 && !isLoading ? (
<TableRow>
<TableCell
colSpan={orderedVisibleColumns.length}
className="text-center py-8 text-muted-foreground"
>
No products found matching your criteria.
</TableCell>
</TableRow>
) : (
products.map((product) => (
<TableRow
key={product.pid}
onClick={() => onRowClick?.(product)}
className="cursor-pointer"
className="cursor-pointer hover:bg-muted/50"
data-state={isLoading ? 'loading' : undefined}
>
{orderedColumns.map((column) => (
<TableCell key={`${product.pid}-${column}`}>
{formatColumnValue(product, column)}
</TableCell>
))}
</TableRow>
))}
{!products.length && (
<TableRow>
<TableCell
colSpan={orderedColumns.length}
className="text-center py-8 text-muted-foreground"
>
No products found
</TableCell>
</TableRow>
{orderedVisibleColumns.map((columnKey) => (
<TableCell key={`${product.pid}-${columnKey}`} className={cn(columnDefs.find(c=>c.key===columnKey)?.width)}>
{columnKey === 'imageUrl' ? (
<div className="flex items-center justify-center h-12 w-[60px]">
{product.imageUrl ? (
<img
src={product.imageUrl}
alt={product.title || 'Product image'}
className="max-h-full max-w-full object-contain bg-white p-0.5 border rounded"
loading="lazy"
/>
) : (
<div className="h-10 w-10 bg-muted rounded flex items-center justify-center text-muted-foreground text-xs">No Image</div>
)}
</div>
) : (
formatColumnValue(product, columnKey)
)}
</TableCell>
))}
</TableRow>
))
)}
{isLoading && products.length === 0 && Array.from({length: 10}).map((_, i) => (
<TableRow key={`skel-${i}`}>
{orderedVisibleColumns.map(key => (
<TableCell key={`skel-${i}-${key}`} className={cn(columnDefs.find(c=>c.key===key)?.width)}>
<Skeleton className={`h-5 ${key==='imageUrl' ? 'w-10 h-10' : 'w-full'}`} />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>

View File

@@ -0,0 +1,239 @@
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { Spinner } from "@/components/ui/spinner";
import { ProductFilterOptions, ProductMetric } from "@/types/products";
import { ProductTable } from "./ProductTable";
import { ProductFilters } from "./ProductFilters";
import { ProductDetail } from "./ProductDetail";
import config from "@/config";
import { getProductStatus } from "@/utils/productUtils";
export function Products() {
const [searchParams, setSearchParams] = useSearchParams();
const [selectedProductId, setSelectedProductId] = React.useState<number | null>(null);
// Get current filter values from URL params
const currentPage = Number(searchParams.get("page") || "1");
const pageSize = Number(searchParams.get("pageSize") || "25");
const sortBy = searchParams.get("sortBy") || "title";
const sortDirection = searchParams.get("sortDirection") || "asc";
const filterType = searchParams.get("filterType") || "";
const filterValue = searchParams.get("filterValue") || "";
const searchQuery = searchParams.get("search") || "";
const statusFilter = searchParams.get("status") || "";
// Fetch filter options
const {
data: filterOptions,
isLoading: isLoadingOptions
} = useQuery<ProductFilterOptions>({
queryKey: ["productFilterOptions"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/metrics/filter-options`, {
credentials: 'include',
});
if (!response.ok) {
return { vendors: [], brands: [], abcClasses: [] };
}
return await response.json();
},
initialData: { vendors: [], brands: [], abcClasses: [] }, // Provide initial data to prevent undefined
});
// Fetch products with metrics data
const {
data,
isLoading,
error
} = useQuery<{ products: ProductMetric[], total: number }>({
queryKey: ["products", currentPage, pageSize, sortBy, sortDirection, filterType, filterValue, searchQuery, statusFilter],
queryFn: async () => {
// Build query parameters
const params = new URLSearchParams();
params.append("page", currentPage.toString());
params.append("limit", pageSize.toString());
if (sortBy) params.append("sortBy", sortBy);
if (sortDirection) params.append("sortDirection", sortDirection);
if (filterType && filterValue) {
params.append("filterType", filterType);
params.append("filterValue", filterValue);
}
if (searchQuery) params.append("search", searchQuery);
if (statusFilter) params.append("status", statusFilter);
const response = await fetch(`${config.apiUrl}/metrics?${params.toString()}`, {
credentials: 'include',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to fetch products (${response.status})`);
}
const data = await response.json();
// Calculate status for each product
const productsWithStatus = data.products.map((product: ProductMetric) => ({
...product,
status: getProductStatus(product)
}));
return {
products: productsWithStatus,
total: data.total
};
},
});
const handlePageChange = (page: number) => {
searchParams.set("page", page.toString());
setSearchParams(searchParams);
};
const handlePageSizeChange = (size: number) => {
searchParams.set("pageSize", size.toString());
searchParams.set("page", "1"); // Reset to first page when changing page size
setSearchParams(searchParams);
};
const handleSortChange = (field: string, direction: "asc" | "desc") => {
searchParams.set("sortBy", field);
searchParams.set("sortDirection", direction);
setSearchParams(searchParams);
};
const handleFilterChange = (type: string, value: string) => {
if (type && value) {
searchParams.set("filterType", type);
searchParams.set("filterValue", value);
} else {
searchParams.delete("filterType");
searchParams.delete("filterValue");
}
searchParams.set("page", "1"); // Reset to first page when applying filters
setSearchParams(searchParams);
};
const handleStatusFilterChange = (status: string) => {
if (status) {
searchParams.set("status", status);
} else {
searchParams.delete("status");
}
searchParams.set("page", "1"); // Reset to first page when changing status filter
setSearchParams(searchParams);
};
const handleSearchChange = (query: string) => {
if (query) {
searchParams.set("search", query);
} else {
searchParams.delete("search");
}
searchParams.set("page", "1"); // Reset to first page when searching
setSearchParams(searchParams);
};
const handleViewProduct = (id: number) => {
setSelectedProductId(id);
};
const handleCloseProductDetail = () => {
setSelectedProductId(null);
};
// Create a wrapper function to handle all filter changes
const handleFiltersChange = (filters: Record<string, any>) => {
// Reset to first page when applying filters
searchParams.set("page", "1");
// Update searchParams with all filters
Object.entries(filters).forEach(([key, value]) => {
if (value) {
searchParams.set(key, String(value));
} else {
searchParams.delete(key);
}
});
setSearchParams(searchParams);
};
// Clear all filters
const handleClearFilters = () => {
// Keep only pagination and sorting params
const newParams = new URLSearchParams();
newParams.set("page", "1");
newParams.set("pageSize", pageSize.toString());
newParams.set("sortBy", sortBy);
newParams.set("sortDirection", sortDirection);
setSearchParams(newParams);
};
// Current active filters
const activeFilters = React.useMemo(() => {
const filters: Record<string, any> = {};
if (filterType && filterValue) {
filters[filterType] = filterValue;
}
if (searchQuery) {
filters.search = searchQuery;
}
if (statusFilter) {
filters.status = statusFilter;
}
return filters;
}, [filterType, filterValue, searchQuery, statusFilter]);
return (
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Products</h2>
</div>
<ProductFilters
filterOptions={filterOptions || { vendors: [], brands: [], abcClasses: [] }}
isLoadingOptions={isLoadingOptions}
onFilterChange={handleFiltersChange}
onClearFilters={handleClearFilters}
activeFilters={activeFilters}
/>
{isLoading ? (
<div className="flex justify-center items-center min-h-[300px]">
<Spinner size="lg" />
</div>
) : error ? (
<div className="bg-destructive/10 p-4 rounded-lg text-center text-destructive border border-destructive">
Error loading products: {(error as Error).message}
</div>
) : (
<ProductTable
products={data?.products || []}
total={data?.total || 0}
currentPage={currentPage}
pageSize={pageSize}
sortBy={sortBy}
sortDirection={sortDirection as "asc" | "desc"}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onSortChange={handleSortChange}
onViewProduct={handleViewProduct}
/>
)}
<ProductDetail
productId={selectedProductId}
onClose={handleCloseProductDetail}
/>
</div>
);
}

View File

@@ -0,0 +1,459 @@
import { useState, useMemo, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { motion } from "framer-motion";
import { Input } from "@/components/ui/input";
import config from "../config";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
// Matches backend COLUMN_MAP keys for sorting
type BrandSortableColumns =
| 'brandName' | 'productCount' | 'activeProductCount' | 'currentStockUnits'
| 'currentStockCost' | 'currentStockRetail' | 'revenue_7d' | 'revenue_30d'
| 'profit_30d' | 'sales_30d' | 'avg_margin_30d' | 'stock_turn_30d' | 'status'; // Add more as needed
interface BrandMetric {
brand_id: string | number;
brand_name: string;
last_calculated: string;
product_count: number;
active_product_count: number;
replenishable_product_count: number;
current_stock_units: number;
current_stock_cost: string | number;
current_stock_retail: string | number;
sales_7d: number;
revenue_7d: string | number;
sales_30d: number;
revenue_30d: string | number;
profit_30d: string | number;
cogs_30d: string | number;
sales_365d: number;
revenue_365d: string | number;
lifetime_sales: number;
lifetime_revenue: string | number;
avg_margin_30d: string | number | null;
stock_turn_30d: string | number | null;
status: string;
brand_status: string;
description: string;
// Camel case versions
brandId: string | number;
brandName: string;
lastCalculated: string;
productCount: number;
activeProductCount: number;
replenishableProductCount: number;
currentStockUnits: number;
currentStockCost: string | number;
currentStockRetail: string | number;
lifetimeSales: number;
lifetimeRevenue: string | number;
avgMargin_30d: string | number | null;
stockTurn_30d: string | number | null;
}
// Define response type to avoid type errors
interface BrandResponse {
brands: BrandMetric[];
pagination: {
total: number;
pages: number;
currentPage: number;
limit: number;
};
}
interface BrandFilterOptions {
statuses: string[];
}
interface BrandStats {
totalBrands: number;
activeBrands: number;
totalActiveProducts: number; // SUM(active_product_count)
totalValue: number; // SUM(current_stock_cost)
avgMargin: number; // Weighted avg margin 30d
}
interface BrandFilters {
search: string;
status: string;
showInactive: boolean; // Show brands with 0 active products
}
const ITEMS_PER_PAGE = 50;
// Re-use formatting helpers or define here
const formatCurrency = (value: number | string | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(parsed);
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(value);
};
const formatNumber = (value: number | string | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return parsed.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return value.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
const formatPercentage = (value: number | string | null | undefined, digits = 1): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return `${parsed.toFixed(digits)}%`;
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return `${value.toFixed(digits)}%`;
};
const getStatusVariant = (status: string): "default" | "secondary" | "outline" | "destructive" => {
switch (status) {
case 'active':
return 'default';
case 'inactive':
return 'secondary';
case 'discontinued':
return 'destructive';
default:
return 'outline';
}
};
export function Brands() {
const [page, setPage] = useState(1);
const [limit] = useState(ITEMS_PER_PAGE);
const [sortColumn, setSortColumn] = useState<BrandSortableColumns>("brandName");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [filters, setFilters] = useState<BrandFilters>({
search: "",
status: "all",
showInactive: false, // Default to hiding brands with 0 active products
});
// --- Data Fetching ---
const queryParams = useMemo(() => {
const params = new URLSearchParams();
params.set('page', page.toString());
params.set('limit', limit.toString());
params.set('sort', sortColumn);
params.set('order', sortDirection);
if (filters.search) {
params.set('brandName_ilike', filters.search); // Filter by name
}
if (filters.status !== 'all') {
params.set('status', filters.status); // Filter by status
}
if (!filters.showInactive) {
params.set('activeProductCount_gt', '0'); // Only show brands with active products
}
// Add more filters here if needed (e.g., revenue30d_gt=5000)
return params;
}, [page, limit, sortColumn, sortDirection, filters]);
const { data: listData, isLoading: isLoadingList, error: listError } = useQuery<BrandResponse, Error>({
queryKey: ['brands', queryParams.toString()],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/brands-aggregate?${queryParams.toString()}`, {
credentials: 'include'
});
if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
const data = await response.json();
console.log('Brands data:', JSON.stringify(data, null, 2));
return data;
},
placeholderData: (prev) => prev, // Modern replacement for keepPreviousData
});
const { data: statsData, isLoading: isLoadingStats } = useQuery<BrandStats, Error>({
queryKey: ['brandsStats'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/brands-aggregate/stats`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch brand stats");
return response.json();
},
});
// Fetch filter options
const { data: filterOptions } = useQuery<BrandFilterOptions, Error>({
queryKey: ['brandsFilterOptions'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/brands-aggregate/filter-options`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch filter options");
return response.json();
},
});
// --- Event Handlers ---
const handleSort = useCallback((column: BrandSortableColumns) => {
setSortDirection(prev => (sortColumn === column && prev === "asc" ? "desc" : "asc"));
setSortColumn(column);
setPage(1);
}, [sortColumn]);
const handleFilterChange = useCallback((filterName: keyof BrandFilters, value: string | boolean) => {
setFilters(prev => ({ ...prev, [filterName]: value }));
setPage(1);
}, []);
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= (listData?.pagination.pages ?? 1)) {
setPage(newPage);
}
};
// --- Derived Data ---
const brands = listData?.brands ?? [];
const pagination = listData?.pagination;
const totalPages = pagination?.pages ?? 0;
// --- Rendering ---
return (
<motion.div
layout
transition={{ layout: { duration: 0.15, ease: [0.4, 0, 0.2, 1] } }}
className="container mx-auto py-6 space-y-4"
>
{/* Header */}
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Brands</h1>
<div className="text-sm text-muted-foreground">
{isLoadingList && !pagination ? 'Loading...' : `${formatNumber(pagination?.total)} brands`}
</div>
</motion.div>
{/* Stats Cards */}
<motion.div layout="preserve-aspect" transition={{ duration: 0.15 }} className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Brands</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalBrands)}</div>}
<p className="text-xs text-muted-foreground">
{isLoadingStats ? <Skeleton className="h-4 w-28" /> :
`${formatNumber(statsData?.activeBrands)} active`}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Stock Value</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-28" /> : <div className="text-2xl font-bold">{formatCurrency(statsData?.totalValue)}</div>}
<p className="text-xs text-muted-foreground">
Current cost value
</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 Margin (30d)</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-20" /> : <div className="text-2xl font-bold">{formatPercentage(statsData?.avgMargin)}</div>}
<p className="text-xs text-muted-foreground">
Weighted by revenue
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Active Products</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalActiveProducts)}</div>}
<p className="text-xs text-muted-foreground">
Across all brands
</p>
</CardContent>
</Card>
</motion.div>
{/* Filter Controls */}
<div className="flex flex-wrap items-center space-y-2 sm:space-y-0 sm:space-x-2">
<Input
placeholder="Search brands..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
className="w-full sm:w-[250px]"
/>
<Select
value={filters.status}
onValueChange={(value) => handleFilterChange('status', value)}
>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{filterOptions?.statuses?.map((status) => (
<SelectItem key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center space-x-2 ml-auto">
<Switch
id="show-inactive-brands"
checked={filters.showInactive}
onCheckedChange={(checked) => handleFilterChange('showInactive', checked)}
/>
<Label htmlFor="show-inactive-brands">Show brands with no active products</Label>
</div>
</div>
{/* Data Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead onClick={() => handleSort("brandName")} className="cursor-pointer">Brand</TableHead>
<TableHead onClick={() => handleSort("activeProductCount")} className="cursor-pointer text-right">Active Prod.</TableHead>
<TableHead onClick={() => handleSort("currentStockUnits")} className="cursor-pointer text-right">Stock Units</TableHead>
<TableHead onClick={() => handleSort("currentStockCost")} className="cursor-pointer text-right">Stock Cost</TableHead>
<TableHead onClick={() => handleSort("currentStockRetail")} className="cursor-pointer text-right">Stock Retail</TableHead>
<TableHead onClick={() => handleSort("revenue_30d")} className="cursor-pointer text-right">Revenue (30d)</TableHead>
<TableHead onClick={() => handleSort("profit_30d")} className="cursor-pointer text-right">Profit (30d)</TableHead>
<TableHead onClick={() => handleSort("avg_margin_30d")} className="cursor-pointer text-right">Margin (30d)</TableHead>
<TableHead onClick={() => handleSort("stock_turn_30d")} className="cursor-pointer text-right">Stock Turn (30d)</TableHead>
<TableHead onClick={() => handleSort("status")} className="cursor-pointer text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoadingList && !listData ? (
Array.from({ length: 5 }).map((_, i) => ( // Skeleton rows
<TableRow key={`skel-${i}`}>
<TableCell><Skeleton className="h-5 w-40" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
</TableRow>
))
) : listError ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-destructive">
Error loading brands: {listError.message}
</TableCell>
</TableRow>
) : brands.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
No brands found matching your criteria.
</TableCell>
</TableRow>
) : (
brands.map((brand: BrandMetric) => (
<TableRow key={brand.brand_id} className={brand.active_product_count === 0 ? "opacity-60" : ""}>
<TableCell className="font-medium">{brand.brand_name}</TableCell>
<TableCell className="text-right">{formatNumber(brand.active_product_count || brand.activeProductCount)}</TableCell>
<TableCell className="text-right">{formatNumber(brand.current_stock_units || brand.currentStockUnits)}</TableCell>
<TableCell className="text-right">{formatCurrency(brand.current_stock_cost as number)}</TableCell>
<TableCell className="text-right">{formatCurrency(brand.current_stock_retail as number)}</TableCell>
<TableCell className="text-right">{formatCurrency(brand.revenue_30d as number)}</TableCell>
<TableCell className="text-right">{formatCurrency(brand.profit_30d as number)}</TableCell>
<TableCell className="text-right">{formatPercentage(brand.avg_margin_30d as number)}</TableCell>
<TableCell className="text-right">{formatNumber(brand.stock_turn_30d, 2)}</TableCell>
<TableCell className="text-right">
<Badge variant={getStatusVariant(brand.status)}>
{brand.status || 'Unknown'}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
{totalPages > 1 && pagination && (
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex justify-center">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage - 1); }}
aria-disabled={pagination.currentPage === 1}
className={pagination.currentPage === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{[...Array(totalPages)].map((_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(i + 1); }}
isActive={pagination.currentPage === i + 1}
>
{i + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage + 1); }}
aria-disabled={pagination.currentPage >= totalPages}
className={pagination.currentPage >= totalPages ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</motion.div>
)}
</motion.div>
);
}
export default Brands;

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,8 @@ import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton
import { ProductDetail } from '@/components/products/ProductDetail';
import { ProductViews } from '@/components/products/ProductViews';
import { Button } from '@/components/ui/button';
import { Product } from '@/types/products';
import type { ColumnKey } from '@/components/products/ProductTable';
import { Product, ProductMetric, ProductMetricColumnKey } from '@/types/products';
import { getProductStatus } from '@/utils/productUtils';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -35,7 +35,7 @@ import { toast } from "sonner";
// Column definition type
interface ColumnDef {
key: ColumnKey;
key: ProductMetricColumnKey;
label: string;
group: string;
noLabel?: boolean;
@@ -45,171 +45,162 @@ interface ColumnDef {
// Define available columns with their groups
const AVAILABLE_COLUMNS: ColumnDef[] = [
{ key: 'image', label: 'Image', group: 'Basic Info', noLabel: true, width: 'w-[60px]' },
{ key: 'imageUrl', label: 'Image', group: 'Basic Info', noLabel: true, width: 'w-[60px]' },
{ key: 'title', label: 'Name', group: 'Basic Info' },
{ key: 'SKU', label: 'SKU', group: 'Basic Info' },
{ key: 'sku', label: 'SKU', group: 'Basic Info' },
{ key: 'brand', label: 'Company', group: 'Basic Info' },
{ key: 'vendor', label: 'Supplier', group: 'Basic Info' },
{ key: 'vendor_reference', label: 'Supplier #', group: 'Basic Info' },
{ key: 'barcode', label: 'UPC', group: 'Basic Info' },
{ key: 'description', label: 'Description', group: 'Basic Info' },
{ key: 'created_at', label: 'Created', group: 'Basic Info' },
{ key: 'harmonized_tariff_code', label: 'HTS Code', group: 'Basic Info' },
{ key: 'notions_reference', label: 'Notions Ref', group: 'Basic Info' },
{ key: 'line', label: 'Line', group: 'Basic Info' },
{ key: 'subline', label: 'Subline', group: 'Basic Info' },
{ key: 'artist', label: 'Artist', group: 'Basic Info' },
{ key: 'country_of_origin', label: 'Origin', group: 'Basic Info' },
{ key: 'location', label: 'Location', group: 'Basic Info' },
{ key: 'isVisible', label: 'Visible', group: 'Basic Info' },
{ key: 'isReplenishable', label: 'Replenishable', group: 'Basic Info' },
{ key: 'dateCreated', label: 'Created', group: 'Basic Info' },
// Physical properties
{ key: 'weight', label: 'Weight', group: 'Physical', format: (v) => v?.toString() ?? '-' },
{ key: 'dimensions', label: 'Dimensions', group: 'Physical', format: (v) => v ? `${v.length}x${v.width}x${v.height}` : '-' },
// Current Status
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentRegularPrice', label: 'Regular Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentCostPrice', label: 'Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentLandingCostPrice', label: 'Landing Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentStock', label: 'Stock', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'currentStockCost', label: 'Stock Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentStockRetail', label: 'Stock Retail', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentStockGross', label: 'Stock Gross', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'onOrderQty', label: 'On Order', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'onOrderCost', label: 'On Order Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'onOrderRetail', label: 'On Order Retail', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'earliestExpectedDate', label: 'Expected Date', group: 'Stock' },
// Stock columns
{ key: 'stock_quantity', label: 'Shelf Count', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'stock_status', label: 'Stock Status', group: 'Stock' },
{ key: 'preorder_count', label: 'Preorders', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'notions_inv_count', label: 'Notions Inv', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'days_of_inventory', label: 'Days of Stock', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'weeks_of_inventory', label: 'Weeks of Stock', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'abc_class', label: 'ABC Class', group: 'Stock' },
{ key: 'replenishable', label: 'Replenishable', group: 'Stock' },
{ key: 'moq', label: 'MOQ', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'reorder_qty', label: 'Reorder Qty', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'reorder_point', label: 'Reorder Point', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'safety_stock', label: 'Safety Stock', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'overstocked_amt', label: 'Overstock Amt', group: 'Stock', format: (v) => v?.toString() ?? '-' },
// Dates
{ key: 'dateFirstReceived', label: 'First Received', group: 'Dates' },
{ key: 'dateLastReceived', label: 'Last Received', group: 'Dates' },
{ key: 'dateFirstSold', label: 'First Sold', group: 'Dates' },
{ key: 'dateLastSold', label: 'Last Sold', group: 'Dates' },
{ key: 'ageDays', label: 'Age (Days)', group: 'Dates', format: (v) => v?.toString() ?? '-' },
// Pricing columns
{ key: 'price', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'regular_price', label: 'Default Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'cost_price', label: 'Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'landing_cost_price', label: 'Landing Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
// Product Status
{ key: 'status', label: 'Status', group: 'Status' },
// Sales columns
{ key: 'daily_sales_avg', label: 'Daily Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'weekly_sales_avg', label: 'Weekly Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'monthly_sales_avg', label: 'Monthly Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'avg_quantity_per_order', label: 'Avg Qty/Order', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'number_of_orders', label: 'Order Count', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'first_sale_date', label: 'First Sale', group: 'Sales' },
{ key: 'last_sale_date', label: 'Last Sale', group: 'Sales' },
{ key: 'date_last_sold', label: 'Date Last Sold', group: 'Sales' },
{ key: 'total_sold', label: 'Total Sold', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'baskets', label: 'In Baskets', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'notifies', label: 'Notifies', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'rating', label: 'Rating', group: 'Sales', format: (v) => v ? v.toFixed(1) : '-' },
{ key: 'reviews', label: 'Reviews', group: 'Sales', format: (v) => v?.toString() ?? '-' },
// Rolling Metrics
{ key: 'sales7d', label: 'Sales (7d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'revenue7d', label: 'Revenue (7d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'sales14d', label: 'Sales (14d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'revenue14d', label: 'Revenue (14d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'sales30d', label: 'Sales (30d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'cogs30d', label: 'COGS (30d)', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'profit30d', label: 'Profit (30d)', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'sales365d', label: 'Sales (365d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
// Financial columns
{ key: 'gmroi', label: 'GMROI', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'turnover_rate', label: 'Turnover Rate', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'avg_margin_percent', label: 'Margin %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
{ key: 'inventory_value', label: 'Inventory Value', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'cost_of_goods_sold', label: 'COGS', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'gross_profit', label: 'Gross Profit', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
// KPIs
{ key: 'margin30d', label: 'Margin %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
{ key: 'markup30d', label: 'Markup %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
{ key: 'gmroi30d', label: 'GMROI', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'stockturn30d', label: 'Stock Turn', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'sellThrough30d', label: 'Sell Through %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
{ key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
// Lead Time columns
{ key: 'current_lead_time', label: 'Current Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'target_lead_time', label: 'Target Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'lead_time_status', label: 'Lead Time Status', group: 'Lead Time' },
{ key: 'last_purchase_date', label: 'Last Purchase', group: 'Lead Time' },
{ key: 'first_received_date', label: 'First Received', group: 'Lead Time' },
{ key: 'last_received_date', label: 'Last Received', group: 'Lead Time' },
// Replenishment
{ key: 'abcClass', label: 'ABC Class', group: 'Stock' },
{ key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'overstockedUnits', label: 'Overstock Qty', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'overstockedCost', label: 'Overstock Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'overstockedRetail', label: 'Overstock Retail', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'isOldStock', label: 'Old Stock', group: 'Stock' },
{ key: 'yesterdaySales', label: 'Yesterday Sales', group: 'Sales', format: (v) => v?.toString() ?? '-' },
];
// Define default columns for each view
const VIEW_COLUMNS: Record<string, ColumnKey[]> = {
const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
all: [
'image',
'imageUrl',
'title',
'brand',
'vendor',
'stock_quantity',
'stock_status',
'reorder_qty',
'price',
'regular_price',
'daily_sales_avg',
'weekly_sales_avg',
'monthly_sales_avg',
'inventory_value',
'currentStock',
'status',
'salesVelocityDaily',
'currentPrice',
'currentRegularPrice',
'sales7d',
'sales30d',
'revenue30d',
'currentStockCost',
],
critical: [
'image',
'imageUrl',
'title',
'stock_quantity',
'safety_stock',
'daily_sales_avg',
'weekly_sales_avg',
'reorder_qty',
'reorder_point',
'currentStock',
'configSafetyStock',
'sales7d',
'sales30d',
'replenishmentUnits',
'salesVelocityDaily',
'vendor',
'last_purchase_date',
'current_lead_time',
'dateLastReceived',
'avgLeadTimeDays',
],
reorder: [
'image',
'imageUrl',
'title',
'stock_quantity',
'reorder_point',
'daily_sales_avg',
'weekly_sales_avg',
'reorder_qty',
'currentStock',
'salesVelocityDaily',
'sales7d',
'sales30d',
'replenishmentUnits',
'vendor',
'last_purchase_date',
'avg_lead_time_days',
'dateLastReceived',
'avgLeadTimeDays',
],
overstocked: [
'image',
'imageUrl',
'title',
'stock_quantity',
'daily_sales_avg',
'weekly_sales_avg',
'overstocked_amt',
'days_of_inventory',
'inventory_value',
'turnover_rate',
'currentStock',
'sales7d',
'sales30d',
'overstockedUnits',
'stockCoverInDays',
'currentStockCost',
'stockturn30d',
],
'at-risk': [
'image',
'imageUrl',
'title',
'stock_quantity',
'safety_stock',
'daily_sales_avg',
'weekly_sales_avg',
'days_of_inventory',
'last_sale_date',
'current_lead_time',
'currentStock',
'configSafetyStock',
'sales7d',
'sales30d',
'stockCoverInDays',
'dateLastSold',
'avgLeadTimeDays',
],
new: [
'image',
'imageUrl',
'title',
'stock_quantity',
'currentStock',
'vendor',
'brand',
'price',
'regular_price',
'first_received_date',
'currentPrice',
'currentRegularPrice',
'dateFirstReceived',
],
healthy: [
'image',
'imageUrl',
'title',
'stock_quantity',
'daily_sales_avg',
'weekly_sales_avg',
'monthly_sales_avg',
'days_of_inventory',
'gross_profit',
'gmroi',
'currentStock',
'sales7d',
'sales30d',
'revenue30d',
'stockCoverInDays',
'profit30d',
'gmroi30d',
],
};
export function Products() {
const [searchParams, setSearchParams] = useSearchParams();
const [filters, setFilters] = useState<Record<string, ActiveFilterValue>>({});
const [sortColumn, setSortColumn] = useState<ColumnKey>('title');
const [sortColumn, setSortColumn] = useState<ProductMetricColumnKey>('title');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [currentPage, setCurrentPage] = useState(1);
const [activeView, setActiveView] = useState(searchParams.get('view') || "all");
@@ -219,16 +210,16 @@ export function Products() {
const [, setIsLoading] = useState(false);
// Store visible columns and order for each view
const [viewColumns, setViewColumns] = useState<Record<string, Set<ColumnKey>>>(() => {
const initialColumns: Record<string, Set<ColumnKey>> = {};
const [viewColumns, setViewColumns] = useState<Record<string, Set<ProductMetricColumnKey>>>(() => {
const initialColumns: Record<string, Set<ProductMetricColumnKey>> = {};
Object.entries(VIEW_COLUMNS).forEach(([view, columns]) => {
initialColumns[view] = new Set(columns);
});
return initialColumns;
});
const [viewColumnOrder, setViewColumnOrder] = useState<Record<string, ColumnKey[]>>(() => {
const initialOrder: Record<string, ColumnKey[]> = {};
const [viewColumnOrder, setViewColumnOrder] = useState<Record<string, ProductMetricColumnKey[]>>(() => {
const initialOrder: Record<string, ProductMetricColumnKey[]> = {};
Object.entries(VIEW_COLUMNS).forEach(([view, defaultColumns]) => {
initialOrder[view] = [
...defaultColumns,
@@ -241,16 +232,19 @@ export function Products() {
// Get current view's columns
const visibleColumns = useMemo(() => {
const columns = new Set(viewColumns[activeView] || VIEW_COLUMNS.all);
// Add isReplenishable column when showing non-replenishable products for better visibility
if (showNonReplenishable) {
columns.add('replenishable');
columns.add('isReplenishable');
}
return columns;
}, [viewColumns, activeView, showNonReplenishable]);
const columnOrder = viewColumnOrder[activeView] || viewColumnOrder.all;
// Handle column visibility changes
const handleColumnVisibilityChange = (column: ColumnKey, isVisible: boolean) => {
const handleColumnVisibilityChange = (column: ProductMetricColumnKey, isVisible: boolean) => {
setViewColumns(prev => ({
...prev,
[activeView]: isVisible
@@ -260,7 +254,7 @@ export function Products() {
};
// Handle column order changes
const handleColumnOrderChange = (newOrder: ColumnKey[]) => {
const handleColumnOrderChange = (newOrder: ProductMetricColumnKey[]) => {
setViewColumnOrder(prev => ({
...prev,
[activeView]: newOrder
@@ -307,35 +301,93 @@ export function Products() {
params.append('limit', pageSize.toString());
if (sortColumn) {
params.append('sort', sortColumn);
// Convert camelCase to snake_case for the API
const snakeCaseSort = sortColumn.replace(/([A-Z])/g, '_$1').toLowerCase();
params.append('sort', snakeCaseSort);
params.append('order', sortDirection);
}
if (activeView && activeView !== 'all') {
params.append('stockStatus', activeView === 'at-risk' ? 'At Risk' : activeView);
params.append('stock_status', activeView === 'at-risk' ? 'At Risk' : activeView);
}
// Transform filters to match API expectations
const transformedFilters = transformFilters(filters);
Object.entries(transformedFilters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
// Convert camelCase to snake_case for the API
const snakeCaseKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
if (Array.isArray(value)) {
params.append(key, JSON.stringify(value));
params.append(snakeCaseKey, JSON.stringify(value));
} else {
params.append(key, value.toString());
params.append(snakeCaseKey, value.toString());
}
}
});
if (!showNonReplenishable) {
params.append('showNonReplenishable', 'false');
params.append('show_non_replenishable', 'false');
}
const response = await fetch(`/api/products?${params.toString()}`);
const response = await fetch(`/api/metrics?${params.toString()}`);
if (!response.ok) throw new Error('Failed to fetch products');
const data = await response.json();
return data;
// Transform snake_case keys to camelCase and convert string numbers to actual numbers
const transformedProducts = data.metrics?.map((product: any) => {
const transformed: any = {};
// Process all keys to convert from snake_case to camelCase
Object.entries(product).forEach(([key, value]) => {
// Better handling of snake_case to camelCase conversion
let camelKey = key;
// First handle cases like sales_7d -> sales7d
camelKey = camelKey.replace(/_(\d+[a-z])/g, '$1');
// Then handle regular snake_case -> camelCase
camelKey = camelKey.replace(/_([a-z])/g, (_, p1) => p1.toUpperCase());
// Convert numeric strings to actual numbers
if (typeof value === 'string' && !isNaN(Number(value)) &&
!key.toLowerCase().includes('date') && key !== 'sku' && key !== 'title' &&
key !== 'brand' && key !== 'vendor') {
transformed[camelKey] = Number(value);
} else {
transformed[camelKey] = value;
}
});
// Ensure pid is a number
transformed.pid = typeof transformed.pid === 'string' ?
parseInt(transformed.pid, 10) : transformed.pid;
return transformed;
}) || [];
// Debug: Log the first item to check field mapping
if (transformedProducts.length > 0) {
console.log('Sample product after transformation:');
console.log('sales7d:', transformedProducts[0].sales7d);
console.log('sales30d:', transformedProducts[0].sales30d);
console.log('revenue30d:', transformedProducts[0].revenue30d);
console.log('margin30d:', transformedProducts[0].margin30d);
console.log('markup30d:', transformedProducts[0].markup30d);
}
// Transform the metrics response to match our expected format
return {
products: transformedProducts,
pagination: data.pagination || {
total: 0,
pages: 0,
currentPage: 1,
limit: pageSize
},
filters: data.appliedQuery?.filters || {}
};
} catch (error) {
console.error('Error fetching products:', error);
toast("Failed to fetch products. Please try again.");
@@ -345,6 +397,29 @@ export function Products() {
}
};
// Query for filter options
const { data: filterOptionsData, isLoading: isLoadingFilterOptions } = useQuery({
queryKey: ['filterOptions'],
queryFn: async () => {
try {
const response = await fetch('/api/metrics/filter-options');
if (!response.ok) throw new Error('Failed to fetch filter options');
const data = await response.json();
// Ensure we have the expected structure with correct casing
return {
vendors: data.vendors || [],
brands: data.brands || [],
abcClasses: data.abc_classes || data.abcClasses || []
};
} catch (error) {
console.error('Error fetching filter options:', error);
return { vendors: [], brands: [], abcClasses: [] };
}
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Query for products data
const { data, isFetching } = useQuery({
queryKey: ['products', currentPage, pageSize, sortColumn, sortDirection, activeView, filters, showNonReplenishable],
@@ -360,7 +435,7 @@ export function Products() {
}, [currentPage, data?.pagination.pages]);
// Handle sort column change
const handleSort = (column: keyof Product) => {
const handleSort = (column: ProductMetricColumnKey) => {
setSortDirection(prev => {
if (sortColumn !== column) return 'asc';
return prev === 'asc' ? 'desc' : 'asc';
@@ -515,9 +590,12 @@ export function Products() {
<div>
<div className="flex items-center justify-between mb-4">
<ProductFilters
categories={data?.filters?.categories ?? []}
vendors={data?.filters?.vendors ?? []}
brands={data?.filters?.brands ?? []}
filterOptions={{
vendors: filterOptionsData?.vendors ?? [],
brands: filterOptionsData?.brands ?? [],
abcClasses: filterOptionsData?.abcClasses ?? []
}}
isLoadingOptions={isLoadingFilterOptions}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
activeFilters={filters}
@@ -534,7 +612,7 @@ export function Products() {
/>
<Label htmlFor="show-non-replenishable">Show Non-Replenishable</Label>
</div>
{data?.pagination.total > 0 && (
{data?.pagination?.total !== undefined && (
<div className="text-sm text-muted-foreground">
{data.pagination.total.toLocaleString()} products
</div>
@@ -548,7 +626,13 @@ export function Products() {
) : (
<div className="space-y-4">
<ProductTable
products={data?.products || []}
products={data?.products?.map((product: ProductMetric) => {
// Before returning the product, ensure it has a status for display
if (!product.status) {
product.status = getProductStatus(product);
}
return product;
}) || []}
onSort={handleSort}
sortColumn={sortColumn}
sortDirection={sortDirection}

View File

@@ -1,329 +1,462 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { motion } from "framer-motion";
import config from "../config";
import { Skeleton } from "@/components/ui/skeleton";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
interface Vendor {
vendor_id: number;
name: string;
// Matches backend COLUMN_MAP keys for sorting
type VendorSortableColumns =
| 'vendorName' | 'productCount' | 'activeProductCount' | 'currentStockUnits'
| 'currentStockCost' | 'onOrderUnits' | 'onOrderCost' | 'avgLeadTimeDays'
| 'revenue_30d' | 'profit_30d' | 'avg_margin_30d' | 'po_count_365d' | 'status';
interface VendorMetric {
vendor_id: string | number;
vendor_name: string;
last_calculated: string;
product_count: number;
active_product_count: number;
replenishable_product_count: number;
current_stock_units: number;
current_stock_cost: string | number;
current_stock_retail: string | number;
on_order_units: number;
on_order_cost: string | number;
po_count_365d: number;
avg_lead_time_days: number | null;
sales_7d: number;
revenue_7d: string | number;
sales_30d: number;
revenue_30d: string | number;
profit_30d: string | number;
cogs_30d: string | number;
sales_365d: number;
revenue_365d: string | number;
lifetime_sales: number;
lifetime_revenue: string | number;
avg_margin_30d: string | number | null;
// New fields added by vendorsAggregate
status: string;
avg_lead_time_days: number;
on_time_delivery_rate: number;
order_fill_rate: number;
total_orders: number;
active_products: number;
vendor_status: string;
cost_metrics_30d: {
avg_unit_cost: number;
total_spend: number;
order_count: number;
};
// Camel case versions
vendorId: string | number;
vendorName: string;
lastCalculated: string;
productCount: number;
activeProductCount: number;
replenishableProductCount: number;
currentStockUnits: number;
currentStockCost: string | number;
currentStockRetail: string | number;
onOrderUnits: number;
onOrderCost: string | number;
poCount_365d: number;
avgLeadTimeDays: number | null;
lifetimeSales: number;
lifetimeRevenue: string | number;
avgMargin_30d: string | number | null;
}
// Define response type to avoid type errors
interface VendorResponse {
vendors: VendorMetric[];
pagination: {
total: number;
pages: number;
currentPage: number;
limit: number;
};
}
interface VendorFilterOptions {
statuses: string[];
}
interface VendorStats {
totalVendors: number;
activeVendors: number;
totalActiveProducts: number;
totalValue: number;
totalOnOrderValue: number;
avgLeadTime: number;
}
interface VendorFilters {
search: string;
status: string;
performance: string;
showInactive: boolean;
}
const ITEMS_PER_PAGE = 50;
const formatCurrency = (value: number | string | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(parsed);
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(value);
};
const formatNumber = (value: number | string | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return parsed.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return value.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
const formatPercentage = (value: number | string | null | undefined, digits = 1): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return `${parsed.toFixed(digits)}%`;
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return `${value.toFixed(digits)}%`;
};
const formatDays = (value: number | string | null | undefined, digits = 1): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return `${parsed.toFixed(digits)} days`;
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return `${value.toFixed(digits)} days`;
};
const getStatusVariant = (status: string): "default" | "secondary" | "outline" | "destructive" => {
switch (status) {
case 'active':
return 'default';
case 'inactive':
return 'secondary';
case 'discontinued':
return 'destructive';
default:
return 'outline';
}
};
export function Vendors() {
const [page, setPage] = useState(1);
const [sortColumn, setSortColumn] = useState<keyof Vendor>("name");
const [limit] = useState(ITEMS_PER_PAGE);
const [sortColumn, setSortColumn] = useState<VendorSortableColumns>("vendorName");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [filters, setFilters] = useState<VendorFilters>({
search: "",
status: "all",
performance: "all",
showInactive: false, // Default to hiding vendors with 0 active products
});
const { data, isLoading } = useQuery({
queryKey: ["vendors"],
// --- Data Fetching ---
const queryParams = useMemo(() => {
const params = new URLSearchParams();
params.set('page', page.toString());
params.set('limit', limit.toString());
params.set('sort', sortColumn);
params.set('order', sortDirection);
if (filters.search) {
params.set('vendorName_ilike', filters.search); // Filter by name
}
if (filters.status !== 'all') {
params.set('status', filters.status); // Filter by status
}
if (!filters.showInactive) {
params.set('activeProductCount_gt', '0'); // Only show vendors with active products
}
return params;
}, [page, limit, sortColumn, sortDirection, filters]);
const { data: listData, isLoading: isLoadingList, error: listError } = useQuery<VendorResponse, Error>({
queryKey: ['vendors', queryParams.toString()],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/vendors`, {
const response = await fetch(`${config.apiUrl}/vendors-aggregate?${queryParams.toString()}`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch vendors");
if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
return response.json();
},
placeholderData: (prev) => prev, // Modern replacement for keepPreviousData
});
const { data: statsData, isLoading: isLoadingStats } = useQuery<VendorStats, Error>({
queryKey: ['vendorsStats'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/vendors-aggregate/stats`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch vendor stats");
return response.json();
},
});
// Filter and sort the data client-side
const filteredData = useMemo(() => {
if (!data?.vendors) return [];
let filtered = [...data.vendors];
// Apply search filter
if (filters.search) {
const searchLower = filters.search.toLowerCase();
filtered = filtered.filter(vendor =>
vendor.name.toLowerCase().includes(searchLower)
);
}
// Apply status filter
if (filters.status !== 'all') {
filtered = filtered.filter(vendor => vendor.status === filters.status);
}
// Apply performance filter
if (filters.performance !== 'all') {
filtered = filtered.filter(vendor => {
const fillRate = vendor.order_fill_rate ?? 0;
switch (filters.performance) {
case 'excellent': return fillRate >= 95;
case 'good': return fillRate >= 85 && fillRate < 95;
case 'fair': return fillRate >= 75 && fillRate < 85;
case 'poor': return fillRate < 75;
default: return true;
}
// Fetch filter options
const { data: filterOptions, isLoading: isLoadingFilterOptions } = useQuery<VendorFilterOptions, Error>({
queryKey: ['vendorsFilterOptions'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/vendors-aggregate/filter-options`, {
credentials: 'include'
});
}
// Apply sorting
filtered.sort((a, b) => {
const aVal = a[sortColumn];
const bVal = b[sortColumn];
if (typeof aVal === 'number' && typeof bVal === 'number') {
return sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
}
const aStr = String(aVal || '');
const bStr = String(bVal || '');
return sortDirection === 'asc' ?
aStr.localeCompare(bStr) :
bStr.localeCompare(aStr);
if (!response.ok) throw new Error("Failed to fetch filter options");
return response.json();
},
});
return filtered;
}, [data?.vendors, filters, sortColumn, sortDirection]);
// --- Event Handlers ---
// Calculate pagination
const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE);
const paginatedData = useMemo(() => {
const start = (page - 1) * ITEMS_PER_PAGE;
const end = start + ITEMS_PER_PAGE;
return filteredData.slice(start, end);
}, [filteredData, page]);
const handleSort = (column: keyof Vendor) => {
setSortDirection(prev => {
if (sortColumn !== column) return "asc";
return prev === "asc" ? "desc" : "asc";
});
const handleSort = useCallback((column: VendorSortableColumns) => {
setSortDirection(prev => (sortColumn === column && prev === "asc" ? "desc" : "asc"));
setSortColumn(column);
setPage(1);
}, [sortColumn]);
const handleFilterChange = useCallback((filterName: keyof VendorFilters, value: string | boolean) => {
setFilters(prev => ({ ...prev, [filterName]: value }));
setPage(1);
}, []);
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= (listData?.pagination.pages ?? 1)) {
setPage(newPage);
}
};
const getPerformanceBadge = (fillRate: number) => {
if (fillRate >= 95) return <Badge variant="default">Excellent</Badge>;
if (fillRate >= 85) return <Badge variant="secondary">Good</Badge>;
if (fillRate >= 75) return <Badge variant="outline">Fair</Badge>;
return <Badge variant="destructive">Poor</Badge>;
};
// --- Derived Data ---
const vendors = listData?.vendors ?? [];
const pagination = listData?.pagination;
const totalPages = pagination?.pages ?? 0;
// --- Rendering ---
return (
<motion.div
layout
transition={{
layout: {
duration: 0.15,
ease: [0.4, 0, 0.2, 1]
}
}}
transition={{ layout: { duration: 0.15, ease: [0.4, 0, 0.2, 1] } }}
className="container mx-auto py-6 space-y-4"
>
<motion.div
layout="position"
transition={{ duration: 0.15 }}
className="flex items-center justify-between"
>
{/* Header */}
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Vendors</h1>
<div className="text-sm text-muted-foreground">
{filteredData.length.toLocaleString()} vendors
{isLoadingList && !pagination ? 'Loading...' : `${formatNumber(pagination?.total)} vendors`}
</div>
</motion.div>
<motion.div
layout="preserve-aspect"
transition={{ duration: 0.15 }}
className="grid gap-4 md:grid-cols-4"
>
{/* Stats Cards */}
<motion.div layout="preserve-aspect" transition={{ duration: 0.15 }} className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Vendors</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data?.stats?.totalVendors ?? "..."}</div>
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalVendors)}</div>}
<p className="text-xs text-muted-foreground">
{data?.stats?.activeVendors ?? "..."} active
{isLoadingStats ? <Skeleton className="h-4 w-28" /> :
`${formatNumber(statsData?.activeVendors)} active`}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Spend</CardTitle>
<CardTitle className="text-sm font-medium">Total Stock Value</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
${typeof data?.stats?.totalSpend === 'number' ? data.stats.totalSpend.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }) : "..."}
</div>
{isLoadingStats ? <Skeleton className="h-8 w-28" /> : <div className="text-2xl font-bold">{formatCurrency(statsData?.totalValue)}</div>}
<p className="text-xs text-muted-foreground">
Avg unit cost: ${typeof data?.stats?.avgUnitCost === 'number' ? data.stats.avgUnitCost.toFixed(2) : "..."}
Current cost value
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Performance</CardTitle>
<CardTitle className="text-sm font-medium">Value On Order</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{typeof data?.stats?.avgFillRate === 'number' ? data.stats.avgFillRate.toFixed(1) : "..."}%</div>
{isLoadingStats ? <Skeleton className="h-8 w-28" /> : <div className="text-2xl font-bold">{formatCurrency(statsData?.totalOnOrderValue)}</div>}
<p className="text-xs text-muted-foreground">
Fill rate / {typeof data?.stats?.avgOnTimeDelivery === 'number' ? data.stats.avgOnTimeDelivery.toFixed(1) : "..."}% on-time
Total cost on open POs
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Lead Time</CardTitle>
<CardTitle className="text-sm font-medium">Avg Lead Time</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{typeof data?.stats?.avgLeadTime === 'number' ? data.stats.avgLeadTime.toFixed(1) : "..."} days</div>
{isLoadingStats ? <Skeleton className="h-8 w-20" /> : <div className="text-2xl font-bold">{formatDays(statsData?.avgLeadTime)}</div>}
<p className="text-xs text-muted-foreground">
Average delivery time
Average across vendors
</p>
</CardContent>
</Card>
</motion.div>
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
{/* Filter Controls */}
<div className="flex flex-wrap items-center space-y-2 sm:space-y-0 sm:space-x-2">
<Input
placeholder="Search vendors..."
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
className="h-8 w-[150px] lg:w-[250px]"
onChange={(e) => handleFilterChange('search', e.target.value)}
className="w-full sm:w-[250px]"
/>
<Select
value={filters.status}
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
onValueChange={(value) => handleFilterChange('status', value)}
>
<SelectTrigger className="h-8 w-[150px]">
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
</SelectContent>
</Select>
<Select
value={filters.performance}
onValueChange={(value) => setFilters(prev => ({ ...prev, performance: value }))}
>
<SelectTrigger className="h-8 w-[150px]">
<SelectValue placeholder="Performance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Performance</SelectItem>
<SelectItem value="excellent">Excellent</SelectItem>
<SelectItem value="good">Good</SelectItem>
<SelectItem value="fair">Fair</SelectItem>
<SelectItem value="poor">Poor</SelectItem>
<SelectItem value="all">All Statuses</SelectItem>
{filterOptions?.statuses?.map((status) => (
<SelectItem key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center space-x-2 ml-auto">
<Switch
id="show-inactive-vendors"
checked={filters.showInactive}
onCheckedChange={(checked) => handleFilterChange('showInactive', checked)}
/>
<Label htmlFor="show-inactive-vendors">Show vendors with no active products</Label>
</div>
</div>
{/* Data Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead onClick={() => handleSort("name")} className="cursor-pointer">Vendor</TableHead>
<TableHead onClick={() => handleSort("status")} className="cursor-pointer">Status</TableHead>
<TableHead onClick={() => handleSort("avg_lead_time_days")} className="cursor-pointer">Lead Time</TableHead>
<TableHead onClick={() => handleSort("on_time_delivery_rate")} className="cursor-pointer">On-Time %</TableHead>
<TableHead onClick={() => handleSort("order_fill_rate")} className="cursor-pointer">Fill Rate</TableHead>
<TableHead onClick={() => handleSort("avg_unit_cost")} className="cursor-pointer">Avg Unit Cost</TableHead>
<TableHead onClick={() => handleSort("total_spend")} className="cursor-pointer">Total Spend</TableHead>
<TableHead onClick={() => handleSort("total_orders")} className="cursor-pointer">Orders</TableHead>
<TableHead onClick={() => handleSort("active_products")} className="cursor-pointer">Products</TableHead>
<TableHead onClick={() => handleSort("vendorName")} className="cursor-pointer">Vendor</TableHead>
<TableHead onClick={() => handleSort("activeProductCount")} className="cursor-pointer text-right">Active Prod.</TableHead>
<TableHead onClick={() => handleSort("currentStockCost")} className="cursor-pointer text-right">Stock Value</TableHead>
<TableHead onClick={() => handleSort("onOrderUnits")} className="cursor-pointer text-right">On Order (Units)</TableHead>
<TableHead onClick={() => handleSort("onOrderCost")} className="cursor-pointer text-right">On Order (Cost)</TableHead>
<TableHead onClick={() => handleSort("avgLeadTimeDays")} className="cursor-pointer text-right">Avg Lead Time</TableHead>
<TableHead onClick={() => handleSort("revenue_30d")} className="cursor-pointer text-right">Revenue (30d)</TableHead>
<TableHead onClick={() => handleSort("profit_30d")} className="cursor-pointer text-right">Profit (30d)</TableHead>
<TableHead onClick={() => handleSort("avg_margin_30d")} className="cursor-pointer text-right">Margin (30d)</TableHead>
<TableHead onClick={() => handleSort("po_count_365d")} className="cursor-pointer text-right">POs (365d)</TableHead>
<TableHead onClick={() => handleSort("status")} className="cursor-pointer text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
{isLoadingList && !listData ? (
Array.from({ length: 5 }).map((_, i) => ( // Skeleton rows
<TableRow key={`skel-${i}`}>
<TableCell><Skeleton className="h-5 w-40" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
</TableRow>
))
) : listError ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8">
Loading vendors...
<TableCell colSpan={11} className="text-center py-8 text-destructive">
Error loading vendors: {listError.message}
</TableCell>
</TableRow>
) : paginatedData.map((vendor: Vendor) => (
<TableRow key={vendor.vendor_id}>
<TableCell className="font-medium">{vendor.name}</TableCell>
<TableCell>{vendor.status}</TableCell>
<TableCell>{typeof vendor.avg_lead_time_days === 'number' ? vendor.avg_lead_time_days.toFixed(1) : "0.0"} days</TableCell>
<TableCell>{typeof vendor.on_time_delivery_rate === 'number' ? vendor.on_time_delivery_rate.toFixed(1) : "0.0"}%</TableCell>
<TableCell>
<div className="flex items-center gap-2" style={{ minWidth: '120px' }}>
<div style={{ width: '50px', textAlign: 'right' }}>
{typeof vendor.order_fill_rate === 'number' ? vendor.order_fill_rate.toFixed(1) : "0.0"}%
</div>
{getPerformanceBadge(vendor.order_fill_rate ?? 0)}
</div>
</TableCell>
<TableCell>${typeof vendor.avg_unit_cost === 'number' ? vendor.avg_unit_cost.toFixed(2) : "0.00"}</TableCell>
<TableCell>${typeof vendor.total_spend === 'number' ? vendor.total_spend.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }) : "0"}</TableCell>
<TableCell>{vendor.total_orders?.toLocaleString() ?? 0}</TableCell>
<TableCell>{vendor.active_products?.toLocaleString() ?? 0}</TableCell>
</TableRow>
))}
{!isLoading && !paginatedData.length && (
) : vendors.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
No vendors found
<TableCell colSpan={11} className="text-center py-8 text-muted-foreground">
No vendors found matching your criteria.
</TableCell>
</TableRow>
) : (
vendors.map((vendor: VendorMetric) => (
<TableRow key={vendor.vendor_id} className={vendor.active_product_count === 0 ? "opacity-60" : ""}>
<TableCell className="font-medium">{vendor.vendor_name}</TableCell>
<TableCell className="text-right">{formatNumber(vendor.active_product_count || vendor.activeProductCount)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.current_stock_cost as number)}</TableCell>
<TableCell className="text-right">{formatNumber(vendor.on_order_units || vendor.onOrderUnits)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.on_order_cost as number)}</TableCell>
<TableCell className="text-right">{formatDays(vendor.avg_lead_time_days || vendor.avgLeadTimeDays)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.revenue_30d as number)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.profit_30d as number)}</TableCell>
<TableCell className="text-right">{formatPercentage(vendor.avg_margin_30d as number)}</TableCell>
<TableCell className="text-right">{formatNumber(vendor.po_count_365d || vendor.poCount_365d)}</TableCell>
<TableCell className="text-right">
<Badge variant={getStatusVariant(vendor.status)}>
{vendor.status || 'Unknown'}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<motion.div
layout="position"
transition={{ duration: 0.15 }}
className="flex justify-center"
>
{/* Pagination Controls */}
{totalPages > 1 && pagination && (
<div className="flex justify-center">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (page > 1) setPage(p => p - 1);
}}
aria-disabled={page === 1}
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage - 1); }}
aria-disabled={pagination.currentPage === 1}
className={pagination.currentPage === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => (
{[...Array(totalPages)].map((_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
setPage(i + 1);
}}
isActive={page === i + 1}
onClick={(e) => { e.preventDefault(); handlePageChange(i + 1); }}
isActive={pagination.currentPage === i + 1}
>
{i + 1}
</PaginationLink>
@@ -332,16 +465,14 @@ export function Vendors() {
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (page < totalPages) setPage(p => p + 1);
}}
aria-disabled={page >= totalPages}
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage + 1); }}
aria-disabled={pagination.currentPage >= totalPages}
className={pagination.currentPage >= totalPages ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</motion.div>
</div>
)}
</motion.div>
);

View File

@@ -78,3 +78,196 @@ export interface Product {
reorder_qty?: number;
overstocked_amt?: string; // numeric(15,3)
}
// Type for product status (used for calculated statuses)
export type ProductStatus = "Critical" | "Reorder Soon" | "Healthy" | "Overstock" | "At Risk" | "Unknown";
// Represents data returned by the /metrics endpoint (from product_metrics table)
export interface ProductMetric {
pid: number;
sku: string;
title: string;
brand: string | null;
vendor: string | null;
imageUrl: string | null;
isVisible: boolean;
isReplenishable: boolean;
// Current Status
currentPrice: number | null;
currentRegularPrice: number | null;
currentCostPrice: number | null;
currentLandingCostPrice: number | null;
currentStock: number;
currentStockCost: number | null;
currentStockRetail: number | null;
currentStockGross: number | null;
onOrderQty: number | null;
onOrderCost: number | null;
onOrderRetail: number | null;
earliestExpectedDate: string | null; // Date as string
// Historical Dates
dateCreated: string | null;
dateFirstReceived: string | null;
dateLastReceived: string | null;
dateFirstSold: string | null;
dateLastSold: string | null;
ageDays: number | null;
// Rolling Period Metrics
sales7d: number | null;
revenue7d: number | null;
sales14d: number | null;
revenue14d: number | null;
sales30d: number | null;
revenue30d: number | null;
cogs30d: number | null;
profit30d: number | null;
returnsUnits30d: number | null;
returnsRevenue30d: number | null;
discounts30d: number | null;
grossRevenue30d: number | null;
grossRegularRevenue30d: number | null;
stockoutDays30d: number | null;
sales365d: number | null;
revenue365d: number | null;
avgStockUnits30d: number | null;
avgStockCost30d: number | null;
avgStockRetail30d: number | null;
avgStockGross30d: number | null;
receivedQty30d: number | null;
receivedCost30d: number | null;
// Calculated KPIs
asp30d: number | null;
acp30d: number | null;
avgRos30d: number | null;
avgSalesPerDay30d: number | null;
avgSalesPerMonth30d: number | null;
margin30d: number | null;
markup30d: number | null;
gmroi30d: number | null;
stockturn30d: number | null;
returnRate30d: number | null;
discountRate30d: number | null;
stockoutRate30d: number | null;
markdown30d: number | null;
markdownRate30d: number | null;
sellThrough30d: number | null;
avgLeadTimeDays: number | null;
// Forecasting & Replenishment
abcClass: string | null;
salesVelocityDaily: number | null;
configLeadTime: number | null;
configDaysOfStock: number | null;
configSafetyStock: number | null;
planningPeriodDays: number | null;
leadTimeForecastUnits: number | null;
daysOfStockForecastUnits: number | null;
planningPeriodForecastUnits: number | null;
leadTimeClosingStock: number | null;
daysOfStockClosingStock: number | null;
replenishmentNeededRaw: number | null;
replenishmentUnits: number | null;
replenishmentCost: number | null;
replenishmentRetail: number | null;
replenishmentProfit: number | null;
toOrderUnits: number | null;
forecastLostSalesUnits: number | null;
forecastLostRevenue: number | null;
stockCoverInDays: number | null;
poCoverInDays: number | null;
sellsOutInDays: number | null;
replenishDate: string | null;
overstockedUnits: number | null;
overstockedCost: number | null;
overstockedRetail: number | null;
isOldStock: boolean | null;
// Yesterday
yesterdaySales: number | null;
// Calculated status (added by frontend)
status?: ProductStatus;
}
// Type for filter options returned by /metrics/filter-options
export interface ProductFilterOptions {
vendors: string[];
brands: string[];
abcClasses: string[];
}
// Type for keys used in sorting/filtering (matching frontend state/UI)
export type ProductMetricColumnKey = keyof Omit<ProductMetric, 'pid'> | 'pid' | 'status';
// Mapping frontend keys to backend query param keys
export const FRONTEND_TO_BACKEND_KEY_MAP: Record<string, string> = {
pid: 'pid',
sku: 'sku',
title: 'title',
brand: 'brand',
vendor: 'vendor',
imageUrl: 'imageUrl',
isVisible: 'isVisible',
isReplenishable: 'isReplenishable',
currentPrice: 'currentPrice',
currentRegularPrice: 'currentRegularPrice',
currentCostPrice: 'currentCostPrice',
currentLandingCostPrice: 'currentLandingCostPrice',
currentStock: 'currentStock',
currentStockCost: 'currentStockCost',
currentStockRetail: 'currentStockRetail',
currentStockGross: 'currentStockGross',
onOrderQty: 'onOrderQty',
onOrderCost: 'onOrderCost',
onOrderRetail: 'onOrderRetail',
earliestExpectedDate: 'earliestExpectedDate',
dateCreated: 'dateCreated',
dateFirstReceived: 'dateFirstReceived',
dateLastReceived: 'dateLastReceived',
dateFirstSold: 'dateFirstSold',
dateLastSold: 'dateLastSold',
ageDays: 'ageDays',
sales7d: 'sales7d',
revenue7d: 'revenue7d',
sales14d: 'sales14d',
revenue14d: 'revenue14d',
sales30d: 'sales30d',
revenue30d: 'revenue30d',
cogs30d: 'cogs30d',
profit30d: 'profit30d',
stockoutDays30d: 'stockoutDays30d',
sales365d: 'sales365d',
revenue365d: 'revenue365d',
avgStockUnits30d: 'avgStockUnits30d',
avgStockCost30d: 'avgStockCost30d',
receivedQty30d: 'receivedQty30d',
receivedCost30d: 'receivedCost30d',
asp30d: 'asp30d',
acp30d: 'acp30d',
margin30d: 'margin30d',
gmroi30d: 'gmroi30d',
stockturn30d: 'stockturn30d',
sellThrough30d: 'sellThrough30d',
avgLeadTimeDays: 'avgLeadTimeDays',
abcClass: 'abcClass',
salesVelocityDaily: 'salesVelocityDaily',
configLeadTime: 'configLeadTime',
configDaysOfStock: 'configDaysOfStock',
stockCoverInDays: 'stockCoverInDays',
sellsOutInDays: 'sellsOutInDays',
replenishDate: 'replenishDate',
overstockedUnits: 'overstockedUnits',
overstockedCost: 'overstockedCost',
isOldStock: 'isOldStock',
yesterdaySales: 'yesterdaySales',
status: 'status' // Frontend-only field
};
// Function to get backend key safely
export function getBackendKey(frontendKey: string): string | null {
return FRONTEND_TO_BACKEND_KEY_MAP[frontendKey] || null;
}

View File

@@ -0,0 +1,134 @@
import { ProductMetric, ProductStatus } from "@/types/products";
//Calculates the product status based on various metrics
export function getProductStatus(product: ProductMetric): ProductStatus {
if (!product.isReplenishable) {
return "Healthy"; // Non-replenishable items default to Healthy
}
const {
currentStock,
stockCoverInDays,
sellsOutInDays,
overstockedUnits,
configLeadTime,
avgLeadTimeDays,
dateLastSold,
ageDays,
isOldStock
} = product;
const leadTime = configLeadTime ?? avgLeadTimeDays ?? 30; // Default lead time if none configured
const safetyThresholdDays = leadTime * 0.5; // Safety threshold is 50% of lead time
// Check for overstock first
if (overstockedUnits != null && overstockedUnits > 0) {
return "Overstock";
}
// Check for critical stock
if (stockCoverInDays != null) {
// Stock is <= 0 or very low compared to lead time
if (currentStock <= 0 || stockCoverInDays <= 0) {
return "Critical";
}
if (stockCoverInDays < safetyThresholdDays) {
return "Critical";
}
}
// Check for products that will need reordering soon
if (sellsOutInDays != null && sellsOutInDays < (leadTime + 7)) { // Within lead time + 1 week
// If also critically low, keep Critical status
if (stockCoverInDays != null && stockCoverInDays < safetyThresholdDays) {
return "Critical";
}
return "Reorder Soon";
}
// Check for 'At Risk' - e.g., old stock or hasn't sold in a long time
const ninetyDaysAgo = new Date();
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
if (isOldStock) {
return "At Risk";
}
if (dateLastSold && new Date(dateLastSold) < ninetyDaysAgo && (ageDays ?? 0) > 180) {
return "At Risk";
}
// Very high stock cover (more than a year) is at risk too
if (stockCoverInDays != null && stockCoverInDays > 365) {
return "At Risk";
}
// If none of the above, assume Healthy
return "Healthy";
}
//Returns a Badge component HTML string for a given product status
export function getStatusBadge(status: ProductStatus): string {
switch (status) {
case 'Critical':
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-red-600 text-white">Critical</div>';
case 'Reorder Soon':
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-secondary bg-yellow-500 text-black">Reorder Soon</div>';
case 'Healthy':
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-green-600 text-white">Healthy</div>';
case 'Overstock':
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-secondary bg-blue-600 text-white">Overstock</div>';
case 'At Risk':
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-orange-500 text-orange-600">At Risk</div>';
default:
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">Unknown</div>';
}
}
//Formatting utilities for displaying metrics
export const formatCurrency = (value: number | null | undefined, digits = 2): string => {
if (value == null) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(value);
};
export const formatNumber = (value: number | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
return value.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits
});
};
export const formatPercentage = (value: number | null | undefined, digits = 1): string => {
if (value == null) return 'N/A';
return `${value.toFixed(digits)}%`;
};
export const formatDays = (value: number | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
return `${value.toFixed(digits)} days`;
};
export const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return 'N/A';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (e) {
return 'Invalid Date';
}
};
export const formatBoolean = (value: boolean | null | undefined): string => {
if (value == null) return 'N/A';
return value ? 'Yes' : 'No';
};