Add new dashboard backend

This commit is contained in:
2025-01-13 00:14:15 -05:00
parent 024155d054
commit 88c51059bb
14 changed files with 1085 additions and 727 deletions

View File

@@ -43,6 +43,9 @@ CREATE TABLE IF NOT EXISTS product_metrics (
avg_margin_percent DECIMAL(10,3), avg_margin_percent DECIMAL(10,3),
total_revenue DECIMAL(10,3), total_revenue DECIMAL(10,3),
inventory_value DECIMAL(10,3), inventory_value DECIMAL(10,3),
cost_of_goods_sold DECIMAL(10,3),
gross_profit DECIMAL(10,3),
gmroi DECIMAL(10,3),
-- Purchase metrics -- Purchase metrics
avg_lead_time_days INT, avg_lead_time_days INT,
last_purchase_date DATE, last_purchase_date DATE,
@@ -50,9 +53,18 @@ CREATE TABLE IF NOT EXISTS product_metrics (
-- Classification -- Classification
abc_class CHAR(1), abc_class CHAR(1),
stock_status VARCHAR(20), stock_status VARCHAR(20),
-- Turnover metrics
turnover_rate DECIMAL(10,3),
-- Lead time metrics
current_lead_time INT,
target_lead_time INT,
lead_time_status VARCHAR(20),
PRIMARY KEY (product_id), PRIMARY KEY (product_id),
FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE, FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE,
INDEX idx_metrics_revenue (total_revenue) INDEX idx_metrics_revenue (total_revenue),
INDEX idx_metrics_stock_status (stock_status),
INDEX idx_metrics_lead_time (lead_time_status),
INDEX idx_metrics_turnover (turnover_rate)
); );
-- New table for time-based aggregates -- New table for time-based aggregates
@@ -71,6 +83,8 @@ CREATE TABLE IF NOT EXISTS product_time_aggregates (
-- Calculated fields -- Calculated fields
avg_price DECIMAL(10,3), avg_price DECIMAL(10,3),
profit_margin DECIMAL(10,3), profit_margin DECIMAL(10,3),
inventory_value DECIMAL(10,3),
gmroi DECIMAL(10,3),
PRIMARY KEY (product_id, year, month), PRIMARY KEY (product_id, year, month),
FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE, FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE,
INDEX idx_date (year, month) INDEX idx_date (year, month)
@@ -85,7 +99,10 @@ CREATE TABLE IF NOT EXISTS vendor_metrics (
order_fill_rate DECIMAL(5,2), order_fill_rate DECIMAL(5,2),
total_orders INT, total_orders INT,
total_late_orders INT, total_late_orders INT,
PRIMARY KEY (vendor) total_purchase_value DECIMAL(10,3),
avg_order_value DECIMAL(10,3),
PRIMARY KEY (vendor),
INDEX idx_vendor_performance (on_time_delivery_rate)
); );
-- Re-enable foreign key checks -- Re-enable foreign key checks

View File

