Add new dashboard backend
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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('Trending products query result:', rows);
|
console.log('Raw inventory health summary:', rows);
|
||||||
|
|
||||||
res.json(rows.map(row => ({
|
// Convert array to object with lowercase keys
|
||||||
product_id: row.product_id,
|
const summary = {
|
||||||
title: row.title,
|
critical: 0,
|
||||||
sku: row.sku,
|
reorder: 0,
|
||||||
total_sales: parseFloat(row.total_sales || 0),
|
healthy: 0,
|
||||||
sales_growth: parseFloat(row.sales_growth || 0),
|
overstock: 0
|
||||||
stock_quantity: parseInt(row.stock_quantity || 0),
|
};
|
||||||
image_url: row.image || null
|
|
||||||
})));
|
rows.forEach(row => {
|
||||||
|
const key = row.stock_status.toLowerCase();
|
||||||
|
if (key in summary) {
|
||||||
|
summary[key] = parseInt(row.count);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate total
|
||||||
|
summary.total = Object.values(summary).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
console.log('Final inventory health summary:', summary);
|
||||||
|
res.json(summary);
|
||||||
} 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);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching low stock alerts:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch low stock alerts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const config = configs[0] || {
|
// Get vendor performance metrics
|
||||||
low_stock_threshold: 5,
|
router.get('/vendors/metrics', async (req, res) => {
|
||||||
turnover_period: 30
|
const pool = req.app.locals.pool;
|
||||||
};
|
try {
|
||||||
|
const [rows] = await pool.query(`
|
||||||
// Get stock levels by category
|
|
||||||
const [stockLevels] = await pool.query(`
|
|
||||||
SELECT
|
|
||||||
c.name as category,
|
|
||||||
SUM(CASE WHEN stock_quantity > ? THEN 1 ELSE 0 END) as inStock,
|
|
||||||
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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
60
inventory-server/src/routes/metrics.js
Normal file
60
inventory-server/src/routes/metrics.js
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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: [
|
|
||||||
{
|
{
|
||||||
data: [
|
title: "Critical Stock",
|
||||||
data?.critical || 0,
|
value: summary?.critical || 0,
|
||||||
data?.reorder || 0,
|
description: "Products needing immediate attention",
|
||||||
data?.healthy || 0,
|
icon: AlertCircle,
|
||||||
data?.overstock || 0
|
className: "bg-destructive/10",
|
||||||
],
|
iconClassName: "text-destructive",
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
}
|
title: "Reorder Soon",
|
||||||
|
value: summary?.reorder || 0,
|
||||||
const options = {
|
description: "Products approaching reorder point",
|
||||||
responsive: true,
|
icon: AlertTriangle,
|
||||||
plugins: {
|
className: "bg-warning/10",
|
||||||
legend: {
|
iconClassName: "text-warning",
|
||||||
position: 'right' as const,
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Healthy Stock",
|
||||||
|
value: summary?.healthy || 0,
|
||||||
|
description: "Products at optimal levels",
|
||||||
|
icon: CheckCircle2,
|
||||||
|
className: "bg-success/10",
|
||||||
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
}
|
{
|
||||||
|
title: "Overstock",
|
||||||
const total = data ? data.critical + data.reorder + data.healthy + data.overstock : 0
|
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 className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||||
|
<stat.icon className={`h-4 w-4 ${stat.iconClassName}`} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="text-2xl font-bold">{stat.value}</div>
|
||||||
<div className="h-[200px] flex items-center justify-center">
|
<p className="text-xs text-muted-foreground">{stat.description}</p>
|
||||||
{!isLoading && <Pie data={chartData} options={options} />}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium">Critical</span>
|
|
||||||
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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 className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={metrics?.revenue}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(value) => value}
|
||||||
/>
|
/>
|
||||||
)}
|
<YAxis
|
||||||
</div>
|
tickLine={false}
|
||||||
<div className="h-[200px]">
|
axisLine={false}
|
||||||
{!isLoading && (
|
tickFormatter={formatCurrency}
|
||||||
<Line
|
|
||||||
data={inventoryValueData}
|
|
||||||
options={options}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<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>
|
</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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
87
inventory/src/components/dashboard/LowStockAlerts.tsx
Normal file
87
inventory/src/components/dashboard/LowStockAlerts.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{products?.map((product) => (
|
||||||
|
<TableRow key={product.product_id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{product.title}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
{product.sku}
|
{product.sku}
|
||||||
</Link>
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
|
||||||
{product.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto font-medium text-right space-y-1">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<span className="text-sm">
|
|
||||||
{product.daily_sales_avg.toFixed(1)}/day
|
|
||||||
</span>
|
</span>
|
||||||
<div className={`flex items-center ${
|
</div>
|
||||||
product.growth_rate >= 0 ? 'text-green-500' : 'text-red-500'
|
</TableCell>
|
||||||
}`}>
|
<TableCell>{parseFloat(product.daily_sales_avg).toFixed(1)}</TableCell>
|
||||||
{product.growth_rate >= 0 ? (
|
<TableCell className="text-right">
|
||||||
<ArrowUpIcon className="h-4 w-4" />
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{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">
|
<span
|
||||||
{Math.abs(product.growth_rate).toFixed(1)}%
|
className={
|
||||||
|
parseFloat(product.growth_rate) > 0 ? "text-success" : "text-destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatPercent(parseFloat(product.growth_rate))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
<p className="text-xs text-muted-foreground">
|
</TableRow>
|
||||||
${product.total_revenue.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center text-xs text-muted-foreground justify-between">
|
|
||||||
<span>On-time Delivery</span>
|
|
||||||
<span>{vendor.on_time_delivery_rate.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
<Progress
|
||||||
value={vendor.on_time_delivery_rate}
|
value={vendor.on_time_delivery_rate}
|
||||||
className="h-1"
|
className="h-2"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center text-xs text-muted-foreground justify-between">
|
<span className="w-10 text-sm">
|
||||||
<span>Order Fill Rate</span>
|
{vendor.on_time_delivery_rate.toFixed(0)}%
|
||||||
<span>{vendor.order_fill_rate.toFixed(1)}%</span>
|
</span>
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
value={vendor.order_fill_rate}
|
|
||||||
className="h-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{vendor.order_fill_rate.toFixed(0)}%
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</div>
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
|
<Card className="col-span-4">
|
||||||
<KeyMetricsCharts />
|
<KeyMetricsCharts />
|
||||||
<StockAlerts />
|
</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 />
|
<TrendingProducts />
|
||||||
|
</Card>
|
||||||
|
<Card className="col-span-3">
|
||||||
|
<VendorPerformance />
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Dashboard
|
||||||
Reference in New Issue
Block a user