Compare commits
3 Commits
a9dbbbf824
...
6051b849d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 6051b849d6 | |||
| dbd0232285 | |||
| 1b9f01d101 |
@@ -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
|
||||
|
||||
@@ -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 $$;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.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
|
||||
pc.cat_id,
|
||||
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,
|
||||
|
||||
@@ -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 <> ''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
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 (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,
|
||||
-- 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
|
||||
|
||||
281
inventory-server/src/routes/brandsAggregate.js
Normal file
281
inventory-server/src/routes/brandsAggregate.js
Normal 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;
|
||||
@@ -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;
|
||||
330
inventory-server/src/routes/categoriesAggregate.js
Normal file
330
inventory-server/src/routes/categoriesAggregate.js
Normal 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;
|
||||
@@ -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;
|
||||
320
inventory-server/src/routes/vendorsAggregate.js
Normal file
320
inventory-server/src/routes/vendorsAggregate.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
35
inventory-server/src/utils/apiHelpers.js
Normal file
35
inventory-server/src/utils/apiHelpers.js
Normal 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 };
|
||||
20
inventory/package-lock.json
generated
20
inventory/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
7
inventory/src/components/config.ts
Normal file
7
inventory/src/components/config.ts
Normal 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;
|
||||
@@ -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
@@ -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,18 +75,30 @@ 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 (
|
||||
<TableHead
|
||||
<TableHead
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"cursor-pointer select-none",
|
||||
"cursor-pointer select-none group",
|
||||
columnDef?.width,
|
||||
sortColumn === column && "bg-accent/50"
|
||||
)}
|
||||
@@ -95,7 +109,7 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
|
||||
<div className="flex items-center gap-1">
|
||||
{columnDef?.label ?? column}
|
||||
{sortColumn === column && (
|
||||
sortDirection === 'desc'
|
||||
sortDirection === 'desc'
|
||||
? <SortDesc className="h-4 w-4 min-w-4" />
|
||||
: <SortAsc className="h-4 w-4 min-w-4" />
|
||||
)}
|
||||
@@ -104,206 +118,113 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductTable({
|
||||
products,
|
||||
onSort,
|
||||
sortColumn,
|
||||
export function ProductTable({
|
||||
products,
|
||||
onSort,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
visibleColumns,
|
||||
columnDefs,
|
||||
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);
|
||||
|
||||
const newOrder = arrayMove(orderedColumns, oldIndex, newIndex);
|
||||
onColumnOrderChange?.(newOrder);
|
||||
if (over && active.id !== over.id && onColumnOrderChange) {
|
||||
const oldIndex = orderedVisibleColumns.indexOf(active.id as ProductMetricColumnKey);
|
||||
const newIndex = orderedVisibleColumns.indexOf(over.id as ProductMetricColumnKey);
|
||||
|
||||
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}`;
|
||||
}
|
||||
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 ?? '-';
|
||||
|
||||
// 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 '-';
|
||||
}
|
||||
|
||||
// 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) => (
|
||||
<TableRow
|
||||
key={product.pid}
|
||||
onClick={() => onRowClick?.(product)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{orderedColumns.map((column) => (
|
||||
<TableCell key={`${product.pid}-${column}`}>
|
||||
{formatColumnValue(product, column)}
|
||||
{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 hover:bg-muted/50"
|
||||
data-state={isLoading ? 'loading' : undefined}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
{!products.length && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={orderedColumns.length}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
No products found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
239
inventory/src/components/products/Products.tsx
Normal file
239
inventory/src/components/products/Products.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
459
inventory/src/pages/Brands.tsx
Normal file
459
inventory/src/pages/Brands.tsx
Normal 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
@@ -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}
|
||||
|
||||
@@ -1,350 +1,481 @@
|
||||
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;
|
||||
status: string;
|
||||
avg_lead_time_days: number;
|
||||
on_time_delivery_rate: number;
|
||||
order_fill_rate: number;
|
||||
total_orders: number;
|
||||
active_products: number;
|
||||
avg_unit_cost: number;
|
||||
total_spend: number;
|
||||
// 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;
|
||||
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;
|
||||
search: string;
|
||||
status: 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 [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const [filters, setFilters] = useState<VendorFilters>({
|
||||
search: "",
|
||||
status: "all",
|
||||
performance: "all",
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["vendors"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/vendors`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch vendors");
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
const [page, setPage] = useState(1);
|
||||
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",
|
||||
showInactive: false, // Default to hiding vendors with 0 active products
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [data?.vendors, filters, sortColumn, sortDirection]);
|
||||
// --- Data Fetching ---
|
||||
|
||||
// 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 queryParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', page.toString());
|
||||
params.set('limit', limit.toString());
|
||||
params.set('sort', sortColumn);
|
||||
params.set('order', sortDirection);
|
||||
|
||||
const handleSort = (column: keyof Vendor) => {
|
||||
setSortDirection(prev => {
|
||||
if (sortColumn !== column) return "asc";
|
||||
return prev === "asc" ? "desc" : "asc";
|
||||
});
|
||||
setSortColumn(column);
|
||||
};
|
||||
|
||||
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>;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
transition={{
|
||||
layout: {
|
||||
duration: 0.15,
|
||||
ease: [0.4, 0, 0.2, 1]
|
||||
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
|
||||
}
|
||||
}}
|
||||
className="container mx-auto py-6 space-y-4"
|
||||
>
|
||||
<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
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data?.stats?.activeVendors ?? "..."} active
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
return params;
|
||||
}, [page, limit, sortColumn, sortDirection, filters]);
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Spend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
${typeof data?.stats?.totalSpend === 'number' ? data.stats.totalSpend.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }) : "..."}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Avg unit cost: ${typeof data?.stats?.avgUnitCost === 'number' ? data.stats.avgUnitCost.toFixed(2) : "..."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
const { data: listData, isLoading: isLoadingList, error: listError } = useQuery<VendorResponse, Error>({
|
||||
queryKey: ['vendors', queryParams.toString()],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/vendors-aggregate?${queryParams.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
|
||||
return response.json();
|
||||
},
|
||||
placeholderData: (prev) => prev, // Modern replacement for keepPreviousData
|
||||
});
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Performance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{typeof data?.stats?.avgFillRate === 'number' ? data.stats.avgFillRate.toFixed(1) : "..."}%</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fill rate / {typeof data?.stats?.avgOnTimeDelivery === 'number' ? data.stats.avgOnTimeDelivery.toFixed(1) : "..."}% on-time
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Lead Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{typeof data?.stats?.avgLeadTime === 'number' ? data.stats.avgLeadTime.toFixed(1) : "..."} days</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Average delivery time
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
// 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'
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch filter options");
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center 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]"
|
||||
/>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[150px]">
|
||||
<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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
// --- Event Handlers ---
|
||||
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8">
|
||||
Loading vendors...
|
||||
</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 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
No vendors found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
const handleSort = useCallback((column: VendorSortableColumns) => {
|
||||
setSortDirection(prev => (sortColumn === column && prev === "asc" ? "desc" : "asc"));
|
||||
setSortColumn(column);
|
||||
setPage(1);
|
||||
}, [sortColumn]);
|
||||
|
||||
{totalPages > 1 && (
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ duration: 0.15 }}
|
||||
className="flex justify-center"
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 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] } }}
|
||||
className="container mx-auto py-6 space-y-4"
|
||||
>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (page > 1) setPage(p => p - 1);
|
||||
}}
|
||||
aria-disabled={page === 1}
|
||||
{/* 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">
|
||||
{isLoadingList && !pagination ? 'Loading...' : `${formatNumber(pagination?.total)} vendors`}
|
||||
</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 Vendors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalVendors)}</div>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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 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">Value On Order</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-28" /> : <div className="text-2xl font-bold">{formatCurrency(statsData?.totalOnOrderValue)}</div>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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">Avg Lead Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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 across vendors
|
||||
</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 vendors..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
className="w-full sm:w-[250px]"
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }, (_, i) => (
|
||||
<PaginationItem key={i + 1}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setPage(i + 1);
|
||||
}}
|
||||
isActive={page === i + 1}
|
||||
>
|
||||
{i + 1}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (page < totalPages) setPage(p => p + 1);
|
||||
}}
|
||||
aria-disabled={page >= totalPages}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
<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-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("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>
|
||||
{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={11} className="text-center py-8 text-destructive">
|
||||
Error loading vendors: {listError.message}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : vendors.length === 0 ? (
|
||||
<TableRow>
|
||||
<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>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && pagination && (
|
||||
<div 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>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
export default Vendors;
|
||||
export default Vendors;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
134
inventory/src/utils/productUtils.ts
Normal file
134
inventory/src/utils/productUtils.ts
Normal 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';
|
||||
};
|
||||
Reference in New Issue
Block a user