@@ -179,6 +179,294 @@ function cancelCalculation() {
// Handle SIGTERM signal for cancellation // Handle SIGTERM signal for cancellation
process.on('SIGTERM', cancelCalculation); process.on('SIGTERM', cancelCalculation);
// Calculate GMROI and other financial metrics
async function calculateFinancialMetrics(connection, startTime, totalProducts) {
outputProgress({
status: 'running',
operation: 'Calculating financial metrics',
current: Math.floor(totalProducts * 0.6),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.6), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.6)),
percentage: '60'
});
await connection.query(`
UPDATE product_metrics pm
JOIN (
SELECT
p.product_id,
p.cost_price * p.stock_quantity as inventory_value,
SUM(o.quantity * o.price) as total_revenue,
SUM(o.quantity * p.cost_price) as cost_of_goods_sold,
SUM(o.quantity * (o.price - p.cost_price)) as gross_profit
FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id
WHERE o.canceled = false
AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
GROUP BY p.product_id
) fin ON pm.product_id = fin.product_id
SET
pm.inventory_value = COALESCE(fin.inventory_value, 0),
pm.total_revenue = COALESCE(fin.total_revenue, 0),
pm.cost_of_goods_sold = COALESCE(fin.cost_of_goods_sold, 0),
pm.gross_profit = COALESCE(fin.gross_profit, 0),
pm.gmroi = CASE
WHEN COALESCE(fin.inventory_value, 0) > 0
THEN (COALESCE(fin.gross_profit, 0) / COALESCE(fin.inventory_value, 0)) * 100
ELSE 0
END
`);
// Update time-based aggregates with financial metrics
await connection.query(`
UPDATE product_time_aggregates pta
JOIN (
SELECT
p.product_id,
YEAR(o.date) as year,
MONTH(o.date) as month,
p.cost_price * p.stock_quantity as inventory_value,
SUM((o.price - p.cost_price) * o.quantity) /
NULLIF(p.cost_price * p.stock_quantity, 0) * 100 as gmroi
FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id
WHERE o.canceled = false
GROUP BY p.product_id, YEAR(o.date), MONTH(o.date)
) fin ON pta.product_id = fin.product_id
AND pta.year = fin.year
AND pta.month = fin.month
SET
pta.inventory_value = COALESCE(fin.inventory_value, 0),
pta.gmroi = COALESCE(fin.gmroi, 0)
`);
}
// Calculate vendor metrics
async function calculateVendorMetrics(connection, startTime, totalProducts) {
outputProgress({
status: 'running',
operation: 'Calculating vendor metrics',
current: Math.floor(totalProducts * 0.7),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.7), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.7)),
percentage: '70'
});
await connection.query(`
INSERT INTO vendor_metrics (
vendor,
last_calculated_at,
avg_lead_time_days,
on_time_delivery_rate,
order_fill_rate,
total_orders,
total_late_orders,
total_purchase_value,
avg_order_value
)
SELECT
po.vendor,
NOW() as last_calculated_at,
AVG(
CASE
WHEN po.received_date IS NOT NULL
THEN DATEDIFF(po.received_date, po.date)
ELSE NULL
END
) as avg_lead_time_days,
(
COUNT(CASE WHEN po.received_date <= po.expected_date THEN 1 END) * 100.0 /
NULLIF(COUNT(*), 0)
) as on_time_delivery_rate,
(
SUM(CASE WHEN po.received >= po.ordered THEN 1 ELSE 0 END) * 100.0 /
NULLIF(COUNT(*), 0)
) as order_fill_rate,
COUNT(*) as total_orders,
COUNT(CASE WHEN po.received_date > po.expected_date THEN 1 END) as total_late_orders,
SUM(po.ordered * po.cost_price) as total_purchase_value,
AVG(po.ordered * po.cost_price) as avg_order_value
FROM purchase_orders po
WHERE po.status = 'closed'
AND po.date >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
GROUP BY po.vendor
ON DUPLICATE KEY UPDATE
last_calculated_at = VALUES(last_calculated_at),
avg_lead_time_days = VALUES(avg_lead_time_days),
on_time_delivery_rate = VALUES(on_time_delivery_rate),
order_fill_rate = VALUES(order_fill_rate),
total_orders = VALUES(total_orders),
total_late_orders = VALUES(total_late_orders),
total_purchase_value = VALUES(total_purchase_value),
avg_order_value = VALUES(avg_order_value)
`);
}
// Calculate turnover rate metrics
async function calculateTurnoverMetrics(connection, startTime, totalProducts) {
outputProgress({
status: 'running',
operation: 'Calculating turnover metrics',
current: Math.floor(totalProducts * 0.75),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.75), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.75)),
percentage: '75'
});
await connection.query(`
WITH product_turnover AS (
SELECT
p.product_id,
p.vendor,
pc.category_id,
COALESCE(
(SELECT calculation_period_days
FROM turnover_config tc
WHERE tc.category_id = pc.category_id
AND tc.vendor = p.vendor
LIMIT 1),
COALESCE(
(SELECT calculation_period_days
FROM turnover_config tc
WHERE tc.category_id = pc.category_id
AND tc.vendor IS NULL
LIMIT 1),
COALESCE(
(SELECT calculation_period_days
FROM turnover_config tc
WHERE tc.category_id IS NULL
AND tc.vendor = p.vendor
LIMIT 1),
(SELECT calculation_period_days
FROM turnover_config
WHERE category_id IS NULL
AND vendor IS NULL
LIMIT 1)
)
)
) as calculation_period_days,
SUM(o.quantity) as total_quantity_sold,
AVG(p.stock_quantity) as avg_stock_level
FROM products p
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
LEFT JOIN orders o ON p.product_id = o.product_id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND o.canceled = 0
GROUP BY p.product_id, p.vendor, pc.category_id
)
UPDATE product_metrics pm
JOIN product_turnover pt ON pm.product_id = pt.product_id
SET
pm.turnover_rate = CASE
WHEN pt.avg_stock_level > 0
THEN (pt.total_quantity_sold / pt.avg_stock_level) * (30.0 / pt.calculation_period_days)
ELSE 0
END,
pm.last_calculated_at = NOW()
`);
}
// Enhance lead time calculations
async function calculateLeadTimeMetrics(connection, startTime, totalProducts) {
outputProgress({
status: 'running',
operation: 'Calculating lead time metrics',
current: Math.floor(totalProducts * 0.8),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.8), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.8)),
percentage: '80'
});
await connection.query(`
WITH lead_time_stats AS (
SELECT
p.product_id,
p.vendor,
pc.category_id,
AVG(DATEDIFF(po.received_date, po.date)) as current_lead_time,
COALESCE(
(SELECT target_days
FROM lead_time_thresholds lt
WHERE lt.category_id = pc.category_id
AND lt.vendor = p.vendor
LIMIT 1),
COALESCE(
(SELECT target_days
FROM lead_time_thresholds lt
WHERE lt.category_id = pc.category_id
AND lt.vendor IS NULL
LIMIT 1),
COALESCE(
(SELECT target_days
FROM lead_time_thresholds lt
WHERE lt.category_id IS NULL
AND lt.vendor = p.vendor
LIMIT 1),
(SELECT target_days
FROM lead_time_thresholds
WHERE category_id IS NULL
AND vendor IS NULL
LIMIT 1)
)
)
) as target_lead_time,
COALESCE(
(SELECT warning_days
FROM lead_time_thresholds lt
WHERE lt.category_id = pc.category_id
AND lt.vendor = p.vendor
LIMIT 1),
COALESCE(
(SELECT warning_days
FROM lead_time_thresholds lt
WHERE lt.category_id = pc.category_id
AND lt.vendor IS NULL
LIMIT 1),
COALESCE(
(SELECT warning_days
FROM lead_time_thresholds lt
WHERE lt.category_id IS NULL
AND lt.vendor = p.vendor
LIMIT 1),
(SELECT warning_days
FROM lead_time_thresholds
WHERE category_id IS NULL
AND vendor IS NULL
LIMIT 1)
)
)
) as warning_lead_time
FROM products p
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
LEFT JOIN purchase_orders po ON p.product_id = po.product_id
WHERE po.status = 'completed'
AND po.received_date IS NOT NULL
AND po.date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
GROUP BY p.product_id, p.vendor, pc.category_id
)
UPDATE product_metrics pm
JOIN lead_time_stats lt ON pm.product_id = lt.product_id
SET
pm.current_lead_time = lt.current_lead_time,
pm.target_lead_time = lt.target_lead_time,
pm.lead_time_status = CASE
WHEN lt.current_lead_time <= lt.target_lead_time THEN 'On Target'
WHEN lt.current_lead_time <= lt.warning_lead_time THEN 'Warning'
ELSE 'Critical'
END,
pm.last_calculated_at = NOW()
`);
}
// Update the main calculation function to include our new calculations
async function calculateMetrics() { async function calculateMetrics() {
let pool; let pool;
const startTime = Date.now(); const startTime = Date.now();
@@ -191,6 +479,17 @@ async function calculateMetrics() {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
outputProgress({
status: 'running',
operation: 'Starting metrics calculation',
current: 0,
total: 100,
elapsed: '0s',
remaining: 'Calculating...',
rate: 0,
percentage: '0'
});
// Get total number of products // Get total number of products
const [countResult] = await connection.query('SELECT COUNT(*) as total FROM products') const [countResult] = await connection.query('SELECT COUNT(*) as total FROM products')
.catch(err => { .catch(err => {
@@ -202,7 +501,7 @@ async function calculateMetrics() {
// Initial progress with percentage // Initial progress with percentage
outputProgress({ outputProgress({
status: 'running', status: 'running',
operation: 'Processing products', operation: 'Processing sales and stock metrics',
current: processedCount, current: processedCount,
total: totalProducts, total: totalProducts,
elapsed: '0s', elapsed: '0s',
@@ -467,64 +766,6 @@ async function calculateMetrics() {
throw err; throw err;
}); });
// Get stock thresholds for this product's category/vendor
const [thresholds] = await connection.query(`
WITH product_info AS (
SELECT
p.product_id,
p.vendor,
pc.category_id
FROM products p
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
WHERE p.product_id = ?
),
threshold_options AS (
SELECT
st.*,
CASE
WHEN st.category_id = pi.category_id AND st.vendor = pi.vendor THEN 1 -- Category + vendor match
WHEN st.category_id = pi.category_id AND st.vendor IS NULL THEN 2 -- Category match
WHEN st.category_id IS NULL AND st.vendor = pi.vendor THEN 3 -- Vendor match
WHEN st.category_id IS NULL AND st.vendor IS NULL THEN 4 -- Default
ELSE 5
END as priority
FROM product_info pi
CROSS JOIN stock_thresholds st
WHERE (st.category_id = pi.category_id OR st.category_id IS NULL)
AND (st.vendor = pi.vendor OR st.vendor IS NULL)
)
SELECT
COALESCE(
(SELECT critical_days
FROM threshold_options
ORDER BY priority LIMIT 1),
7
) as critical_days,
COALESCE(
(SELECT reorder_days
FROM threshold_options
ORDER BY priority LIMIT 1),
14
) as reorder_days,
COALESCE(
(SELECT overstock_days
FROM threshold_options
ORDER BY priority LIMIT 1),
90
) as overstock_days
`, [product.product_id]).catch(err => {
logError(err, `Failed to get thresholds for product ${product.product_id}`);
throw err;
});
const threshold = thresholds[0] || {
critical_days: 7,
reorder_days: 14,
overstock_days: 90,
safety_stock_days: 14, // Add default safety stock days
service_level: 95.0 // Add default service level
};
// Calculate metrics // Calculate metrics
const metrics = salesMetrics[0] || {}; const metrics = salesMetrics[0] || {};
const purchases = purchaseMetrics[0] || {}; const purchases = purchaseMetrics[0] || {};
@@ -544,13 +785,13 @@ async function calculateMetrics() {
// Calculate stock status using configurable thresholds with proper handling of zero sales // Calculate stock status using configurable thresholds with proper handling of zero sales
const stock_status = daily_sales_avg === 0 ? 'New' : const stock_status = daily_sales_avg === 0 ? 'New' :
stock.stock_quantity <= Math.max(1, Math.ceil(daily_sales_avg * threshold.critical_days)) ? 'Critical' : stock.stock_quantity <= Math.max(1, Math.ceil(daily_sales_avg * config.critical_days)) ? 'Critical' :
stock.stock_quantity <= Math.max(1, Math.ceil(daily_sales_avg * threshold.reorder_days)) ? 'Reorder' : stock.stock_quantity <= Math.max(1, Math.ceil(daily_sales_avg * config.reorder_days)) ? 'Reorder' :
stock.stock_quantity > Math.max(1, daily_sales_avg * threshold.overstock_days) ? 'Overstocked' : 'Healthy'; stock.stock_quantity > Math.max(1, daily_sales_avg * config.overstock_days) ? 'Overstocked' : 'Healthy';
// Calculate safety stock using configured values with proper defaults // Calculate safety stock using configured values with proper defaults
const safety_stock = daily_sales_avg > 0 ? const safety_stock = daily_sales_avg > 0 ?
Math.max(1, Math.ceil(daily_sales_avg * (threshold.safety_stock_days || 14) * ((threshold.service_level || 95.0) / 100))) : Math.max(1, Math.ceil(daily_sales_avg * (config.safety_stock_days || 14) * ((config.service_level || 95.0) / 100))) :
null; null;
// Add to batch update // Add to batch update
@@ -565,8 +806,8 @@ async function calculateMetrics() {
metrics.last_sale_date || null, metrics.last_sale_date || null,
daily_sales_avg > 0 ? stock.stock_quantity / daily_sales_avg : null, daily_sales_avg > 0 ? stock.stock_quantity / daily_sales_avg : null,
weekly_sales_avg > 0 ? stock.stock_quantity / weekly_sales_avg : null, weekly_sales_avg > 0 ? stock.stock_quantity / weekly_sales_avg : null,
daily_sales_avg > 0 ? Math.max(1, Math.ceil(daily_sales_avg * threshold.reorder_days)) : null, daily_sales_avg > 0 ? Math.max(1, Math.ceil(daily_sales_avg * config.reorder_days)) : null,
daily_sales_avg > 0 ? Math.max(1, Math.ceil(daily_sales_avg * threshold.critical_days)) : null, safety_stock,
margin_percent, margin_percent,
metrics.total_revenue || 0, metrics.total_revenue || 0,
inventory_value || 0, inventory_value || 0,
@@ -633,31 +874,45 @@ async function calculateMetrics() {
} }
} }
// Update progress for ABC classification
if (isCancelled) {
throw new Error('Operation cancelled');
}
outputProgress({ outputProgress({
status: 'running', status: 'running',
operation: 'Starting ABC classification', operation: 'Starting financial metrics calculation',
current: Math.floor(totalProducts * 0.7), // Start from 70% after product processing current: Math.floor(totalProducts * 0.6),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.6), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.6)),
percentage: '60'
});
await calculateFinancialMetrics(connection, startTime, totalProducts);
outputProgress({
status: 'running',
operation: 'Starting vendor metrics calculation',
current: Math.floor(totalProducts * 0.7),
total: totalProducts, total: totalProducts,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.7), totalProducts), remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.7), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.7)), rate: calculateRate(startTime, Math.floor(totalProducts * 0.7)),
percentage: '70' percentage: '70'
}); });
await calculateVendorMetrics(connection, startTime, totalProducts);
// Calculate ABC classification using configured thresholds
if (isCancelled) {
throw new Error('Operation cancelled');
}
const [abcConfig] = await connection.query('SELECT a_threshold, b_threshold FROM abc_classification_config WHERE id = 1');
const abcThresholds = abcConfig[0] || { a_threshold: 20, b_threshold: 50 };
outputProgress({ outputProgress({
status: 'running', status: 'running',
operation: 'Calculating ABC rankings', operation: 'Starting turnover metrics calculation',
current: Math.floor(totalProducts * 0.75),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.75), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.75)),
percentage: '75'
});
await calculateTurnoverMetrics(connection, startTime, totalProducts);
outputProgress({
status: 'running',
operation: 'Starting lead time metrics calculation',
current: Math.floor(totalProducts * 0.8), current: Math.floor(totalProducts * 0.8),
total: totalProducts, total: totalProducts,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
@@ -665,6 +920,25 @@ async function calculateMetrics() {
rate: calculateRate(startTime, Math.floor(totalProducts * 0.8)), rate: calculateRate(startTime, Math.floor(totalProducts * 0.8)),
percentage: '80' percentage: '80'
}); });
await calculateLeadTimeMetrics(connection, startTime, totalProducts);
// Calculate ABC classification
if (isCancelled) {
throw new Error('Operation cancelled');
}
outputProgress({
status: 'running',
operation: 'Starting ABC classification',
current: Math.floor(totalProducts * 0.7),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.7), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.7)),
percentage: '70'
});
const [abcConfig] = await connection.query('SELECT a_threshold, b_threshold FROM abc_classification_config WHERE id = 1');
const abcThresholds = abcConfig[0] || { a_threshold: 20, b_threshold: 50 };
await connection.query(` await connection.query(`
WITH revenue_rankings AS ( WITH revenue_rankings AS (
@@ -690,7 +964,7 @@ async function calculateMetrics() {
pm.last_calculated_at = NOW() pm.last_calculated_at = NOW()
`, [abcThresholds.a_threshold, abcThresholds.b_threshold]); `, [abcThresholds.a_threshold, abcThresholds.b_threshold]);
// Update progress for time-based aggregates // Calculate time-based aggregates
if (isCancelled) { if (isCancelled) {
throw new Error('Operation cancelled'); throw new Error('Operation cancelled');
} }
@@ -705,23 +979,8 @@ async function calculateMetrics() {
percentage: '85' percentage: '85'
}); });
// Calculate time-based aggregates
if (isCancelled) {
throw new Error('Operation cancelled');
}
await connection.query('TRUNCATE TABLE product_time_aggregates;'); await connection.query('TRUNCATE TABLE product_time_aggregates;');
outputProgress({
status: 'running',
operation: 'Calculating sales aggregates',
current: Math.floor(totalProducts * 0.9),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.9), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.9)),
percentage: '90'
});
await connection.query(` await connection.query(`
INSERT INTO product_time_aggregates ( INSERT INTO product_time_aggregates (
product_id, product_id,
@@ -806,83 +1065,6 @@ async function calculateMetrics() {
WHERE s.product_id IS NULL WHERE s.product_id IS NULL
`); `);
outputProgress({
status: 'running',
operation: 'Time-based aggregates complete',
current: totalProducts,
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, totalProducts, totalProducts),
rate: calculateRate(startTime, totalProducts),
percentage: '100'
});
// Update progress for vendor metrics
if (isCancelled) {
throw new Error('Operation cancelled');
}
outputProgress({
status: 'running',
operation: 'Starting vendor metrics calculation',
current: Math.floor(totalProducts * 0.95),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.95), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.95)),
percentage: '95'
});
// Calculate vendor metrics with fixed order fill rate calculation
if (isCancelled) {
throw new Error('Operation cancelled');
}
await connection.query(`
INSERT INTO vendor_metrics (
vendor,
last_calculated_at,
avg_lead_time_days,
on_time_delivery_rate,
order_fill_rate,
total_orders,
total_late_orders
)
SELECT
vendor,
NOW() as last_calculated_at,
COALESCE(AVG(DATEDIFF(received_date, date)), 0) as avg_lead_time_days,
COALESCE(
(COUNT(CASE WHEN DATEDIFF(received_date, date) <= 14 THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0)),
0
) as on_time_delivery_rate,
CASE
WHEN SUM(ordered) = 0 THEN 0
ELSE LEAST(100, GREATEST(0, (SUM(CASE WHEN received >= 0 THEN received ELSE 0 END) * 100.0 / SUM(ordered))))
END as order_fill_rate,
COUNT(DISTINCT po_id) as total_orders,
COUNT(CASE WHEN DATEDIFF(received_date, date) > 14 THEN 1 END) as total_late_orders
FROM purchase_orders
WHERE status = 'closed'
GROUP BY vendor
ON DUPLICATE KEY UPDATE
last_calculated_at = VALUES(last_calculated_at),
avg_lead_time_days = VALUES(avg_lead_time_days),
on_time_delivery_rate = VALUES(on_time_delivery_rate),
order_fill_rate = VALUES(order_fill_rate),
total_orders = VALUES(total_orders),
total_late_orders = VALUES(total_late_orders)
`);
outputProgress({
status: 'running',
operation: 'Vendor metrics complete',
current: Math.floor(totalProducts * 0.98),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.98), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.98)),
percentage: '98'
});
// Final success message // Final success message
outputProgress({ outputProgress({
status: 'complete', status: 'complete',

View File

@@ -192,165 +192,125 @@ router.get('/sales-by-category', async (req, res) => {
} }
}); });
// Get trending products // Get inventory health summary
router.get('/trending-products', async (req, res) => { router.get('/inventory/health/summary', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
// First check what statuses exist
const [checkStatuses] = await pool.query(`
SELECT DISTINCT stock_status
FROM product_metrics
WHERE stock_status IS NOT NULL
`);
console.log('Available stock statuses:', checkStatuses.map(row => row.stock_status));
const [rows] = await pool.query(` const [rows] = await pool.query(`
WITH CurrentSales AS ( WITH normalized_status AS (
SELECT SELECT
p.product_id, CASE
p.title, WHEN stock_status = 'Overstocked' THEN 'Overstock'
p.sku, WHEN stock_status = 'New' THEN 'Healthy'
p.stock_quantity, ELSE stock_status
p.image, END as status
COALESCE(SUM(o.price * o.quantity), 0) as total_sales FROM product_metrics
FROM products p WHERE stock_status IS NOT NULL
LEFT JOIN orders o ON p.product_id = o.product_id
AND o.canceled = false
AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
WHERE p.visible = true
GROUP BY p.product_id, p.title, p.sku, p.stock_quantity, p.image
HAVING total_sales > 0
),
PreviousSales AS (
SELECT
p.product_id,
COALESCE(SUM(o.price * o.quantity), 0) as previous_sales
FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id
AND o.canceled = false
AND DATE(o.date) BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 30 DAY)
WHERE p.visible = true
GROUP BY p.product_id
) )
SELECT SELECT
cs.*, status as stock_status,
CASE COUNT(*) as count
WHEN COALESCE(ps.previous_sales, 0) = 0 THEN FROM normalized_status
CASE WHEN cs.total_sales > 0 THEN 100 ELSE 0 END GROUP BY status
ELSE ((cs.total_sales - ps.previous_sales) / ps.previous_sales * 100)
END as sales_growth
FROM CurrentSales cs
LEFT JOIN PreviousSales ps ON cs.product_id = ps.product_id
ORDER BY cs.total_sales DESC
LIMIT 5
`); `);
console.log('Raw inventory health summary:', rows);
// Convert array to object with lowercase keys
const summary = {
critical: 0,
reorder: 0,
healthy: 0,
overstock: 0
};
console.log('Trending products query result:', rows); rows.forEach(row => {
const key = row.stock_status.toLowerCase();
if (key in summary) {
summary[key] = parseInt(row.count);
}
});
res.json(rows.map(row => ({ // Calculate total
product_id: row.product_id, summary.total = Object.values(summary).reduce((a, b) => a + b, 0);
title: row.title,
sku: row.sku, console.log('Final inventory health summary:', summary);
total_sales: parseFloat(row.total_sales || 0), res.json(summary);
sales_growth: parseFloat(row.sales_growth || 0),
stock_quantity: parseInt(row.stock_quantity || 0),
image_url: row.image || null
})));
} catch (error) { } catch (error) {
console.error('Error in trending products:', { console.error('Error fetching inventory health summary:', error);
message: error.message, res.status(500).json({ error: 'Failed to fetch inventory health summary' });
stack: error.stack,
code: error.code,
sqlState: error.sqlState,
sqlMessage: error.sqlMessage
});
res.status(500).json({
error: 'Failed to fetch trending products',
details: error.message
});
} }
}); });
// Get inventory metrics // Get low stock alerts
router.get('/inventory-metrics', async (req, res) => { router.get('/inventory/low-stock', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
// Get global configuration values const [rows] = await pool.query(`
const [configs] = await pool.query(`
SELECT SELECT
st.low_stock_threshold, p.product_id,
tc.calculation_period_days as turnover_period p.sku,
FROM stock_thresholds st p.title,
CROSS JOIN turnover_config tc p.stock_quantity,
WHERE st.id = 1 AND tc.id = 1 pm.reorder_point,
pm.days_of_inventory,
pm.daily_sales_avg,
pm.stock_status
FROM product_metrics pm
JOIN products p ON pm.product_id = p.product_id
WHERE pm.stock_status IN ('Critical', 'Reorder')
ORDER BY
CASE pm.stock_status
WHEN 'Critical' THEN 1
WHEN 'Reorder' THEN 2
ELSE 3
END,
pm.days_of_inventory ASC
LIMIT 50
`); `);
res.json(rows);
const config = configs[0] || { } catch (error) {
low_stock_threshold: 5, console.error('Error fetching low stock alerts:', error);
turnover_period: 30 res.status(500).json({ error: 'Failed to fetch low stock alerts' });
}; }
});
// Get stock levels by category // Get vendor performance metrics
const [stockLevels] = await pool.query(` router.get('/vendors/metrics', async (req, res) => {
SELECT const pool = req.app.locals.pool;
c.name as category, try {
SUM(CASE WHEN stock_quantity > ? THEN 1 ELSE 0 END) as inStock, const [rows] = await pool.query(`
SUM(CASE WHEN stock_quantity > 0 AND stock_quantity <= ? THEN 1 ELSE 0 END) as lowStock,
SUM(CASE WHEN stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock
FROM products p
JOIN product_categories pc ON p.product_id = pc.product_id
JOIN categories c ON pc.category_id = c.id
WHERE visible = true
GROUP BY c.name
ORDER BY c.name ASC
`, [config.low_stock_threshold, config.low_stock_threshold]);
// Get top vendors with product counts and average stock
const [topVendors] = await pool.query(`
SELECT SELECT
vendor, vendor,
COUNT(*) as productCount, avg_lead_time_days,
AVG(stock_quantity) as averageStockLevel on_time_delivery_rate,
FROM products order_fill_rate,
WHERE visible = true total_orders,
AND vendor IS NOT NULL total_late_orders,
AND vendor != '' total_purchase_value,
GROUP BY vendor avg_order_value
ORDER BY productCount DESC FROM vendor_metrics
LIMIT 5 ORDER BY on_time_delivery_rate DESC
`); `);
res.json(rows.map(row => ({
// Calculate stock turnover rate by category ...row,
const [stockTurnover] = await pool.query(` avg_lead_time_days: parseFloat(row.avg_lead_time_days || 0),
WITH CategorySales AS ( on_time_delivery_rate: parseFloat(row.on_time_delivery_rate || 0),
SELECT order_fill_rate: parseFloat(row.order_fill_rate || 0),
c.name as category, total_purchase_value: parseFloat(row.total_purchase_value || 0),
SUM(o.quantity) as units_sold avg_order_value: parseFloat(row.avg_order_value || 0)
FROM products p })));
LEFT JOIN orders o ON p.product_id = o.product_id
JOIN product_categories pc ON p.product_id = pc.product_id
JOIN categories c ON pc.category_id = c.id
WHERE o.canceled = false
AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY c.name
),
CategoryStock AS (
SELECT
c.name as category,
AVG(p.stock_quantity) as avg_stock
FROM products p
JOIN product_categories pc ON p.product_id = pc.product_id
JOIN categories c ON pc.category_id = c.id
WHERE p.visible = true
GROUP BY c.name
)
SELECT
cs.category,
CASE
WHEN cst.avg_stock > 0 THEN (cs.units_sold / cst.avg_stock)
ELSE 0
END as rate
FROM CategorySales cs
JOIN CategoryStock cst ON cs.category = cst.category
ORDER BY rate DESC
`, [config.turnover_period]);
res.json({ stockLevels, topVendors, stockTurnover });
} catch (error) { } catch (error) {
console.error('Error fetching inventory metrics:', error); console.error('Error fetching vendor metrics:', error);
res.status(500).json({ error: 'Failed to fetch inventory metrics' }); res.status(500).json({ error: 'Failed to fetch vendor metrics' });
} }
}); });

View File

@@ -0,0 +1,60 @@
const express = require('express');
const router = express.Router();
// Get key metrics trends (revenue, inventory value, GMROI)
router.get('/trends', async (req, res) => {
const pool = req.app.locals.pool;
try {
const [rows] = await pool.query(`
WITH MonthlyMetrics AS (
SELECT
DATE(CONCAT(pta.year, '-', LPAD(pta.month, 2, '0'), '-01')) as date,
SUM(pta.total_revenue) as revenue,
SUM(pta.total_cost) as cost,
SUM(pm.inventory_value) as inventory_value,
CASE
WHEN SUM(pm.inventory_value) > 0
THEN (SUM(pta.total_revenue - pta.total_cost) / SUM(pm.inventory_value)) * 100
ELSE 0
END as gmroi
FROM product_time_aggregates pta
JOIN product_metrics pm ON pta.product_id = pm.product_id
WHERE (pta.year * 100 + pta.month) >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 12 MONTH), '%Y%m')
GROUP BY pta.year, pta.month
ORDER BY date ASC
)
SELECT
DATE_FORMAT(date, '%b %y') as date,
ROUND(revenue, 2) as revenue,
ROUND(inventory_value, 2) as inventory_value,
ROUND(gmroi, 2) as gmroi
FROM MonthlyMetrics
`);
console.log('Raw metrics trends data:', rows);
// Transform the data into the format expected by the frontend
const transformedData = {
revenue: rows.map(row => ({
date: row.date,
value: parseFloat(row.revenue || 0)
})),
inventory_value: rows.map(row => ({
date: row.date,
value: parseFloat(row.inventory_value || 0)
})),
gmroi: rows.map(row => ({
date: row.date,
value: parseFloat(row.gmroi || 0)
}))
};
console.log('Transformed metrics data:', transformedData);
res.json(transformedData);
} catch (error) {
console.error('Error fetching metrics trends:', error);
res.status(500).json({ error: 'Failed to fetch metrics trends' });
}
});
module.exports = router;

View File

@@ -139,6 +139,56 @@ router.get('/', async (req, res) => {
} }
}); });
// Get trending products
router.get('/trending', async (req, res) => {
const pool = req.app.locals.pool;
try {
// First check if we have any data
const [checkData] = await pool.query(`
SELECT COUNT(*) as count,
MAX(total_revenue) as max_revenue,
MAX(daily_sales_avg) as max_daily_sales,
COUNT(DISTINCT product_id) as products_with_metrics
FROM product_metrics
WHERE total_revenue > 0 OR daily_sales_avg > 0
`);
console.log('Product metrics stats:', checkData[0]);
if (checkData[0].count === 0) {
console.log('No products with metrics found');
return res.json([]);
}
// Get trending products
const [rows] = await pool.query(`
SELECT
p.product_id,
p.sku,
p.title,
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
COALESCE(pm.weekly_sales_avg, 0) as weekly_sales_avg,
CASE
WHEN pm.weekly_sales_avg > 0 AND pm.daily_sales_avg > 0
THEN ((pm.daily_sales_avg - pm.weekly_sales_avg) / pm.weekly_sales_avg) * 100
ELSE 0
END as growth_rate,
COALESCE(pm.total_revenue, 0) as total_revenue
FROM products p
INNER JOIN product_metrics pm ON p.product_id = pm.product_id
WHERE (pm.total_revenue > 0 OR pm.daily_sales_avg > 0)
AND p.visible = true
ORDER BY growth_rate DESC
LIMIT 50
`);
console.log('Trending products:', rows);
res.json(rows);
} catch (error) {
console.error('Error fetching trending products:', error);
res.status(500).json({ error: 'Failed to fetch trending products' });
}
});
// Get a single product // Get a single product
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;

View File

@@ -13,6 +13,7 @@ const csvRouter = require('./routes/csv');
const analyticsRouter = require('./routes/analytics'); 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');
// Get the absolute path to the .env file // Get the absolute path to the .env file
const envPath = path.resolve(process.cwd(), '.env'); const envPath = path.resolve(process.cwd(), '.env');
@@ -78,13 +79,14 @@ const pool = initPool({
app.locals.pool = pool; app.locals.pool = pool;
// Routes // Routes
app.use('/api/products', productsRouter); app.use('/api/dashboard/products', productsRouter);
app.use('/api/dashboard', dashboardRouter); app.use('/api/dashboard', dashboardRouter);
app.use('/api/orders', ordersRouter); app.use('/api/orders', ordersRouter);
app.use('/api/csv', csvRouter); app.use('/api/csv', csvRouter);
app.use('/api/analytics', analyticsRouter); app.use('/api/analytics', analyticsRouter);
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);
// Basic health check route // Basic health check route
app.get('/health', (req, res) => { app.get('/health', (req, res) => {

View File

@@ -1,117 +1,77 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Pie } from "react-chartjs-2"
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { AlertCircle, AlertTriangle, CheckCircle2, PackageSearch } from "lucide-react"
import config from "@/config"
ChartJS.register(ArcElement, Tooltip, Legend) interface InventoryHealth {
interface InventoryHealthData {
critical: number critical: number
reorder: number reorder: number
healthy: number healthy: number
overstock: number overstock: number
total: number
} }
export function InventoryHealthSummary() { export function InventoryHealthSummary() {
const { data, isLoading } = useQuery<InventoryHealthData>({ const { data: summary } = useQuery<InventoryHealth>({
queryKey: ['inventoryHealth'], queryKey: ["inventory-health"],
queryFn: async () => { queryFn: async () => {
const response = await fetch('/api/inventory/health/summary') const response = await fetch(`${config.apiUrl}/dashboard/inventory/health/summary`)
if (!response.ok) { if (!response.ok) {
throw new Error('Network response was not ok') throw new Error("Failed to fetch inventory health")
} }
return response.json() return response.json()
} },
}) })
const chartData = { const stats = [
labels: ['Critical', 'Reorder', 'Healthy', 'Overstock'], {
datasets: [ title: "Critical Stock",
{ value: summary?.critical || 0,
data: [ description: "Products needing immediate attention",
data?.critical || 0, icon: AlertCircle,
data?.reorder || 0, className: "bg-destructive/10",
data?.healthy || 0, iconClassName: "text-destructive",
data?.overstock || 0
],
backgroundColor: [
'rgb(239, 68, 68)', // red-500
'rgb(234, 179, 8)', // yellow-500
'rgb(34, 197, 94)', // green-500
'rgb(59, 130, 246)', // blue-500
],
borderColor: [
'rgb(239, 68, 68, 0.2)',
'rgb(234, 179, 8, 0.2)',
'rgb(34, 197, 94, 0.2)',
'rgb(59, 130, 246, 0.2)',
],
borderWidth: 1,
},
],
}
const options = {
responsive: true,
plugins: {
legend: {
position: 'right' as const,
},
}, },
} {
title: "Reorder Soon",
const total = data ? data.critical + data.reorder + data.healthy + data.overstock : 0 value: summary?.reorder || 0,
description: "Products approaching reorder point",
icon: AlertTriangle,
className: "bg-warning/10",
iconClassName: "text-warning",
},
{
title: "Healthy Stock",
value: summary?.healthy || 0,
description: "Products at optimal levels",
icon: CheckCircle2,
className: "bg-success/10",
iconClassName: "text-success",
},
{
title: "Overstock",
value: summary?.overstock || 0,
description: "Products exceeding optimal levels",
icon: PackageSearch,
className: "bg-muted",
iconClassName: "text-muted-foreground",
},
]
return ( return (
<Card className="col-span-4"> <>
<CardHeader> {stats.map((stat) => (
<CardTitle>Inventory Health</CardTitle> <Card key={stat.title} className={stat.className}>
</CardHeader> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardContent> <CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<div className="grid grid-cols-2 gap-4"> <stat.icon className={`h-4 w-4 ${stat.iconClassName}`} />
<div className="h-[200px] flex items-center justify-center"> </CardHeader>
{!isLoading && <Pie data={chartData} options={options} />} <CardContent>
</div> <div className="text-2xl font-bold">{stat.value}</div>
<div className="grid grid-cols-2 gap-2"> <p className="text-xs text-muted-foreground">{stat.description}</p>
<div className="flex flex-col"> </CardContent>
<span className="text-sm font-medium">Critical</span> </Card>
<span className="text-2xl font-bold text-red-500"> ))}
{data?.critical || 0} </>
</span>
<span className="text-xs text-muted-foreground">
{total ? Math.round((data?.critical / total) * 100) : 0}% of total
</span>
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">Reorder</span>
<span className="text-2xl font-bold text-yellow-500">
{data?.reorder || 0}
</span>
<span className="text-xs text-muted-foreground">
{total ? Math.round((data?.reorder / total) * 100) : 0}% of total
</span>
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">Healthy</span>
<span className="text-2xl font-bold text-green-500">
{data?.healthy || 0}
</span>
<span className="text-xs text-muted-foreground">
{total ? Math.round((data?.healthy / total) * 100) : 0}% of total
</span>
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">Overstock</span>
<span className="text-2xl font-bold text-blue-500">
{data?.overstock || 0}
</span>
<span className="text-xs text-muted-foreground">
{total ? Math.round((data?.overstock / total) * 100) : 0}% of total
</span>
</div>
</div>
</div>
</CardContent>
</Card>
) )
} }

View File

@@ -1,135 +1,232 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { Line } from "react-chartjs-2" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { import {
Chart as ChartJS, Area,
CategoryScale, AreaChart,
LinearScale, ResponsiveContainer,
PointElement,
LineElement,
Title,
Tooltip, Tooltip,
Legend, XAxis,
TimeScale YAxis,
} from 'chart.js' } from "recharts"
import 'chartjs-adapter-date-fns' import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import config from "@/config"
ChartJS.register( interface MetricDataPoint {
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
TimeScale
)
interface TimeSeriesData {
date: string date: string
revenue: number value: number
cost: number }
inventory_value: number
interface KeyMetrics {
revenue: MetricDataPoint[]
inventory_value: MetricDataPoint[]
gmroi: MetricDataPoint[]
} }
export function KeyMetricsCharts() { export function KeyMetricsCharts() {
const { data, isLoading } = useQuery<TimeSeriesData[]>({ const { data: metrics } = useQuery<KeyMetrics>({
queryKey: ['keyMetrics'], queryKey: ["key-metrics"],
queryFn: async () => { queryFn: async () => {
const response = await fetch('/api/metrics/timeseries') const response = await fetch(`${config.apiUrl}/metrics/trends`)
if (!response.ok) { if (!response.ok) {
throw new Error('Network response was not ok') throw new Error("Failed to fetch metrics trends")
} }
return response.json() return response.json()
} },
}) })
const revenueVsCostData = { const formatCurrency = (value: number) =>
labels: data?.map(d => d.date), new Intl.NumberFormat("en-US", {
datasets: [ style: "currency",
{ currency: "USD",
label: 'Revenue', minimumFractionDigits: 0,
data: data?.map(d => d.revenue), maximumFractionDigits: 0,
borderColor: 'rgb(34, 197, 94)', // green-500 }).format(value)
backgroundColor: 'rgba(34, 197, 94, 0.5)',
tension: 0.3,
},
{
label: 'Cost',
data: data?.map(d => d.cost),
borderColor: 'rgb(239, 68, 68)', // red-500
backgroundColor: 'rgba(239, 68, 68, 0.5)',
tension: 0.3,
}
]
}
const inventoryValueData = {
labels: data?.map(d => d.date),
datasets: [
{
label: 'Inventory Value',
data: data?.map(d => d.inventory_value),
borderColor: 'rgb(59, 130, 246)', // blue-500
backgroundColor: 'rgba(59, 130, 246, 0.5)',
tension: 0.3,
fill: true,
}
]
}
const options = {
responsive: true,
interaction: {
mode: 'index' as const,
intersect: false,
},
scales: {
x: {
type: 'time' as const,
time: {
unit: 'month' as const,
},
title: {
display: true,
text: 'Date'
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: 'Amount ($)'
}
}
}
}
return ( return (
<Card className="col-span-8"> <>
<CardHeader> <CardHeader>
<CardTitle>Key Financial Metrics</CardTitle> <CardTitle className="text-lg font-medium">Key Metrics</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4"> <Tabs defaultValue="revenue" className="space-y-4">
<div className="h-[200px]"> <TabsList>
{!isLoading && ( <TabsTrigger value="revenue">Revenue</TabsTrigger>
<Line <TabsTrigger value="inventory">Inventory Value</TabsTrigger>
data={revenueVsCostData} <TabsTrigger value="gmroi">GMROI</TabsTrigger>
options={options} </TabsList>
/>
)} <TabsContent value="revenue" className="space-y-4">
</div> <div className="h-[300px]">
<div className="h-[200px]"> <ResponsiveContainer width="100%" height="100%">
{!isLoading && ( <AreaChart data={metrics?.revenue}>
<Line <XAxis
data={inventoryValueData} dataKey="date"
options={options} tickLine={false}
/> axisLine={false}
)} tickFormatter={(value) => value}
</div> />
</div> <YAxis
tickLine={false}
axisLine={false}
tickFormatter={formatCurrency}
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Date
</span>
<span className="font-bold">
{payload[0].payload.date}
</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Revenue
</span>
<span className="font-bold">
{formatCurrency(payload[0].value as number)}
</span>
</div>
</div>
</div>
)
}
return null
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="#0ea5e9"
fill="#0ea5e9"
fillOpacity={0.2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</TabsContent>
<TabsContent value="inventory" className="space-y-4">
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={metrics?.inventory_value}>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickFormatter={(value) => value}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={formatCurrency}
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Date
</span>
<span className="font-bold">
{payload[0].payload.date}
</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Value
</span>
<span className="font-bold">
{formatCurrency(payload[0].value as number)}
</span>
</div>
</div>
</div>
)
}
return null
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="#84cc16"
fill="#84cc16"
fillOpacity={0.2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</TabsContent>
<TabsContent value="gmroi" className="space-y-4">
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={metrics?.gmroi}>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickFormatter={(value) => value}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value.toFixed(1)}%`}
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Date
</span>
<span className="font-bold">
{payload[0].payload.date}
</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
GMROI
</span>
<span className="font-bold">
{`${typeof payload[0].value === 'number' ? payload[0].value.toFixed(1) : payload[0].value}%`}
</span>
</div>
</div>
</div>
)
}
return null
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="#f59e0b"
fill="#f59e0b"
fillOpacity={0.2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</TabsContent>
</Tabs>
</CardContent> </CardContent>
</Card> </>
) )
} }

