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 ---
|
// --- Configuration ---
|
||||||
// Toggle these constants to enable/disable specific steps for testing
|
// Toggle these constants to enable/disable specific steps for testing
|
||||||
const RUN_DAILY_SNAPSHOTS = true;
|
const RUN_DAILY_SNAPSHOTS = false;
|
||||||
const RUN_PRODUCT_METRICS = true;
|
const RUN_PRODUCT_METRICS = false;
|
||||||
const RUN_PERIODIC_METRICS = true;
|
const RUN_PERIODIC_METRICS = false;
|
||||||
const RUN_BRAND_METRICS = true;
|
const RUN_BRAND_METRICS = true;
|
||||||
const RUN_VENDOR_METRICS = true;
|
const RUN_VENDOR_METRICS = true;
|
||||||
const RUN_CATEGORY_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,
|
start_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
end_time TIMESTAMP WITH TIME ZONE,
|
end_time TIMESTAMP WITH TIME ZONE,
|
||||||
duration_seconds INTEGER,
|
duration_seconds INTEGER,
|
||||||
status TEXT, -- 'running', 'completed', 'failed', 'cancelled'
|
status TEXT, -- Will be altered to enum if needed below
|
||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
additional_info JSONB
|
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
|
// Mark previous runs of this type as cancelled
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
UPDATE calculate_history
|
UPDATE calculate_history
|
||||||
SET
|
SET
|
||||||
status = 'cancelled',
|
status = 'cancelled'::calculation_status,
|
||||||
end_time = NOW(),
|
end_time = NOW(),
|
||||||
duration_seconds = EXTRACT(EPOCH FROM (NOW() - start_time))::INTEGER,
|
duration_seconds = EXTRACT(EPOCH FROM (NOW() - start_time))::INTEGER,
|
||||||
error_message = 'Previous calculation was not completed properly or was superseded.'
|
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]);
|
`, [config.historyType]);
|
||||||
|
|
||||||
// Create history record for this run
|
// Create history record for this run
|
||||||
const historyResult = await connection.query(`
|
const historyResult = await connection.query(`
|
||||||
INSERT INTO calculate_history (status, additional_info)
|
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;
|
RETURNING id;
|
||||||
`, [config.historyType, config.sqlFile]);
|
`, [config.historyType, config.sqlFile]);
|
||||||
calculateHistoryId = historyResult.rows[0].id;
|
calculateHistoryId = historyResult.rows[0].id;
|
||||||
@@ -502,7 +518,7 @@ async function executeSqlStep(config, progress) {
|
|||||||
SET
|
SET
|
||||||
end_time = NOW(),
|
end_time = NOW(),
|
||||||
duration_seconds = $1::integer,
|
duration_seconds = $1::integer,
|
||||||
status = 'completed'
|
status = 'completed'::calculation_status
|
||||||
WHERE id = $2::integer;
|
WHERE id = $2::integer;
|
||||||
`, [stepDuration, calculateHistoryId]);
|
`, [stepDuration, calculateHistoryId]);
|
||||||
|
|
||||||
@@ -551,7 +567,7 @@ async function executeSqlStep(config, progress) {
|
|||||||
SET
|
SET
|
||||||
end_time = NOW(),
|
end_time = NOW(),
|
||||||
duration_seconds = $1::integer,
|
duration_seconds = $1::integer,
|
||||||
status = $2::text,
|
status = $2::calculation_status,
|
||||||
error_message = $3::text
|
error_message = $3::text
|
||||||
WHERE id = $4::integer;
|
WHERE id = $4::integer;
|
||||||
`, [errorDuration, finalStatus, errorMessage.substring(0, 1000), calculateHistoryId]); // Limit error message size
|
`, [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
|
DECLARE
|
||||||
_module_name VARCHAR := 'brand_metrics';
|
_module_name VARCHAR := 'brand_metrics';
|
||||||
_start_time TIMESTAMPTZ := clock_timestamp();
|
_start_time TIMESTAMPTZ := clock_timestamp();
|
||||||
|
_min_revenue NUMERIC := 50.00; -- Minimum revenue threshold for margin calculation
|
||||||
BEGIN
|
BEGIN
|
||||||
RAISE NOTICE 'Running % calculation...', _module_name;
|
RAISE NOTICE 'Running % calculation...', _module_name;
|
||||||
|
|
||||||
@@ -19,14 +20,26 @@ BEGIN
|
|||||||
SUM(pm.current_stock) AS current_stock_units,
|
SUM(pm.current_stock) AS current_stock_units,
|
||||||
SUM(pm.current_stock_cost) AS current_stock_cost,
|
SUM(pm.current_stock_cost) AS current_stock_cost,
|
||||||
SUM(pm.current_stock_retail) AS current_stock_retail,
|
SUM(pm.current_stock_retail) AS current_stock_retail,
|
||||||
SUM(pm.sales_7d) AS sales_7d, SUM(pm.revenue_7d) AS revenue_7d,
|
-- Only include products with valid sales data in each time period
|
||||||
SUM(pm.sales_30d) AS sales_30d, SUM(pm.revenue_30d) AS revenue_30d,
|
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
|
||||||
SUM(pm.profit_30d) AS profit_30d, SUM(pm.cogs_30d) AS cogs_30d,
|
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
||||||
SUM(pm.sales_365d) AS sales_365d, SUM(pm.revenue_365d) AS revenue_365d,
|
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
||||||
SUM(pm.lifetime_sales) AS lifetime_sales, SUM(pm.lifetime_revenue) AS lifetime_revenue
|
|
||||||
|
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
|
FROM public.product_metrics pm
|
||||||
JOIN public.products p ON pm.pid = p.pid
|
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
|
GROUP BY brand_group
|
||||||
),
|
),
|
||||||
AllBrands AS (
|
AllBrands AS (
|
||||||
@@ -58,8 +71,14 @@ BEGIN
|
|||||||
COALESCE(ba.profit_30d, 0.00), COALESCE(ba.cogs_30d, 0.00),
|
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.sales_365d, 0), COALESCE(ba.revenue_365d, 0.00),
|
||||||
COALESCE(ba.lifetime_sales, 0), COALESCE(ba.lifetime_revenue, 0.00),
|
COALESCE(ba.lifetime_sales, 0), COALESCE(ba.lifetime_revenue, 0.00),
|
||||||
-- KPIs
|
-- KPIs - Calculate margin only for brands with significant revenue
|
||||||
(ba.profit_30d / NULLIF(ba.revenue_30d, 0)) * 100.0
|
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
|
FROM AllBrands b
|
||||||
LEFT JOIN BrandAggregates ba ON b.brand_group = ba.brand_group
|
LEFT JOIN BrandAggregates ba ON b.brand_group = ba.brand_group
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,44 @@ DECLARE
|
|||||||
BEGIN
|
BEGIN
|
||||||
RAISE NOTICE 'Running % calculation...', _module_name;
|
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
|
SELECT
|
||||||
pc.cat_id,
|
pc.pid,
|
||||||
|
pc.cat_id
|
||||||
|
FROM public.product_categories pc
|
||||||
|
JOIN CategoryDepth cd ON pc.cat_id = cd.cat_id
|
||||||
|
-- This is the key part: for each product, select only the category with maximum depth
|
||||||
|
WHERE (pc.pid, cd.depth) IN (
|
||||||
|
SELECT pc2.pid, MAX(cd2.depth)
|
||||||
|
FROM public.product_categories pc2
|
||||||
|
JOIN CategoryDepth cd2 ON pc2.cat_id = cd2.cat_id
|
||||||
|
GROUP BY pc2.pid
|
||||||
|
)
|
||||||
|
),
|
||||||
|
-- Calculate metrics only at the most specific category level for each product
|
||||||
|
CategoryAggregates AS (
|
||||||
|
SELECT
|
||||||
|
pdc.cat_id,
|
||||||
-- Counts
|
-- Counts
|
||||||
COUNT(DISTINCT pm.pid) AS product_count,
|
COUNT(DISTINCT pm.pid) AS product_count,
|
||||||
COUNT(DISTINCT CASE WHEN pm.is_visible THEN pm.pid END) AS active_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) AS current_stock_units,
|
||||||
SUM(pm.current_stock_cost) AS current_stock_cost,
|
SUM(pm.current_stock_cost) AS current_stock_cost,
|
||||||
SUM(pm.current_stock_retail) AS current_stock_retail,
|
SUM(pm.current_stock_retail) AS current_stock_retail,
|
||||||
-- Rolling Periods (Sum directly from product_metrics)
|
-- Rolling Periods - Only include products with actual sales in each period
|
||||||
SUM(pm.sales_7d) AS sales_7d, SUM(pm.revenue_7d) AS revenue_7d,
|
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
||||||
SUM(pm.sales_30d) AS sales_30d, SUM(pm.revenue_30d) AS revenue_30d,
|
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
||||||
SUM(pm.profit_30d) AS profit_30d, SUM(pm.cogs_30d) AS cogs_30d,
|
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
||||||
SUM(pm.sales_365d) AS sales_365d, SUM(pm.revenue_365d) AS revenue_365d,
|
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
||||||
SUM(pm.lifetime_sales) AS lifetime_sales, SUM(pm.lifetime_revenue) AS lifetime_revenue,
|
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
|
||||||
-- Data for KPIs
|
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
|
||||||
SUM(pm.avg_stock_units_30d) AS total_avg_stock_units_30d -- Sum of averages (use cautiously)
|
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
|
FROM public.product_metrics pm
|
||||||
JOIN public.product_categories pc ON pm.pid = pc.pid
|
JOIN ProductDeepestCategory pdc ON pm.pid = pdc.pid
|
||||||
-- Optional: JOIN products p ON pm.pid = p.pid if needed for filtering (e.g., only visible products)
|
GROUP BY pdc.cat_id
|
||||||
-- WHERE p.visible = true -- Example filter
|
),
|
||||||
GROUP BY pc.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 (
|
INSERT INTO public.category_metrics (
|
||||||
category_id, category_name, category_type, parent_id, last_calculated,
|
category_id, category_name, category_type, parent_id, last_calculated,
|
||||||
@@ -49,22 +139,22 @@ BEGIN
|
|||||||
c.parent_id,
|
c.parent_id,
|
||||||
_start_time,
|
_start_time,
|
||||||
-- Base Aggregates
|
-- Base Aggregates
|
||||||
COALESCE(ca.product_count, 0),
|
COALESCE(rm.product_count, 0),
|
||||||
COALESCE(ca.active_product_count, 0),
|
COALESCE(rm.active_product_count, 0),
|
||||||
COALESCE(ca.replenishable_product_count, 0),
|
COALESCE(rm.replenishable_product_count, 0),
|
||||||
COALESCE(ca.current_stock_units, 0),
|
COALESCE(rm.current_stock_units, 0),
|
||||||
COALESCE(ca.current_stock_cost, 0.00),
|
COALESCE(rm.current_stock_cost, 0.00),
|
||||||
COALESCE(ca.current_stock_retail, 0.00),
|
COALESCE(rm.current_stock_retail, 0.00),
|
||||||
COALESCE(ca.sales_7d, 0), COALESCE(ca.revenue_7d, 0.00),
|
COALESCE(rm.sales_7d, 0), COALESCE(rm.revenue_7d, 0.00),
|
||||||
COALESCE(ca.sales_30d, 0), COALESCE(ca.revenue_30d, 0.00),
|
COALESCE(rm.sales_30d, 0), COALESCE(rm.revenue_30d, 0.00),
|
||||||
COALESCE(ca.profit_30d, 0.00), COALESCE(ca.cogs_30d, 0.00),
|
COALESCE(rm.profit_30d, 0.00), COALESCE(rm.cogs_30d, 0.00),
|
||||||
COALESCE(ca.sales_365d, 0), COALESCE(ca.revenue_365d, 0.00),
|
COALESCE(rm.sales_365d, 0), COALESCE(rm.revenue_365d, 0.00),
|
||||||
COALESCE(ca.lifetime_sales, 0), COALESCE(ca.lifetime_revenue, 0.00),
|
COALESCE(rm.lifetime_sales, 0), COALESCE(rm.lifetime_revenue, 0.00),
|
||||||
-- KPIs
|
-- KPIs
|
||||||
(ca.profit_30d / NULLIF(ca.revenue_30d, 0)) * 100.0,
|
(rm.profit_30d / NULLIF(rm.revenue_30d, 0)) * 100.0,
|
||||||
ca.sales_30d / NULLIF(ca.total_avg_stock_units_30d, 0) -- Simple unit-based turnover
|
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
|
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
|
ON CONFLICT (category_id) DO UPDATE SET
|
||||||
category_name = EXCLUDED.category_name,
|
category_name = EXCLUDED.category_name,
|
||||||
|
|||||||
@@ -21,11 +21,24 @@ BEGIN
|
|||||||
SUM(pm.current_stock_retail) AS current_stock_retail,
|
SUM(pm.current_stock_retail) AS current_stock_retail,
|
||||||
SUM(pm.on_order_qty) AS on_order_units,
|
SUM(pm.on_order_qty) AS on_order_units,
|
||||||
SUM(pm.on_order_cost) AS on_order_cost,
|
SUM(pm.on_order_cost) AS on_order_cost,
|
||||||
SUM(pm.sales_7d) AS sales_7d, SUM(pm.revenue_7d) AS revenue_7d,
|
-- Only include products with valid sales data in each time period
|
||||||
SUM(pm.sales_30d) AS sales_30d, SUM(pm.revenue_30d) AS revenue_30d,
|
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
|
||||||
SUM(pm.profit_30d) AS profit_30d, SUM(pm.cogs_30d) AS cogs_30d,
|
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
||||||
SUM(pm.sales_365d) AS sales_365d, SUM(pm.revenue_365d) AS revenue_365d,
|
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
||||||
SUM(pm.lifetime_sales) AS lifetime_sales, SUM(pm.lifetime_revenue) AS lifetime_revenue
|
|
||||||
|
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
|
FROM public.product_metrics pm
|
||||||
JOIN public.products p ON pm.pid = p.pid
|
JOIN public.products p ON pm.pid = p.pid
|
||||||
WHERE p.vendor IS NOT NULL AND p.vendor <> ''
|
WHERE p.vendor IS NOT NULL AND p.vendor <> ''
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ DECLARE
|
|||||||
_start_time TIMESTAMPTZ := clock_timestamp(); -- Time execution started
|
_start_time TIMESTAMPTZ := clock_timestamp(); -- Time execution started
|
||||||
_last_calc_time TIMESTAMPTZ;
|
_last_calc_time TIMESTAMPTZ;
|
||||||
_target_date DATE := CURRENT_DATE; -- Always recalculate today for simplicity with hourly runs
|
_target_date DATE := CURRENT_DATE; -- Always recalculate today for simplicity with hourly runs
|
||||||
|
_total_records INT := 0;
|
||||||
|
_has_orders BOOLEAN := FALSE;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get the timestamp before the last successful run of this module
|
-- Get the timestamp before the last successful run of this module
|
||||||
SELECT last_calculation_timestamp INTO _last_calc_time
|
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;
|
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 (
|
WITH SalesData AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.pid,
|
p.pid,
|
||||||
p.sku,
|
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)
|
-- 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.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
|
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
|
ON p.pid = o.pid
|
||||||
AND o.date::date = _target_date -- Cast to date to ensure compatibility regardless of original type
|
AND o.date::date = _target_date -- Cast to date to ensure compatibility regardless of original type
|
||||||
GROUP BY p.pid, p.sku
|
GROUP BY p.pid, p.sku
|
||||||
|
HAVING COUNT(o.id) > 0 -- CRITICAL: Only include products with actual orders
|
||||||
),
|
),
|
||||||
ReceivingData AS (
|
ReceivingData AS (
|
||||||
SELECT
|
SELECT
|
||||||
po.pid,
|
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
|
-- Prioritize the actual table fields over the JSON data
|
||||||
COALESCE(
|
COALESCE(
|
||||||
-- First try the received field from purchase_orders table
|
-- First try the received field from purchase_orders table
|
||||||
@@ -89,6 +131,15 @@ BEGIN
|
|||||||
jsonb_typeof(po.receiving_history) = 'array' AND
|
jsonb_typeof(po.receiving_history) = 'array' AND
|
||||||
jsonb_array_length(po.receiving_history) > 0
|
jsonb_array_length(po.receiving_history) > 0
|
||||||
GROUP BY po.pid
|
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 (
|
CurrentStock AS (
|
||||||
-- Select current stock values directly from products table
|
-- Select current stock values directly from products table
|
||||||
@@ -100,7 +151,7 @@ BEGIN
|
|||||||
COALESCE(regular_price, 0.00) as current_regular_price
|
COALESCE(regular_price, 0.00) as current_regular_price
|
||||||
FROM public.products
|
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 (
|
INSERT INTO public.daily_product_snapshots (
|
||||||
snapshot_date,
|
snapshot_date,
|
||||||
pid,
|
pid,
|
||||||
@@ -125,8 +176,8 @@ BEGIN
|
|||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
_target_date AS snapshot_date,
|
_target_date AS snapshot_date,
|
||||||
p.pid,
|
COALESCE(sd.pid, rd.pid) AS pid, -- Use sales or receiving PID
|
||||||
p.sku,
|
COALESCE(sd.sku, p.sku) AS sku, -- Get SKU from sales data or products table
|
||||||
-- Inventory Metrics (Using CurrentStock)
|
-- Inventory Metrics (Using CurrentStock)
|
||||||
cs.stock_quantity AS eod_stock_quantity,
|
cs.stock_quantity AS eod_stock_quantity,
|
||||||
cs.stock_quantity * cs.effective_cost_price AS eod_stock_cost,
|
cs.stock_quantity * cs.effective_cost_price AS eod_stock_cost,
|
||||||
@@ -147,31 +198,15 @@ BEGIN
|
|||||||
COALESCE(rd.units_received, 0),
|
COALESCE(rd.units_received, 0),
|
||||||
COALESCE(rd.cost_received, 0.00),
|
COALESCE(rd.cost_received, 0.00),
|
||||||
_start_time -- Timestamp of this calculation run
|
_start_time -- Timestamp of this calculation run
|
||||||
FROM public.products p
|
FROM SalesData sd
|
||||||
LEFT JOIN CurrentStock cs ON p.pid = cs.pid
|
FULL OUTER JOIN ReceivingData rd ON sd.pid = rd.pid
|
||||||
LEFT JOIN SalesData sd ON p.pid = sd.pid
|
LEFT JOIN public.products p ON COALESCE(sd.pid, rd.pid) = p.pid
|
||||||
LEFT JOIN ReceivingData rd ON p.pid = rd.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
|
WHERE p.pid IS NOT NULL; -- Ensure we only insert for existing products
|
||||||
|
|
||||||
ON CONFLICT (snapshot_date, pid) DO UPDATE SET
|
-- Get the total number of records inserted
|
||||||
sku = EXCLUDED.sku,
|
GET DIAGNOSTICS _total_records = ROW_COUNT;
|
||||||
eod_stock_quantity = EXCLUDED.eod_stock_quantity,
|
RAISE NOTICE 'Created % daily snapshot records for % with sales/receiving activity', _total_records, _target_date;
|
||||||
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
|
|
||||||
|
|
||||||
-- Update the status table with the timestamp from the START of this run
|
-- Update the status table with the timestamp from the START of this run
|
||||||
UPDATE public.calculate_status
|
UPDATE public.calculate_status
|
||||||
|
|||||||
@@ -110,31 +110,37 @@ BEGIN
|
|||||||
SUM(units_sold) AS total_units_sold,
|
SUM(units_sold) AS total_units_sold,
|
||||||
SUM(net_revenue) AS total_net_revenue,
|
SUM(net_revenue) AS total_net_revenue,
|
||||||
|
|
||||||
-- Specific time windows if we have enough data
|
-- Specific time windows using date range boundaries precisely
|
||||||
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '6 days' THEN units_sold ELSE 0 END) AS sales_7d,
|
-- Use _current_date - INTERVAL '6 days' to include 7 days (today + 6 previous days)
|
||||||
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '6 days' THEN net_revenue ELSE 0 END) AS revenue_7d,
|
-- This ensures we count exactly the right number of days in each period
|
||||||
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 '6 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_7d,
|
||||||
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 '6 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_7d,
|
||||||
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,
|
|
||||||
|
|
||||||
-- Averages (check for NULLIF 0 days in period if filtering dates)
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '13 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_14d,
|
||||||
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN eod_stock_quantity END) AS avg_stock_units_30d,
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '13 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_14d,
|
||||||
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,
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_30d,
|
||||||
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN eod_stock_gross END) AS avg_stock_gross_30d,
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_30d,
|
||||||
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN cogs ELSE 0 END) AS cogs_30d,
|
||||||
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN profit ELSE 0 END) AS profit_30d,
|
||||||
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN units_returned ELSE 0 END) AS returns_units_30d,
|
||||||
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN returns_revenue ELSE 0 END) AS returns_revenue_30d,
|
||||||
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN discounts ELSE 0 END) AS discounts_30d,
|
||||||
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN gross_revenue ELSE 0 END) AS gross_revenue_30d,
|
||||||
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN gross_regular_revenue ELSE 0 END) AS gross_regular_revenue_30d,
|
||||||
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date AND stockout_flag THEN 1 ELSE 0 END) AS stockout_days_30d,
|
||||||
|
|
||||||
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '364 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_365d,
|
||||||
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '364 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_365d,
|
||||||
|
|
||||||
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN units_received ELSE 0 END) AS received_qty_30d,
|
||||||
|
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN cost_received ELSE 0 END) AS received_cost_30d,
|
||||||
|
|
||||||
|
-- Averages for stock levels - only include dates within the specified period
|
||||||
|
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_quantity END) AS avg_stock_units_30d,
|
||||||
|
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_cost END) AS avg_stock_cost_30d,
|
||||||
|
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_retail END) AS avg_stock_retail_30d,
|
||||||
|
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_gross END) AS avg_stock_gross_30d,
|
||||||
|
|
||||||
-- Lifetime - should match total values above
|
-- Lifetime - should match total values above
|
||||||
SUM(units_sold) AS lifetime_sales,
|
SUM(units_sold) AS lifetime_sales,
|
||||||
@@ -150,14 +156,14 @@ BEGIN
|
|||||||
SELECT
|
SELECT
|
||||||
pid,
|
pid,
|
||||||
date_first_sold,
|
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 >= 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 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 >= 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 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 >= 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 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 >= 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 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 >= 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 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 >= 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 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 >= 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 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 '89 days' THEN net_revenue ELSE 0 END) AS first_90_days_revenue
|
||||||
FROM public.daily_product_snapshots ds
|
FROM public.daily_product_snapshots ds
|
||||||
JOIN HistoricalDates hd USING(pid)
|
JOIN HistoricalDates hd USING(pid)
|
||||||
WHERE date_first_sold IS NOT NULL
|
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,
|
(sa.sales_30d / NULLIF(ci.current_stock + sa.sales_30d, 0)) * 100 AS sell_through_30d,
|
||||||
|
|
||||||
-- Forecasting intermediate values
|
-- 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_lead_time AS config_lead_time,
|
||||||
s.effective_days_of_stock AS config_days_of_stock,
|
s.effective_days_of_stock AS config_days_of_stock,
|
||||||
s.effective_safety_stock AS config_safety_stock,
|
s.effective_safety_stock AS config_safety_stock,
|
||||||
(s.effective_lead_time + s.effective_days_of_stock) AS planning_period_days,
|
(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,
|
-- Apply the same fix to all derived calculations
|
||||||
((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,
|
(sa.sales_30d /
|
||||||
(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,
|
NULLIF(
|
||||||
((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,
|
GREATEST(
|
||||||
(((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,
|
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.)
|
-- Final Forecasting / Replenishment Metrics (apply CEILING/GREATEST/etc.)
|
||||||
-- Note: These calculations are nested for clarity, can be simplified in prod
|
-- 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 /
|
||||||
(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,
|
NULLIF(
|
||||||
(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,
|
GREATEST(
|
||||||
(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,
|
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)
|
-- 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 /
|
||||||
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,
|
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,
|
ci.current_stock / NULLIF((sa.sales_30d /
|
||||||
COALESCE(ooi.on_order_qty, 0) / NULLIF((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)), 0) AS po_cover_in_days,
|
NULLIF(
|
||||||
(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,
|
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
|
-- Replenish Date: Date when stock is projected to hit safety stock, minus lead time
|
||||||
CASE
|
CASE
|
||||||
WHEN (sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) > 0
|
WHEN (sa.sales_30d /
|
||||||
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
|
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
|
ELSE NULL
|
||||||
END AS replenish_date,
|
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 /
|
||||||
(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,
|
NULLIF(
|
||||||
(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(
|
||||||
|
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
|
-- Old Stock Flag
|
||||||
(ci.created_at::date < _current_date - INTERVAL '60 day') AND
|
(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 purchaseOrdersRouter = require('./routes/purchase-orders');
|
||||||
const configRouter = require('./routes/config');
|
const configRouter = require('./routes/config');
|
||||||
const metricsRouter = require('./routes/metrics');
|
const metricsRouter = require('./routes/metrics');
|
||||||
const vendorsRouter = require('./routes/vendors');
|
|
||||||
const categoriesRouter = require('./routes/categories');
|
|
||||||
const importRouter = require('./routes/import');
|
const importRouter = require('./routes/import');
|
||||||
const aiValidationRouter = require('./routes/ai-validation');
|
const aiValidationRouter = require('./routes/ai-validation');
|
||||||
const templatesRouter = require('./routes/templates');
|
const templatesRouter = require('./routes/templates');
|
||||||
const aiPromptsRouter = require('./routes/ai-prompts');
|
const aiPromptsRouter = require('./routes/ai-prompts');
|
||||||
const reusableImagesRouter = require('./routes/reusable-images');
|
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
|
// Get the absolute path to the .env file
|
||||||
const envPath = '/var/www/html/inventory/.env';
|
const envPath = '/var/www/html/inventory/.env';
|
||||||
@@ -100,8 +101,13 @@ async function startServer() {
|
|||||||
app.use('/api/purchase-orders', purchaseOrdersRouter);
|
app.use('/api/purchase-orders', purchaseOrdersRouter);
|
||||||
app.use('/api/config', configRouter);
|
app.use('/api/config', configRouter);
|
||||||
app.use('/api/metrics', metricsRouter);
|
app.use('/api/metrics', metricsRouter);
|
||||||
app.use('/api/vendors', vendorsRouter);
|
// Use only the aggregate routes for vendors and categories
|
||||||
app.use('/api/categories', categoriesRouter);
|
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/import', importRouter);
|
||||||
app.use('/api/ai-validation', aiValidationRouter);
|
app.use('/api/ai-validation', aiValidationRouter);
|
||||||
app.use('/api/templates', templatesRouter);
|
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-chartjs-2": "^5.3.0",
|
||||||
"react-data-grid": "^7.0.0-beta.13",
|
"react-data-grid": "^7.0.0-beta.13",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
|
"react-debounce-input": "^3.3.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-dropzone": "^14.3.5",
|
"react-dropzone": "^14.3.5",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
@@ -6043,6 +6044,12 @@
|
|||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"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"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"react-chartjs-2": "^5.3.0",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-data-grid": "^7.0.0-beta.13",
|
"react-data-grid": "^7.0.0-beta.13",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
|
"react-debounce-input": "^3.3.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-dropzone": "^14.3.5",
|
"react-dropzone": "^14.3.5",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { Import } from '@/pages/Import';
|
|||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { Protected } from './components/auth/Protected';
|
import { Protected } from './components/auth/Protected';
|
||||||
import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage';
|
import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage';
|
||||||
|
import { Brands } from '@/pages/Brands';
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -108,6 +108,11 @@ function App() {
|
|||||||
<Vendors />
|
<Vendors />
|
||||||
</Protected>
|
</Protected>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/brands" element={
|
||||||
|
<Protected page="brands">
|
||||||
|
<Brands />
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
<Route path="/purchase-orders" element={
|
<Route path="/purchase-orders" element={
|
||||||
<Protected page="purchase_orders">
|
<Protected page="purchase_orders">
|
||||||
<PurchaseOrders />
|
<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,
|
Settings,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
LogOut,
|
LogOut,
|
||||||
Users,
|
|
||||||
Tags,
|
Tags,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
|
ShoppingBag,
|
||||||
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -57,9 +58,15 @@ const items = [
|
|||||||
url: "/categories",
|
url: "/categories",
|
||||||
permission: "access:categories"
|
permission: "access:categories"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Brands",
|
||||||
|
icon: ShoppingBag,
|
||||||
|
url: "/brands",
|
||||||
|
permission: "access:brands"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Vendors",
|
title: "Vendors",
|
||||||
icon: Users,
|
icon: Truck,
|
||||||
url: "/vendors",
|
url: "/vendors",
|
||||||
permission: "access: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 * as React from "react";
|
||||||
import { SortAsc, SortDesc } from "lucide-react";
|
import { SortAsc, SortDesc } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -14,10 +13,11 @@ import {
|
|||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
MouseSensor,
|
PointerSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
@@ -26,36 +26,38 @@ import {
|
|||||||
useSortable,
|
useSortable,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { Product } from "@/types/products";
|
import { ProductMetric, ProductMetricColumnKey } from "@/types/products";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
export type ColumnKey = keyof Product | 'image';
|
import { getStatusBadge } from "@/utils/productUtils";
|
||||||
|
|
||||||
|
// Column definition
|
||||||
interface ColumnDef {
|
interface ColumnDef {
|
||||||
key: ColumnKey;
|
key: ProductMetricColumnKey;
|
||||||
label: string;
|
label: string;
|
||||||
group: string;
|
group: string;
|
||||||
format?: (value: any) => string | number;
|
|
||||||
width?: string;
|
|
||||||
noLabel?: boolean;
|
noLabel?: boolean;
|
||||||
|
width?: string;
|
||||||
|
format?: (value: any, product?: ProductMetric) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductTableProps {
|
interface ProductTableProps {
|
||||||
products: Product[];
|
products: ProductMetric[];
|
||||||
onSort: (column: ColumnKey) => void;
|
onSort: (column: ProductMetricColumnKey) => void;
|
||||||
sortColumn: ColumnKey;
|
sortColumn: ProductMetricColumnKey;
|
||||||
sortDirection: 'asc' | 'desc';
|
sortDirection: 'asc' | 'desc';
|
||||||
visibleColumns: Set<ColumnKey>;
|
visibleColumns: Set<ProductMetricColumnKey>;
|
||||||
columnDefs: ColumnDef[];
|
columnDefs: ColumnDef[];
|
||||||
columnOrder: ColumnKey[];
|
columnOrder: ProductMetricColumnKey[];
|
||||||
onColumnOrderChange?: (columns: ColumnKey[]) => void;
|
onColumnOrderChange?: (columns: ProductMetricColumnKey[]) => void;
|
||||||
onRowClick?: (product: Product) => void;
|
onRowClick?: (product: ProductMetric) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortableHeaderProps {
|
interface SortableHeaderProps {
|
||||||
column: ColumnKey;
|
column: ProductMetricColumnKey;
|
||||||
columnDef?: ColumnDef;
|
columnDef?: ColumnDef;
|
||||||
onSort: (column: ColumnKey) => void;
|
onSort: (column: ProductMetricColumnKey) => void;
|
||||||
sortColumn: ColumnKey;
|
sortColumn: ProductMetricColumnKey;
|
||||||
sortDirection: 'asc' | 'desc';
|
sortDirection: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,10 +75,22 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
|
|||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
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) {
|
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 (
|
return (
|
||||||
@@ -84,7 +98,7 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer select-none",
|
"cursor-pointer select-none group",
|
||||||
columnDef?.width,
|
columnDef?.width,
|
||||||
sortColumn === column && "bg-accent/50"
|
sortColumn === column && "bg-accent/50"
|
||||||
)}
|
)}
|
||||||
@@ -114,196 +128,103 @@ export function ProductTable({
|
|||||||
columnOrder = columnDefs.map(col => col.key),
|
columnOrder = columnDefs.map(col => col.key),
|
||||||
onColumnOrderChange,
|
onColumnOrderChange,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
|
isLoading = false,
|
||||||
}: ProductTableProps) {
|
}: ProductTableProps) {
|
||||||
const [, setActiveId] = React.useState<ColumnKey | null>(null);
|
const [activeId, setActiveId] = React.useState<ProductMetricColumnKey | null>(null);
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: { distance: 5 },
|
||||||
distance: 8,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
useSensor(TouchSensor, {
|
useSensor(TouchSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: { delay: 250, tolerance: 5 },
|
||||||
delay: 200,
|
|
||||||
tolerance: 8,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get ordered visible columns
|
// Filter columnOrder to only include visible columns for SortableContext
|
||||||
const orderedColumns = React.useMemo(() => {
|
const orderedVisibleColumns = React.useMemo(() => {
|
||||||
return columnOrder.filter(col => visibleColumns.has(col));
|
return columnOrder.filter(col => visibleColumns.has(col));
|
||||||
}, [columnOrder, visibleColumns]);
|
}, [columnOrder, visibleColumns]);
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
setActiveId(event.active.id as ColumnKey);
|
setActiveId(event.active.id as ProductMetricColumnKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
|
|
||||||
if (over && active.id !== over.id) {
|
if (over && active.id !== over.id && onColumnOrderChange) {
|
||||||
const oldIndex = orderedColumns.indexOf(active.id as ColumnKey);
|
const oldIndex = orderedVisibleColumns.indexOf(active.id as ProductMetricColumnKey);
|
||||||
const newIndex = orderedColumns.indexOf(over.id as ColumnKey);
|
const newIndex = orderedVisibleColumns.indexOf(over.id as ProductMetricColumnKey);
|
||||||
|
|
||||||
const newOrder = arrayMove(orderedColumns, oldIndex, newIndex);
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
onColumnOrderChange?.(newOrder);
|
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 (columnKey === 'status') {
|
||||||
if (!status) return null;
|
return <div dangerouslySetInnerHTML={{ __html: getStatusBadge(product.status || 'Unknown') }} />;
|
||||||
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>;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const getABCClass = (abcClass: string | undefined) => {
|
if (columnDef?.format) {
|
||||||
if (!abcClass) return null;
|
return columnDef.format(value, product);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const getLeadTimeStatus = (status: string | undefined) => {
|
// Default formatting for common types if no formatter provided
|
||||||
if (!status) return null;
|
if (typeof value === 'boolean') {
|
||||||
switch (status.toLowerCase()) {
|
return value ? 'Yes' : 'No';
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const formatColumnValue = (product: Product, column: ColumnKey) => {
|
// Handle date strings consistently
|
||||||
const columnDef = columnDefs.find(def => def.key === column);
|
if (value && typeof value === 'string' &&
|
||||||
let value: any = product[column as keyof Product];
|
(columnKey.toLowerCase().includes('date') || columnKey === 'replenishDate')) {
|
||||||
|
try {
|
||||||
switch (column) {
|
return new Date(value).toLocaleDateString();
|
||||||
case 'image':
|
} catch (e) {
|
||||||
return product.image ? (
|
return String(value);
|
||||||
<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 ?? '-';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to string conversion
|
||||||
|
return String(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={() => setActiveId(null)}
|
||||||
>
|
>
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border overflow-x-auto relative">
|
||||||
<Table>
|
{isLoading && (
|
||||||
<TableHeader>
|
<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>
|
<TableRow>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={orderedColumns}
|
items={orderedVisibleColumns}
|
||||||
strategy={horizontalListSortingStrategy}
|
strategy={horizontalListSortingStrategy}
|
||||||
>
|
>
|
||||||
{orderedColumns.map((column) => (
|
{orderedVisibleColumns.map((columnKey) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
key={column}
|
key={columnKey}
|
||||||
column={column}
|
column={columnKey}
|
||||||
columnDef={columnDefs.find(def => def.key === column)}
|
columnDef={columnDefs.find(def => def.key === columnKey)}
|
||||||
onSort={onSort}
|
onSort={onSort}
|
||||||
sortColumn={sortColumn}
|
sortColumn={sortColumn}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
@@ -313,29 +234,55 @@ export function ProductTable({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products.map((product) => (
|
{products.length === 0 && !isLoading ? (
|
||||||
<TableRow
|
<TableRow>
|
||||||
key={product.pid}
|
<TableCell
|
||||||
onClick={() => onRowClick?.(product)}
|
colSpan={orderedVisibleColumns.length}
|
||||||
className="cursor-pointer"
|
className="text-center py-8 text-muted-foreground"
|
||||||
>
|
>
|
||||||
{orderedColumns.map((column) => (
|
No products found matching your criteria.
|
||||||
<TableCell key={`${product.pid}-${column}`}>
|
</TableCell>
|
||||||
{formatColumnValue(product, column)}
|
</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>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{!products.length && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={orderedColumns.length}
|
|
||||||
className="text-center py-8 text-muted-foreground"
|
|
||||||
>
|
|
||||||
No products found
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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 { ProductDetail } from '@/components/products/ProductDetail';
|
||||||
import { ProductViews } from '@/components/products/ProductViews';
|
import { ProductViews } from '@/components/products/ProductViews';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Product } from '@/types/products';
|
import { Product, ProductMetric, ProductMetricColumnKey } from '@/types/products';
|
||||||
import type { ColumnKey } from '@/components/products/ProductTable';
|
import { getProductStatus } from '@/utils/productUtils';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
@@ -35,7 +35,7 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
// Column definition type
|
// Column definition type
|
||||||
interface ColumnDef {
|
interface ColumnDef {
|
||||||
key: ColumnKey;
|
key: ProductMetricColumnKey;
|
||||||
label: string;
|
label: string;
|
||||||
group: string;
|
group: string;
|
||||||
noLabel?: boolean;
|
noLabel?: boolean;
|
||||||
@@ -45,171 +45,162 @@ interface ColumnDef {
|
|||||||
|
|
||||||
// Define available columns with their groups
|
// Define available columns with their groups
|
||||||
const AVAILABLE_COLUMNS: ColumnDef[] = [
|
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: '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: 'brand', label: 'Company', group: 'Basic Info' },
|
||||||
{ key: 'vendor', label: 'Supplier', group: 'Basic Info' },
|
{ key: 'vendor', label: 'Supplier', group: 'Basic Info' },
|
||||||
{ key: 'vendor_reference', label: 'Supplier #', group: 'Basic Info' },
|
{ key: 'isVisible', label: 'Visible', group: 'Basic Info' },
|
||||||
{ key: 'barcode', label: 'UPC', group: 'Basic Info' },
|
{ key: 'isReplenishable', label: 'Replenishable', group: 'Basic Info' },
|
||||||
{ key: 'description', label: 'Description', group: 'Basic Info' },
|
{ key: 'dateCreated', label: 'Created', 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' },
|
|
||||||
|
|
||||||
// Physical properties
|
// Current Status
|
||||||
{ key: 'weight', label: 'Weight', group: 'Physical', format: (v) => v?.toString() ?? '-' },
|
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'dimensions', label: 'Dimensions', group: 'Physical', format: (v) => v ? `${v.length}x${v.width}x${v.height}` : '-' },
|
{ 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
|
// Dates
|
||||||
{ key: 'stock_quantity', label: 'Shelf Count', group: 'Stock', format: (v) => v?.toString() ?? '-' },
|
{ key: 'dateFirstReceived', label: 'First Received', group: 'Dates' },
|
||||||
{ key: 'stock_status', label: 'Stock Status', group: 'Stock' },
|
{ key: 'dateLastReceived', label: 'Last Received', group: 'Dates' },
|
||||||
{ key: 'preorder_count', label: 'Preorders', group: 'Stock', format: (v) => v?.toString() ?? '-' },
|
{ key: 'dateFirstSold', label: 'First Sold', group: 'Dates' },
|
||||||
{ key: 'notions_inv_count', label: 'Notions Inv', group: 'Stock', format: (v) => v?.toString() ?? '-' },
|
{ key: 'dateLastSold', label: 'Last Sold', group: 'Dates' },
|
||||||
{ key: 'days_of_inventory', label: 'Days of Stock', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'ageDays', label: 'Age (Days)', group: 'Dates', format: (v) => v?.toString() ?? '-' },
|
||||||
{ 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() ?? '-' },
|
|
||||||
|
|
||||||
// Pricing columns
|
// Product Status
|
||||||
{ key: 'price', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'status', label: 'Status', group: 'Status' },
|
||||||
{ 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) ?? '-' },
|
|
||||||
|
|
||||||
// Sales columns
|
// Rolling Metrics
|
||||||
{ key: 'daily_sales_avg', label: 'Daily Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'sales7d', label: 'Sales (7d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'weekly_sales_avg', label: 'Weekly Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'revenue7d', label: 'Revenue (7d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'monthly_sales_avg', label: 'Monthly Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'sales14d', label: 'Sales (14d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'avg_quantity_per_order', label: 'Avg Qty/Order', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'revenue14d', label: 'Revenue (14d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'number_of_orders', label: 'Order Count', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
{ key: 'sales30d', label: 'Sales (30d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'first_sale_date', label: 'First Sale', group: 'Sales' },
|
{ key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'last_sale_date', label: 'Last Sale', group: 'Sales' },
|
{ key: 'cogs30d', label: 'COGS (30d)', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'date_last_sold', label: 'Date Last Sold', group: 'Sales' },
|
{ key: 'profit30d', label: 'Profit (30d)', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'total_sold', label: 'Total Sold', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
{ key: 'sales365d', label: 'Sales (365d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'baskets', label: 'In Baskets', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
{ key: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ 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() ?? '-' },
|
|
||||||
|
|
||||||
// Financial columns
|
// KPIs
|
||||||
{ key: 'gmroi', label: 'GMROI', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'margin30d', label: 'Margin %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
|
||||||
{ key: 'turnover_rate', label: 'Turnover Rate', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'markup30d', label: 'Markup %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
|
||||||
{ key: 'avg_margin_percent', label: 'Margin %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
|
{ key: 'gmroi30d', label: 'GMROI', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'inventory_value', label: 'Inventory Value', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'stockturn30d', label: 'Stock Turn', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'cost_of_goods_sold', label: 'COGS', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'sellThrough30d', label: 'Sell Through %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
|
||||||
{ key: 'gross_profit', label: 'Gross Profit', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
|
||||||
|
|
||||||
// Lead Time columns
|
// Replenishment
|
||||||
{ key: 'current_lead_time', label: 'Current Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'abcClass', label: 'ABC Class', group: 'Stock' },
|
||||||
{ key: 'target_lead_time', label: 'Target Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
|
||||||
{ key: 'lead_time_status', label: 'Lead Time Status', group: 'Lead Time' },
|
{ key: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
|
||||||
{ key: 'last_purchase_date', label: 'Last Purchase', group: 'Lead Time' },
|
{ key: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
|
||||||
{ key: 'first_received_date', label: 'First Received', group: 'Lead Time' },
|
{ key: 'overstockedUnits', label: 'Overstock Qty', group: 'Stock', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'last_received_date', label: 'Last Received', group: 'Lead Time' },
|
{ 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
|
// Define default columns for each view
|
||||||
const VIEW_COLUMNS: Record<string, ColumnKey[]> = {
|
const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
|
||||||
all: [
|
all: [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'brand',
|
'brand',
|
||||||
'vendor',
|
'vendor',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'stock_status',
|
'status',
|
||||||
'reorder_qty',
|
'salesVelocityDaily',
|
||||||
'price',
|
'currentPrice',
|
||||||
'regular_price',
|
'currentRegularPrice',
|
||||||
'daily_sales_avg',
|
'sales7d',
|
||||||
'weekly_sales_avg',
|
'sales30d',
|
||||||
'monthly_sales_avg',
|
'revenue30d',
|
||||||
'inventory_value',
|
'currentStockCost',
|
||||||
],
|
],
|
||||||
critical: [
|
critical: [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'safety_stock',
|
'configSafetyStock',
|
||||||
'daily_sales_avg',
|
'sales7d',
|
||||||
'weekly_sales_avg',
|
'sales30d',
|
||||||
'reorder_qty',
|
'replenishmentUnits',
|
||||||
'reorder_point',
|
'salesVelocityDaily',
|
||||||
'vendor',
|
'vendor',
|
||||||
'last_purchase_date',
|
'dateLastReceived',
|
||||||
'current_lead_time',
|
'avgLeadTimeDays',
|
||||||
],
|
],
|
||||||
reorder: [
|
reorder: [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'reorder_point',
|
'salesVelocityDaily',
|
||||||
'daily_sales_avg',
|
'sales7d',
|
||||||
'weekly_sales_avg',
|
'sales30d',
|
||||||
'reorder_qty',
|
'replenishmentUnits',
|
||||||
'vendor',
|
'vendor',
|
||||||
'last_purchase_date',
|
'dateLastReceived',
|
||||||
'avg_lead_time_days',
|
'avgLeadTimeDays',
|
||||||
],
|
],
|
||||||
overstocked: [
|
overstocked: [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'daily_sales_avg',
|
'sales7d',
|
||||||
'weekly_sales_avg',
|
'sales30d',
|
||||||
'overstocked_amt',
|
'overstockedUnits',
|
||||||
'days_of_inventory',
|
'stockCoverInDays',
|
||||||
'inventory_value',
|
'currentStockCost',
|
||||||
'turnover_rate',
|
'stockturn30d',
|
||||||
],
|
],
|
||||||
'at-risk': [
|
'at-risk': [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'safety_stock',
|
'configSafetyStock',
|
||||||
'daily_sales_avg',
|
'sales7d',
|
||||||
'weekly_sales_avg',
|
'sales30d',
|
||||||
'days_of_inventory',
|
'stockCoverInDays',
|
||||||
'last_sale_date',
|
'dateLastSold',
|
||||||
'current_lead_time',
|
'avgLeadTimeDays',
|
||||||
],
|
],
|
||||||
new: [
|
new: [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'vendor',
|
'vendor',
|
||||||
'brand',
|
'brand',
|
||||||
'price',
|
'currentPrice',
|
||||||
'regular_price',
|
'currentRegularPrice',
|
||||||
'first_received_date',
|
'dateFirstReceived',
|
||||||
],
|
],
|
||||||
healthy: [
|
healthy: [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'daily_sales_avg',
|
'sales7d',
|
||||||
'weekly_sales_avg',
|
'sales30d',
|
||||||
'monthly_sales_avg',
|
'revenue30d',
|
||||||
'days_of_inventory',
|
'stockCoverInDays',
|
||||||
'gross_profit',
|
'profit30d',
|
||||||
'gmroi',
|
'gmroi30d',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Products() {
|
export function Products() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [filters, setFilters] = useState<Record<string, ActiveFilterValue>>({});
|
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 [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [activeView, setActiveView] = useState(searchParams.get('view') || "all");
|
const [activeView, setActiveView] = useState(searchParams.get('view') || "all");
|
||||||
@@ -219,16 +210,16 @@ export function Products() {
|
|||||||
const [, setIsLoading] = useState(false);
|
const [, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Store visible columns and order for each view
|
// Store visible columns and order for each view
|
||||||
const [viewColumns, setViewColumns] = useState<Record<string, Set<ColumnKey>>>(() => {
|
const [viewColumns, setViewColumns] = useState<Record<string, Set<ProductMetricColumnKey>>>(() => {
|
||||||
const initialColumns: Record<string, Set<ColumnKey>> = {};
|
const initialColumns: Record<string, Set<ProductMetricColumnKey>> = {};
|
||||||
Object.entries(VIEW_COLUMNS).forEach(([view, columns]) => {
|
Object.entries(VIEW_COLUMNS).forEach(([view, columns]) => {
|
||||||
initialColumns[view] = new Set(columns);
|
initialColumns[view] = new Set(columns);
|
||||||
});
|
});
|
||||||
return initialColumns;
|
return initialColumns;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [viewColumnOrder, setViewColumnOrder] = useState<Record<string, ColumnKey[]>>(() => {
|
const [viewColumnOrder, setViewColumnOrder] = useState<Record<string, ProductMetricColumnKey[]>>(() => {
|
||||||
const initialOrder: Record<string, ColumnKey[]> = {};
|
const initialOrder: Record<string, ProductMetricColumnKey[]> = {};
|
||||||
Object.entries(VIEW_COLUMNS).forEach(([view, defaultColumns]) => {
|
Object.entries(VIEW_COLUMNS).forEach(([view, defaultColumns]) => {
|
||||||
initialOrder[view] = [
|
initialOrder[view] = [
|
||||||
...defaultColumns,
|
...defaultColumns,
|
||||||
@@ -241,16 +232,19 @@ export function Products() {
|
|||||||
// Get current view's columns
|
// Get current view's columns
|
||||||
const visibleColumns = useMemo(() => {
|
const visibleColumns = useMemo(() => {
|
||||||
const columns = new Set(viewColumns[activeView] || VIEW_COLUMNS.all);
|
const columns = new Set(viewColumns[activeView] || VIEW_COLUMNS.all);
|
||||||
|
|
||||||
|
// Add isReplenishable column when showing non-replenishable products for better visibility
|
||||||
if (showNonReplenishable) {
|
if (showNonReplenishable) {
|
||||||
columns.add('replenishable');
|
columns.add('isReplenishable');
|
||||||
}
|
}
|
||||||
|
|
||||||
return columns;
|
return columns;
|
||||||
}, [viewColumns, activeView, showNonReplenishable]);
|
}, [viewColumns, activeView, showNonReplenishable]);
|
||||||
|
|
||||||
const columnOrder = viewColumnOrder[activeView] || viewColumnOrder.all;
|
const columnOrder = viewColumnOrder[activeView] || viewColumnOrder.all;
|
||||||
|
|
||||||
// Handle column visibility changes
|
// Handle column visibility changes
|
||||||
const handleColumnVisibilityChange = (column: ColumnKey, isVisible: boolean) => {
|
const handleColumnVisibilityChange = (column: ProductMetricColumnKey, isVisible: boolean) => {
|
||||||
setViewColumns(prev => ({
|
setViewColumns(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[activeView]: isVisible
|
[activeView]: isVisible
|
||||||
@@ -260,7 +254,7 @@ export function Products() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle column order changes
|
// Handle column order changes
|
||||||
const handleColumnOrderChange = (newOrder: ColumnKey[]) => {
|
const handleColumnOrderChange = (newOrder: ProductMetricColumnKey[]) => {
|
||||||
setViewColumnOrder(prev => ({
|
setViewColumnOrder(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[activeView]: newOrder
|
[activeView]: newOrder
|
||||||
@@ -307,35 +301,93 @@ export function Products() {
|
|||||||
params.append('limit', pageSize.toString());
|
params.append('limit', pageSize.toString());
|
||||||
|
|
||||||
if (sortColumn) {
|
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);
|
params.append('order', sortDirection);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeView && activeView !== 'all') {
|
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
|
// Transform filters to match API expectations
|
||||||
const transformedFilters = transformFilters(filters);
|
const transformedFilters = transformFilters(filters);
|
||||||
Object.entries(transformedFilters).forEach(([key, value]) => {
|
Object.entries(transformedFilters).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== null && 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)) {
|
if (Array.isArray(value)) {
|
||||||
params.append(key, JSON.stringify(value));
|
params.append(snakeCaseKey, JSON.stringify(value));
|
||||||
} else {
|
} else {
|
||||||
params.append(key, value.toString());
|
params.append(snakeCaseKey, value.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!showNonReplenishable) {
|
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');
|
if (!response.ok) throw new Error('Failed to fetch products');
|
||||||
|
|
||||||
const data = await response.json();
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching products:', error);
|
console.error('Error fetching products:', error);
|
||||||
toast("Failed to fetch products. Please try again.");
|
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
|
// Query for products data
|
||||||
const { data, isFetching } = useQuery({
|
const { data, isFetching } = useQuery({
|
||||||
queryKey: ['products', currentPage, pageSize, sortColumn, sortDirection, activeView, filters, showNonReplenishable],
|
queryKey: ['products', currentPage, pageSize, sortColumn, sortDirection, activeView, filters, showNonReplenishable],
|
||||||
@@ -360,7 +435,7 @@ export function Products() {
|
|||||||
}, [currentPage, data?.pagination.pages]);
|
}, [currentPage, data?.pagination.pages]);
|
||||||
|
|
||||||
// Handle sort column change
|
// Handle sort column change
|
||||||
const handleSort = (column: keyof Product) => {
|
const handleSort = (column: ProductMetricColumnKey) => {
|
||||||
setSortDirection(prev => {
|
setSortDirection(prev => {
|
||||||
if (sortColumn !== column) return 'asc';
|
if (sortColumn !== column) return 'asc';
|
||||||
return prev === 'asc' ? 'desc' : 'asc';
|
return prev === 'asc' ? 'desc' : 'asc';
|
||||||
@@ -515,9 +590,12 @@ export function Products() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<ProductFilters
|
<ProductFilters
|
||||||
categories={data?.filters?.categories ?? []}
|
filterOptions={{
|
||||||
vendors={data?.filters?.vendors ?? []}
|
vendors: filterOptionsData?.vendors ?? [],
|
||||||
brands={data?.filters?.brands ?? []}
|
brands: filterOptionsData?.brands ?? [],
|
||||||
|
abcClasses: filterOptionsData?.abcClasses ?? []
|
||||||
|
}}
|
||||||
|
isLoadingOptions={isLoadingFilterOptions}
|
||||||
onFilterChange={handleFilterChange}
|
onFilterChange={handleFilterChange}
|
||||||
onClearFilters={handleClearFilters}
|
onClearFilters={handleClearFilters}
|
||||||
activeFilters={filters}
|
activeFilters={filters}
|
||||||
@@ -534,7 +612,7 @@ export function Products() {
|
|||||||
/>
|
/>
|
||||||
<Label htmlFor="show-non-replenishable">Show Non-Replenishable</Label>
|
<Label htmlFor="show-non-replenishable">Show Non-Replenishable</Label>
|
||||||
</div>
|
</div>
|
||||||
{data?.pagination.total > 0 && (
|
{data?.pagination?.total !== undefined && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{data.pagination.total.toLocaleString()} products
|
{data.pagination.total.toLocaleString()} products
|
||||||
</div>
|
</div>
|
||||||
@@ -548,7 +626,13 @@ export function Products() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ProductTable
|
<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}
|
onSort={handleSort}
|
||||||
sortColumn={sortColumn}
|
sortColumn={sortColumn}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
|
|||||||
@@ -1,350 +1,481 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useCallback } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import config from "../config";
|
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 {
|
// Matches backend COLUMN_MAP keys for sorting
|
||||||
vendor_id: number;
|
type VendorSortableColumns =
|
||||||
name: string;
|
| 'vendorName' | 'productCount' | 'activeProductCount' | 'currentStockUnits'
|
||||||
status: string;
|
| 'currentStockCost' | 'onOrderUnits' | 'onOrderCost' | 'avgLeadTimeDays'
|
||||||
avg_lead_time_days: number;
|
| 'revenue_30d' | 'profit_30d' | 'avg_margin_30d' | 'po_count_365d' | 'status';
|
||||||
on_time_delivery_rate: number;
|
|
||||||
order_fill_rate: number;
|
interface VendorMetric {
|
||||||
total_orders: number;
|
vendor_id: string | number;
|
||||||
active_products: number;
|
vendor_name: string;
|
||||||
avg_unit_cost: number;
|
last_calculated: string;
|
||||||
total_spend: number;
|
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 {
|
interface VendorFilters {
|
||||||
search: string;
|
search: string;
|
||||||
status: string;
|
status: string;
|
||||||
performance: string;
|
showInactive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 50;
|
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() {
|
export function Vendors() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [sortColumn, setSortColumn] = useState<keyof Vendor>("name");
|
const [limit] = useState(ITEMS_PER_PAGE);
|
||||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
const [sortColumn, setSortColumn] = useState<VendorSortableColumns>("vendorName");
|
||||||
const [filters, setFilters] = useState<VendorFilters>({
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
search: "",
|
const [filters, setFilters] = useState<VendorFilters>({
|
||||||
status: "all",
|
search: "",
|
||||||
performance: "all",
|
status: "all",
|
||||||
});
|
showInactive: false, // Default to hiding vendors with 0 active products
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
// --- Data Fetching ---
|
||||||
}, [data?.vendors, filters, sortColumn, sortDirection]);
|
|
||||||
|
|
||||||
// Calculate pagination
|
const queryParams = useMemo(() => {
|
||||||
const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE);
|
const params = new URLSearchParams();
|
||||||
const paginatedData = useMemo(() => {
|
params.set('page', page.toString());
|
||||||
const start = (page - 1) * ITEMS_PER_PAGE;
|
params.set('limit', limit.toString());
|
||||||
const end = start + ITEMS_PER_PAGE;
|
params.set('sort', sortColumn);
|
||||||
return filteredData.slice(start, end);
|
params.set('order', sortDirection);
|
||||||
}, [filteredData, page]);
|
|
||||||
|
|
||||||
const handleSort = (column: keyof Vendor) => {
|
if (filters.search) {
|
||||||
setSortDirection(prev => {
|
params.set('vendorName_ilike', filters.search); // Filter by name
|
||||||
if (sortColumn !== column) return "asc";
|
}
|
||||||
return prev === "asc" ? "desc" : "asc";
|
if (filters.status !== 'all') {
|
||||||
});
|
params.set('status', filters.status); // Filter by status
|
||||||
setSortColumn(column);
|
}
|
||||||
};
|
if (!filters.showInactive) {
|
||||||
|
params.set('activeProductCount_gt', '0'); // Only show vendors with active products
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
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
|
return params;
|
||||||
layout="preserve-aspect"
|
}, [page, limit, sortColumn, sortDirection, filters]);
|
||||||
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>
|
|
||||||
|
|
||||||
<Card>
|
const { data: listData, isLoading: isLoadingList, error: listError } = useQuery<VendorResponse, Error>({
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
queryKey: ['vendors', queryParams.toString()],
|
||||||
<CardTitle className="text-sm font-medium">Total Spend</CardTitle>
|
queryFn: async () => {
|
||||||
</CardHeader>
|
const response = await fetch(`${config.apiUrl}/vendors-aggregate?${queryParams.toString()}`, {
|
||||||
<CardContent>
|
credentials: 'include'
|
||||||
<div className="text-2xl font-bold">
|
});
|
||||||
${typeof data?.stats?.totalSpend === 'number' ? data.stats.totalSpend.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }) : "..."}
|
if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
|
||||||
</div>
|
return response.json();
|
||||||
<p className="text-xs text-muted-foreground">
|
},
|
||||||
Avg unit cost: ${typeof data?.stats?.avgUnitCost === 'number' ? data.stats.avgUnitCost.toFixed(2) : "..."}
|
placeholderData: (prev) => prev, // Modern replacement for keepPreviousData
|
||||||
</p>
|
});
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
const { data: statsData, isLoading: isLoadingStats } = useQuery<VendorStats, Error>({
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
queryKey: ['vendorsStats'],
|
||||||
<CardTitle className="text-sm font-medium">Performance</CardTitle>
|
queryFn: async () => {
|
||||||
</CardHeader>
|
const response = await fetch(`${config.apiUrl}/vendors-aggregate/stats`, {
|
||||||
<CardContent>
|
credentials: 'include'
|
||||||
<div className="text-2xl font-bold">{typeof data?.stats?.avgFillRate === 'number' ? data.stats.avgFillRate.toFixed(1) : "..."}%</div>
|
});
|
||||||
<p className="text-xs text-muted-foreground">
|
if (!response.ok) throw new Error("Failed to fetch vendor stats");
|
||||||
Fill rate / {typeof data?.stats?.avgOnTimeDelivery === 'number' ? data.stats.avgOnTimeDelivery.toFixed(1) : "..."}% on-time
|
return response.json();
|
||||||
</p>
|
},
|
||||||
</CardContent>
|
});
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
// Fetch filter options
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
const { data: filterOptions, isLoading: isLoadingFilterOptions } = useQuery<VendorFilterOptions, Error>({
|
||||||
<CardTitle className="text-sm font-medium">Lead Time</CardTitle>
|
queryKey: ['vendorsFilterOptions'],
|
||||||
</CardHeader>
|
queryFn: async () => {
|
||||||
<CardContent>
|
const response = await fetch(`${config.apiUrl}/vendors-aggregate/filter-options`, {
|
||||||
<div className="text-2xl font-bold">{typeof data?.stats?.avgLeadTime === 'number' ? data.stats.avgLeadTime.toFixed(1) : "..."} days</div>
|
credentials: 'include'
|
||||||
<p className="text-xs text-muted-foreground">
|
});
|
||||||
Average delivery time
|
if (!response.ok) throw new Error("Failed to fetch filter options");
|
||||||
</p>
|
return response.json();
|
||||||
</CardContent>
|
},
|
||||||
</Card>
|
});
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
// --- Event Handlers ---
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="rounded-md border">
|
const handleSort = useCallback((column: VendorSortableColumns) => {
|
||||||
<Table>
|
setSortDirection(prev => (sortColumn === column && prev === "asc" ? "desc" : "asc"));
|
||||||
<TableHeader>
|
setSortColumn(column);
|
||||||
<TableRow>
|
setPage(1);
|
||||||
<TableHead onClick={() => handleSort("name")} className="cursor-pointer">Vendor</TableHead>
|
}, [sortColumn]);
|
||||||
<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>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
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
|
<motion.div
|
||||||
layout="position"
|
layout
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ layout: { duration: 0.15, ease: [0.4, 0, 0.2, 1] } }}
|
||||||
className="flex justify-center"
|
className="container mx-auto py-6 space-y-4"
|
||||||
>
|
>
|
||||||
<Pagination>
|
{/* Header */}
|
||||||
<PaginationContent>
|
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex items-center justify-between">
|
||||||
<PaginationItem>
|
<h1 className="text-3xl font-bold tracking-tight">Vendors</h1>
|
||||||
<PaginationPrevious
|
<div className="text-sm text-muted-foreground">
|
||||||
href="#"
|
{isLoadingList && !pagination ? 'Loading...' : `${formatNumber(pagination?.total)} vendors`}
|
||||||
onClick={(e) => {
|
</div>
|
||||||
e.preventDefault();
|
</motion.div>
|
||||||
if (page > 1) setPage(p => p - 1);
|
|
||||||
}}
|
{/* Stats Cards */}
|
||||||
aria-disabled={page === 1}
|
<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>
|
<Select
|
||||||
{Array.from({ length: totalPages }, (_, i) => (
|
value={filters.status}
|
||||||
<PaginationItem key={i + 1}>
|
onValueChange={(value) => handleFilterChange('status', value)}
|
||||||
<PaginationLink
|
>
|
||||||
href="#"
|
<SelectTrigger className="w-full sm:w-[180px]">
|
||||||
onClick={(e) => {
|
<SelectValue placeholder="Status" />
|
||||||
e.preventDefault();
|
</SelectTrigger>
|
||||||
setPage(i + 1);
|
<SelectContent>
|
||||||
}}
|
<SelectItem value="all">All Statuses</SelectItem>
|
||||||
isActive={page === i + 1}
|
{filterOptions?.statuses?.map((status) => (
|
||||||
>
|
<SelectItem key={status} value={status}>
|
||||||
{i + 1}
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
</PaginationLink>
|
</SelectItem>
|
||||||
</PaginationItem>
|
))}
|
||||||
))}
|
</SelectContent>
|
||||||
<PaginationItem>
|
</Select>
|
||||||
<PaginationNext
|
<div className="flex items-center space-x-2 ml-auto">
|
||||||
href="#"
|
<Switch
|
||||||
onClick={(e) => {
|
id="show-inactive-vendors"
|
||||||
e.preventDefault();
|
checked={filters.showInactive}
|
||||||
if (page < totalPages) setPage(p => p + 1);
|
onCheckedChange={(checked) => handleFilterChange('showInactive', checked)}
|
||||||
}}
|
/>
|
||||||
aria-disabled={page >= totalPages}
|
<Label htmlFor="show-inactive-vendors">Show vendors with no active products</Label>
|
||||||
/>
|
</div>
|
||||||
</PaginationItem>
|
</div>
|
||||||
</PaginationContent>
|
|
||||||
</Pagination>
|
{/* 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>
|
||||||
)}
|
);
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Vendors;
|
export default Vendors;
|
||||||
@@ -78,3 +78,196 @@ export interface Product {
|
|||||||
reorder_qty?: number;
|
reorder_qty?: number;
|
||||||
overstocked_amt?: string; // numeric(15,3)
|
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