View File

@@ -0,0 +1,87 @@
import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { AlertCircle, AlertTriangle } from "lucide-react"
import config from "@/config"
interface LowStockProduct {
product_id: number
sku: string
title: string
stock_quantity: number
reorder_point: number
days_of_inventory: number
stock_status: "Critical" | "Reorder"
daily_sales_avg: number
}
export function LowStockAlerts() {
const { data: products } = useQuery<LowStockProduct[]>({
queryKey: ["low-stock"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/inventory/low-stock`)
if (!response.ok) {
throw new Error("Failed to fetch low stock products")
}
return response.json()
},
})
return (
<>
<CardHeader>
<CardTitle className="text-lg font-medium">Low Stock Alerts</CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-[350px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>SKU</TableHead>
<TableHead>Product</TableHead>
<TableHead className="text-right">Stock</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products?.map((product) => (
<TableRow key={product.product_id}>
<TableCell className="font-medium">{product.sku}</TableCell>
<TableCell>{product.title}</TableCell>
<TableCell className="text-right">
{product.stock_quantity} / {product.reorder_point}
</TableCell>
<TableCell className="text-right">
<Badge
variant="outline"
className={
product.stock_status === "Critical"
? "border-destructive text-destructive"
: "border-warning text-warning"
}
>
{product.stock_status === "Critical" ? (
<AlertCircle className="mr-1 h-3 w-3" />
) : (
<AlertTriangle className="mr-1 h-3 w-3" />
)}
{product.stock_status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</>
)
}

View File

@@ -1,93 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { useQuery } from "@tanstack/react-query"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Link } from "react-router-dom"
interface StockAlert {
product_id: number
sku: string
title: string
stock_quantity: number
daily_sales_avg: number
days_of_inventory: number
reorder_point: number
stock_status: 'Critical' | 'Reorder'
}
export function StockAlerts() {
const { data, isLoading } = useQuery<StockAlert[]>({
queryKey: ['stockAlerts'],
queryFn: async () => {
const response = await fetch('/api/inventory/alerts')
if (!response.ok) {
throw new Error('Network response was not ok')
}
return response.json()
}
})
return (
<Card className="col-span-8">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Low Stock Alerts</CardTitle>
<Button asChild>
<Link to="/inventory/replenishment">View All</Link>
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>SKU</TableHead>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Stock</TableHead>
<TableHead className="text-right">Daily Sales</TableHead>
<TableHead className="text-right">Days Left</TableHead>
<TableHead className="text-right">Reorder Point</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!isLoading && data?.map((alert) => (
<TableRow key={alert.product_id}>
<TableCell className="font-medium">
<Link
to={`/products/${alert.product_id}`}
className="text-blue-500 hover:underline"
>
{alert.sku}
</Link>
</TableCell>
<TableCell>{alert.title}</TableCell>
<TableCell>
<Badge
variant={alert.stock_status === 'Critical' ? 'destructive' : 'warning'}
>
{alert.stock_status}
</Badge>
</TableCell>
<TableCell className="text-right">{alert.stock_quantity}</TableCell>
<TableCell className="text-right">
{alert.daily_sales_avg.toFixed(1)}
</TableCell>
<TableCell className="text-right">
{alert.days_of_inventory.toFixed(1)}
</TableCell>
<TableCell className="text-right">{alert.reorder_point}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)
}

View File

@@ -1,76 +1,96 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { Link } from "react-router-dom" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { ArrowUpIcon, ArrowDownIcon } from "lucide-react" import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { TrendingUp, TrendingDown } from "lucide-react"
import config from "@/config"
interface TrendingProduct { interface TrendingProduct {
product_id: number product_id: number
sku: string sku: string
title: string title: string
daily_sales_avg: number daily_sales_avg: string
weekly_sales_avg: number weekly_sales_avg: string
growth_rate: number // Percentage growth week over week growth_rate: string
total_revenue: number total_revenue: string
} }
export function TrendingProducts() { export function TrendingProducts() {
const { data, isLoading } = useQuery<TrendingProduct[]>({ const { data: products } = useQuery<TrendingProduct[]>({
queryKey: ['trendingProducts'], queryKey: ["trending-products"],
queryFn: async () => { queryFn: async () => {
const response = await fetch('/api/products/trending') const response = await fetch(`${config.apiUrl}/dashboard/products/trending`)
if (!response.ok) { if (!response.ok) {
throw new Error('Network response was not ok') throw new Error("Failed to fetch trending products")
} }
return response.json() return response.json()
} },
}) })
const formatPercent = (value: number) =>
new Intl.NumberFormat("en-US", {
style: "percent",
minimumFractionDigits: 1,
maximumFractionDigits: 1,
signDisplay: "exceptZero",
}).format(value / 100)
return ( return (
<Card className="col-span-4"> <>
<CardHeader> <CardHeader>
<CardTitle>Trending Products</CardTitle> <CardTitle className="text-lg font-medium">Trending Products</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-8"> <div className="max-h-[400px] overflow-auto">
{!isLoading && data?.map((product) => ( <Table>
<div key={product.product_id} className="flex items-center"> <TableHeader>
<div className="space-y-1 flex-1"> <TableRow>
<Link <TableHead>Product</TableHead>
to={`/products/${product.product_id}`} <TableHead>Daily Sales</TableHead>
className="text-sm font-medium leading-none hover:underline" <TableHead className="text-right">Growth</TableHead>
> </TableRow>
{product.sku} </TableHeader>
</Link> <TableBody>
<p className="text-sm text-muted-foreground line-clamp-1"> {products?.map((product) => (
{product.title} <TableRow key={product.product_id}>
</p> <TableCell className="font-medium">
</div> <div className="flex flex-col">
<div className="ml-auto font-medium text-right space-y-1"> <span className="font-medium">{product.title}</span>
<div className="flex items-center justify-end gap-2"> <span className="text-sm text-muted-foreground">
<span className="text-sm"> {product.sku}
{product.daily_sales_avg.toFixed(1)}/day </span>
</span> </div>
<div className={`flex items-center ${ </TableCell>
product.growth_rate >= 0 ? 'text-green-500' : 'text-red-500' <TableCell>{parseFloat(product.daily_sales_avg).toFixed(1)}</TableCell>
}`}> <TableCell className="text-right">
{product.growth_rate >= 0 ? ( <div className="flex items-center justify-end gap-1">
<ArrowUpIcon className="h-4 w-4" /> {parseFloat(product.growth_rate) > 0 ? (
) : ( <TrendingUp className="h-4 w-4 text-success" />
<ArrowDownIcon className="h-4 w-4" /> ) : (
)} <TrendingDown className="h-4 w-4 text-destructive" />
<span className="text-xs"> )}
{Math.abs(product.growth_rate).toFixed(1)}% <span
</span> className={
</div> parseFloat(product.growth_rate) > 0 ? "text-success" : "text-destructive"
</div> }
<p className="text-xs text-muted-foreground"> >
${product.total_revenue.toLocaleString()} {formatPercent(parseFloat(product.growth_rate))}
</p> </span>
</div> </div>
</div> </TableCell>
))} </TableRow>
))}
</TableBody>
</Table>
</div> </div>
</CardContent> </CardContent>
</Card> </>
) )
} }

View File

@@ -1,6 +1,15 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import config from "@/config"
interface VendorMetrics { interface VendorMetrics {
vendor: string vendor: string
@@ -12,66 +21,58 @@ interface VendorMetrics {
} }
export function VendorPerformance() { export function VendorPerformance() {
const { data, isLoading } = useQuery<VendorMetrics[]>({ const { data: vendors } = useQuery<VendorMetrics[]>({
queryKey: ['vendorMetrics'], queryKey: ["vendor-metrics"],
queryFn: async () => { queryFn: async () => {
const response = await fetch('/api/vendors/metrics') const response = await fetch(`${config.apiUrl}/dashboard/vendors/metrics`)
if (!response.ok) { if (!response.ok) {
throw new Error('Network response was not ok') throw new Error("Failed to fetch vendor metrics")
} }
return response.json() return response.json()
} },
}) })
// Sort vendors by on-time delivery rate // Sort vendors by on-time delivery rate
const sortedVendors = data?.sort((a, b) => const sortedVendors = vendors
b.on_time_delivery_rate - a.on_time_delivery_rate ?.sort((a, b) => b.on_time_delivery_rate - a.on_time_delivery_rate)
).slice(0, 5)
return ( return (
<Card className="col-span-4"> <>
<CardHeader> <CardHeader>
<CardTitle>Top Vendor Performance</CardTitle> <CardTitle className="text-lg font-medium">Top Vendor Performance</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="max-h-[400px] overflow-auto">
<div className="space-y-8"> <Table>
{!isLoading && sortedVendors?.map((vendor) => ( <TableHeader>
<div key={vendor.vendor} className="space-y-2"> <TableRow>
<div className="flex items-center"> <TableHead>Vendor</TableHead>
<div className="flex-1 space-y-1"> <TableHead>On-Time</TableHead>
<p className="text-sm font-medium leading-none"> <TableHead className="text-right">Fill Rate</TableHead>
{vendor.vendor} </TableRow>
</p> </TableHeader>
<p className="text-sm text-muted-foreground"> <TableBody>
{vendor.total_orders} orders, avg {vendor.avg_lead_time_days.toFixed(1)} days {sortedVendors?.map((vendor) => (
</p> <TableRow key={vendor.vendor}>
</div> <TableCell className="font-medium">{vendor.vendor}</TableCell>
<div className="ml-auto font-medium"> <TableCell>
{vendor.on_time_delivery_rate.toFixed(1)}% <div className="flex items-center gap-2">
</div> <Progress
</div> value={vendor.on_time_delivery_rate}
<div className="space-y-1"> className="h-2"
<div className="flex items-center text-xs text-muted-foreground justify-between"> />
<span>On-time Delivery</span> <span className="w-10 text-sm">
<span>{vendor.on_time_delivery_rate.toFixed(1)}%</span> {vendor.on_time_delivery_rate.toFixed(0)}%
</div> </span>
<Progress </div>
value={vendor.on_time_delivery_rate} </TableCell>
className="h-1" <TableCell className="text-right">
/> {vendor.order_fill_rate.toFixed(0)}%
<div className="flex items-center text-xs text-muted-foreground justify-between"> </TableCell>
<span>Order Fill Rate</span> </TableRow>
<span>{vendor.order_fill_rate.toFixed(1)}%</span> ))}
</div> </TableBody>
<Progress </Table>
value={vendor.order_fill_rate}
className="h-1"
/>
</div>
</div>
))}
</div>
</CardContent> </CardContent>
</Card> </>
) )
} }

View File

@@ -1,4 +1,4 @@
const isDev = process.env.NODE_ENV === 'development'; const isDev = import.meta.env.DEV;
const config = { const config = {
apiUrl: isDev ? '/api' : 'https://inventory.kent.pw/api', apiUrl: isDev ? '/api' : 'https://inventory.kent.pw/api',

View File

@@ -1,22 +1,37 @@
import { Card } from "@/components/ui/card"
import { InventoryHealthSummary } from "@/components/dashboard/InventoryHealthSummary" import { InventoryHealthSummary } from "@/components/dashboard/InventoryHealthSummary"
import { StockAlerts } from "@/components/dashboard/StockAlerts" import { LowStockAlerts } from "@/components/dashboard/LowStockAlerts"
import { TrendingProducts } from "@/components/dashboard/TrendingProducts" import { TrendingProducts } from "@/components/dashboard/TrendingProducts"
import { VendorPerformance } from "@/components/dashboard/VendorPerformance" import { VendorPerformance } from "@/components/dashboard/VendorPerformance"
import { KeyMetricsCharts } from "@/components/dashboard/KeyMetricsCharts" import { KeyMetricsCharts } from "@/components/dashboard/KeyMetricsCharts"
export function Dashboard() { export function Dashboard() {
return ( return (
<div className="flex-1 space-y-4 p-8 pt-6"> <div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
<div className="flex items-center justify-between space-y-2"> <div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2> <h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
</div> </div>
<div className="grid gap-4 md:grid-cols-12"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<InventoryHealthSummary /> <InventoryHealthSummary />
<VendorPerformance /> </div>
<KeyMetricsCharts /> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<StockAlerts /> <Card className="col-span-4">
<TrendingProducts /> <KeyMetricsCharts />
</Card>
<Card className="col-span-3">
<LowStockAlerts />
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4">
<TrendingProducts />
</Card>
<Card className="col-span-3">
<VendorPerformance />
</Card>
</div> </div>
</div> </div>
) )
} }
export default Dashboard