Add forecasting page

This commit is contained in:
2025-01-15 22:08:52 -05:00
parent e5f97ab836
commit c8c3d323a4
15 changed files with 893 additions and 94 deletions

View File

@@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS temp_purchase_metrics (
product_id BIGINT NOT NULL, product_id BIGINT NOT NULL,
avg_lead_time_days INT, avg_lead_time_days INT,
last_purchase_date DATE, last_purchase_date DATE,
first_received_date DATE,
last_received_date DATE, last_received_date DATE,
PRIMARY KEY (product_id) PRIMARY KEY (product_id)
); );
@@ -51,6 +52,7 @@ CREATE TABLE IF NOT EXISTS product_metrics (
-- Purchase metrics -- Purchase metrics
avg_lead_time_days INT, avg_lead_time_days INT,
last_purchase_date DATE, last_purchase_date DATE,
first_received_date DATE,
last_received_date DATE, last_received_date DATE,
-- Classification -- Classification
abc_class CHAR(1), abc_class CHAR(1),
@@ -107,6 +109,23 @@ CREATE TABLE IF NOT EXISTS vendor_metrics (
INDEX idx_vendor_performance (on_time_delivery_rate) INDEX idx_vendor_performance (on_time_delivery_rate)
); );
-- New table for category-based sales metrics
CREATE TABLE IF NOT EXISTS category_sales_metrics (
category_id BIGINT NOT NULL,
brand VARCHAR(100) NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
avg_daily_sales DECIMAL(10,3) DEFAULT 0,
total_sold INT DEFAULT 0,
num_products INT DEFAULT 0,
avg_price DECIMAL(10,3) DEFAULT 0,
last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (category_id, brand, period_start, period_end),
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
INDEX idx_category_brand (category_id, brand),
INDEX idx_period (period_start, period_end)
);
-- Re-enable foreign key checks -- Re-enable foreign key checks
SET FOREIGN_KEY_CHECKS = 1; SET FOREIGN_KEY_CHECKS = 1;
@@ -224,3 +243,26 @@ WHERE
o.canceled = false o.canceled = false
GROUP BY GROUP BY
p.product_id, p.SKU, p.title; p.product_id, p.SKU, p.title;
-- Create view for category sales trends
CREATE OR REPLACE VIEW category_sales_trends AS
SELECT
c.id as category_id,
c.name as category_name,
p.brand,
COUNT(DISTINCT p.product_id) as num_products,
COALESCE(AVG(o.quantity), 0) as avg_daily_sales,
COALESCE(SUM(o.quantity), 0) as total_sold,
COALESCE(AVG(o.price), 0) as avg_price,
MIN(o.date) as first_sale_date,
MAX(o.date) as last_sale_date
FROM
categories c
JOIN
product_categories pc ON c.id = pc.category_id
JOIN
products p ON pc.product_id = p.product_id
LEFT JOIN
orders o ON p.product_id = o.product_id AND o.canceled = false
GROUP BY
c.id, c.name, p.brand;

View File

@@ -466,7 +466,88 @@ async function calculateLeadTimeMetrics(connection, startTime, totalProducts) {
`); `);
} }
// Update the main calculation function to include our new calculations // Add new function for category sales metrics
async function calculateCategorySalesMetrics(connection, startTime, totalProducts) {
outputProgress({
status: 'running',
operation: 'Calculating category sales metrics',
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(`
INSERT INTO category_sales_metrics (
category_id,
brand,
period_start,
period_end,
avg_daily_sales,
total_sold,
num_products,
avg_price,
last_calculated_at
)
WITH date_ranges AS (
SELECT
DATE_SUB(CURDATE(), INTERVAL 30 DAY) as period_start,
CURDATE() as period_end
UNION ALL
SELECT
DATE_SUB(CURDATE(), INTERVAL 90 DAY),
CURDATE()
UNION ALL
SELECT
DATE_SUB(CURDATE(), INTERVAL 180 DAY),
CURDATE()
UNION ALL
SELECT
DATE_SUB(CURDATE(), INTERVAL 365 DAY),
CURDATE()
),
category_metrics AS (
SELECT
c.id as category_id,
p.brand,
dr.period_start,
dr.period_end,
COUNT(DISTINCT p.product_id) as num_products,
COALESCE(SUM(o.quantity), 0) / DATEDIFF(dr.period_end, dr.period_start) as avg_daily_sales,
COALESCE(SUM(o.quantity), 0) as total_sold,
COALESCE(AVG(o.price), 0) as avg_price
FROM categories c
JOIN product_categories pc ON c.id = pc.category_id
JOIN products p ON pc.product_id = p.product_id
CROSS JOIN date_ranges dr
LEFT JOIN orders o ON p.product_id = o.product_id
AND o.date BETWEEN dr.period_start AND dr.period_end
AND o.canceled = false
GROUP BY c.id, p.brand, dr.period_start, dr.period_end
)
SELECT
category_id,
brand,
period_start,
period_end,
avg_daily_sales,
total_sold,
num_products,
avg_price,
NOW() as last_calculated_at
FROM category_metrics
ON DUPLICATE KEY UPDATE
avg_daily_sales = VALUES(avg_daily_sales),
total_sold = VALUES(total_sold),
num_products = VALUES(num_products),
avg_price = VALUES(avg_price),
last_calculated_at = NOW()
`);
}
// Update the main calculation function to include category sales metrics
async function calculateMetrics() { async function calculateMetrics() {
let pool; let pool;
const startTime = Date.now(); const startTime = Date.now();
@@ -751,6 +832,7 @@ async function calculateMetrics() {
SUM(CASE WHEN received >= 0 THEN received ELSE 0 END) as total_quantity_purchased, SUM(CASE WHEN received >= 0 THEN received ELSE 0 END) as total_quantity_purchased,
SUM(CASE WHEN received >= 0 THEN cost_price * received ELSE 0 END) as total_cost, SUM(CASE WHEN received >= 0 THEN cost_price * received ELSE 0 END) as total_cost,
MAX(date) as last_purchase_date, MAX(date) as last_purchase_date,
MIN(received_date) as first_received_date,
MAX(received_date) as last_received_date, MAX(received_date) as last_received_date,
AVG(lead_time_days) as avg_lead_time_days, AVG(lead_time_days) as avg_lead_time_days,
COUNT(*) as orders_analyzed COUNT(*) as orders_analyzed
@@ -952,6 +1034,7 @@ async function calculateMetrics() {
inventory_value || 0, inventory_value || 0,
purchases.avg_lead_time_days || null, purchases.avg_lead_time_days || null,
purchases.last_purchase_date || null, purchases.last_purchase_date || null,
purchases.first_received_date || null,
purchases.last_received_date || null, purchases.last_received_date || null,
stock_status, stock_status,
reorder_qty, reorder_qty,
@@ -985,6 +1068,7 @@ async function calculateMetrics() {
inventory_value, inventory_value,
avg_lead_time_days, avg_lead_time_days,
last_purchase_date, last_purchase_date,
first_received_date,
last_received_date, last_received_date,
stock_status, stock_status,
reorder_qty, reorder_qty,
@@ -1008,6 +1092,7 @@ async function calculateMetrics() {
inventory_value = VALUES(inventory_value), inventory_value = VALUES(inventory_value),
avg_lead_time_days = VALUES(avg_lead_time_days), avg_lead_time_days = VALUES(avg_lead_time_days),
last_purchase_date = VALUES(last_purchase_date), last_purchase_date = VALUES(last_purchase_date),
first_received_date = VALUES(first_received_date),
last_received_date = VALUES(last_received_date), last_received_date = VALUES(last_received_date),
stock_status = VALUES(stock_status), stock_status = VALUES(stock_status),
reorder_qty = VALUES(reorder_qty), reorder_qty = VALUES(reorder_qty),
@@ -1067,6 +1152,12 @@ async function calculateMetrics() {
}); });
await calculateLeadTimeMetrics(connection, startTime, totalProducts); await calculateLeadTimeMetrics(connection, startTime, totalProducts);
// Add category sales metrics calculation
if (isCancelled) {
throw new Error('Operation cancelled');
}
await calculateCategorySalesMetrics(connection, startTime, totalProducts);
// Calculate ABC classification // Calculate ABC classification
if (isCancelled) { if (isCancelled) {
throw new Error('Operation cancelled'); throw new Error('Operation cancelled');

View File

@@ -21,7 +21,8 @@ const METRICS_TABLES = [
'temp_purchase_metrics', 'temp_purchase_metrics',
'product_metrics', 'product_metrics',
'product_time_aggregates', 'product_time_aggregates',
'vendor_metrics' 'vendor_metrics',
'category_sales_metrics'
]; ];
// Config tables that must exist // Config tables that must exist

View File

@@ -527,4 +527,72 @@ router.get('/categories', async (req, res) => {
} }
}); });
// Forecast endpoint
router.get('/forecast', async (req, res) => {
try {
const { brand, startDate, endDate } = req.query;
const pool = req.app.locals.pool;
const [results] = await pool.query(`
WITH category_metrics AS (
SELECT
c.id as category_id,
c.name as category_name,
p.brand,
COUNT(DISTINCT p.product_id) as num_products,
COALESCE(ROUND(SUM(o.quantity) / DATEDIFF(?, ?), 2), 0) as avg_daily_sales,
COALESCE(SUM(o.quantity), 0) as total_sold,
COALESCE(ROUND(SUM(o.quantity) / COUNT(DISTINCT p.product_id), 2), 0) as avgTotalSold,
COALESCE(ROUND(AVG(o.price), 2), 0) as avg_price
FROM categories c
JOIN product_categories pc ON c.id = pc.category_id
JOIN products p ON pc.product_id = p.product_id
LEFT JOIN orders o ON p.product_id = o.product_id
AND o.date BETWEEN ? AND ?
AND o.canceled = false
WHERE p.brand = ?
GROUP BY c.id, c.name, p.brand
),
product_metrics AS (
SELECT
p.product_id,
p.title,
p.sku,
p.stock_quantity,
pc.category_id,
COALESCE(SUM(o.quantity), 0) as total_sold,
COALESCE(ROUND(AVG(o.price), 2), 0) as avg_price
FROM products p
JOIN product_categories pc ON p.product_id = pc.product_id
LEFT JOIN orders o ON p.product_id = o.product_id
AND o.date BETWEEN ? AND ?
AND o.canceled = false
WHERE p.brand = ?
GROUP BY p.product_id, p.title, p.sku, p.stock_quantity, pc.category_id
)
SELECT
cm.*,
JSON_ARRAYAGG(
JSON_OBJECT(
'product_id', pm.product_id,
'name', pm.title,
'sku', pm.sku,
'stock_quantity', pm.stock_quantity,
'total_sold', pm.total_sold,
'avg_price', pm.avg_price
)
) as products
FROM category_metrics cm
JOIN product_metrics pm ON cm.category_id = pm.category_id
GROUP BY cm.category_id, cm.category_name, cm.brand, cm.num_products, cm.avg_daily_sales, cm.total_sold, cm.avgTotalSold, cm.avg_price
ORDER BY cm.total_sold DESC
`, [startDate, endDate, startDate, endDate, brand, startDate, endDate, brand]);
res.json(results);
} catch (error) {
console.error('Error fetching forecast data:', error);
res.status(500).json({ error: 'Failed to fetch forecast data' });
}
});
module.exports = router; module.exports = router;

View File

@@ -6,6 +6,36 @@ const { importProductsFromCSV } = require('../utils/csvImporter');
// Configure multer for file uploads // Configure multer for file uploads
const upload = multer({ dest: 'uploads/' }); const upload = multer({ dest: 'uploads/' });
// Get unique brands
router.get('/brands', async (req, res) => {
console.log('Brands endpoint hit:', {
url: req.url,
method: req.method,
headers: req.headers,
path: req.path
});
try {
const pool = req.app.locals.pool;
console.log('Fetching brands from database...');
const [results] = await pool.query(`
SELECT DISTINCT brand
FROM products
WHERE brand IS NOT NULL
AND brand != ''
AND visible = true
ORDER BY brand
`);
console.log(`Found ${results.length} brands:`, results.slice(0, 3));
res.json(results.map(r => r.brand));
} catch (error) {
console.error('Error fetching brands:', error);
res.status(500).json({ error: 'Failed to fetch brands' });
}
});
// Get all products with pagination, filtering, and sorting // Get all products with pagination, filtering, and sorting
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;

View File

@@ -11,6 +11,7 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",
@@ -19,6 +20,7 @@
"@radix-ui/react-label": "^2.1.1", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4", "@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
@@ -28,7 +30,9 @@
"@radix-ui/react-toggle-group": "^1.1.1", "@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"@shadcn/ui": "^0.0.4", "@shadcn/ui": "^0.0.4",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.63.0", "@tanstack/react-query": "^5.63.0",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.2", "@tanstack/react-virtual": "^3.11.2",
"@tanstack/virtual-core": "^3.11.2", "@tanstack/virtual-core": "^3.11.2",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
@@ -1243,6 +1247,37 @@
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.2.tgz",
"integrity": "sha512-b1oh54x4DMCdGsB4/7ahiSrViXxaBwRPotiZNnYXjLha9vfuURSAZErki6qjDoSIV0eXx5v57XnTGVtGwnfp2g==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-collection": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog": { "node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.4.tgz",
@@ -1829,6 +1864,37 @@
} }
} }
}, },
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz",
"integrity": "sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": { "node_modules/@radix-ui/react-select": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz",
@@ -2512,6 +2578,32 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@tabler/icons": {
"version": "3.28.1",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.28.1.tgz",
"integrity": "sha512-h7nqKEvFooLtFxhMOC1/2eiV+KRXhBUuDUUJrJlt6Ft6tuMw2eU/9GLQgrTk41DNmIEzp/LI83K9J9UUU8YBYQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.28.1",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.28.1.tgz",
"integrity": "sha512-KNBpA2kbxr3/2YK5swt7b/kd/xpDP1FHYZCxDFIw54tX8slELRFEf95VMxsccQHZeIcUbdoojmUUuYSbt/sM5Q==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.28.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.62.16", "version": "5.62.16",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.16.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.16.tgz",
@@ -2538,6 +2630,26 @@
"react": "^18 || ^19" "react": "^18 || ^19"
} }
}, },
"node_modules/@tanstack/react-table": {
"version": "8.20.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
"integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.20.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": { "node_modules/@tanstack/react-virtual": {
"version": "3.11.2", "version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz",
@@ -2555,6 +2667,19 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/@tanstack/table-core": {
"version": "8.20.5",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": { "node_modules/@tanstack/virtual-core": {
"version": "3.11.2", "version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz",

View File

@@ -13,6 +13,7 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",
@@ -21,6 +22,7 @@
"@radix-ui/react-label": "^2.1.1", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4", "@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
@@ -30,7 +32,9 @@
"@radix-ui/react-toggle-group": "^1.1.1", "@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"@shadcn/ui": "^0.0.4", "@shadcn/ui": "^0.0.4",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.63.0", "@tanstack/react-query": "^5.63.0",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.2", "@tanstack/react-virtual": "^3.11.2",
"@tanstack/virtual-core": "^3.11.2", "@tanstack/virtual-core": "^3.11.2",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",

View File

@@ -12,6 +12,7 @@ import { Login } from './pages/Login';
import { useEffect } from 'react'; import { useEffect } from 'react';
import config from './config'; import config from './config';
import { RequireAuth } from './components/auth/RequireAuth'; import { RequireAuth } from './components/auth/RequireAuth';
import Forecasting from "@/pages/Forecasting";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -61,6 +62,7 @@ function App() {
<Route path="/purchase-orders" element={<PurchaseOrders />} /> <Route path="/purchase-orders" element={<PurchaseOrders />} />
<Route path="/analytics" element={<Analytics />} /> <Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/forecasting" element={<Forecasting />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -0,0 +1,170 @@
import { ColumnDef, Column } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { ArrowUpDown, ChevronDown, ChevronRight } from "lucide-react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
export type ProductDetail = {
product_id: string;
name: string;
sku: string;
stock_quantity: number;
total_sold: number;
avg_price: number;
};
export type ForecastItem = {
category: string;
avgDailySales: number;
totalSold: number;
numProducts: number;
avgPrice: number;
avgTotalSold: number;
products?: ProductDetail[];
};
export const columns: ColumnDef<ForecastItem>[] = [
{
id: "expander",
header: () => null,
cell: ({ row }) => {
return row.original.products?.length ? (
<Button
variant="ghost"
onClick={() => row.toggleExpanded()}
className="p-0 hover:bg-transparent"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
) : null;
},
},
{
accessorKey: "category",
header: ({ column }: { column: Column<ForecastItem> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Category
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
accessorKey: "avgDailySales",
header: ({ column }: { column: Column<ForecastItem> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Avg Daily Sales
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("avgDailySales") as number;
return value ? value.toFixed(2) : '0.00';
},
},
{
accessorKey: "totalSold",
header: ({ column }: { column: Column<ForecastItem> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Total Sold
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("totalSold") as number;
return value ? value.toLocaleString() : '0';
},
},
{
accessorKey: "avgTotalSold",
header: ({ column }: { column: Column<ForecastItem> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Avg Total Sold
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("avgTotalSold") as number;
return value ? value.toFixed(2) : '0.00';
},
},
{
accessorKey: "numProducts",
header: ({ column }: { column: Column<ForecastItem> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
# of Products
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
accessorKey: "avgPrice",
header: ({ column }: { column: Column<ForecastItem> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Avg Price
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("avgPrice") as number;
return value ? `$${value.toFixed(2)}` : '$0.00';
},
},
];
export const renderSubComponent = ({ row }: { row: any }) => {
const products = row.original.products || [];
return (
<div className="p-4">
<div className="grid grid-cols-6 gap-4 font-medium mb-2">
<div>Name</div>
<div>SKU</div>
<div>Stock</div>
<div>Total Sold</div>
<div>Avg Price</div>
</div>
{products.map((product: ProductDetail) => (
<div key={product.product_id} className="grid grid-cols-6 gap-4 py-2 border-t">
<div>{product.name}</div>
<div>{product.sku}</div>
<div>{product.stock_quantity}</div>
<div>{product.total_sold}</div>
<div>${product.avg_price.toFixed(2)}</div>
</div>
))}
</div>
);
};

View File

@@ -1,13 +1,13 @@
import { import {
Home, Home,
Package, Package,
ShoppingCart,
BarChart2, BarChart2,
Settings, Settings,
Box, Box,
ClipboardList, ClipboardList,
LogOut, LogOut,
} from "lucide-react"; } from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -34,9 +34,9 @@ const items = [
url: "/products", url: "/products",
}, },
{ {
title: "Orders", title: "Forecasting",
icon: ShoppingCart, icon: IconCrystalBall,
url: "/orders", url: "/forecasting",
}, },
{ {
title: "Purchase Orders", title: "Purchase Orders",

View File

@@ -392,93 +392,6 @@ export function ProductFilters({
</ToggleGroup> </ToggleGroup>
); );
const renderNumberInput = () => (
<div className="flex flex-col gap-4 items-start">
<div className="mb-4">
<Button
variant="ghost"
onClick={handleBackToFilters}
className="text-muted-foreground"
>
Back to filters
</Button>
</div>
{renderOperatorSelect()}
<div className="flex items-center gap-2">
<Input
ref={numberInputRef}
type="number"
placeholder={`Enter ${selectedFilter?.label.toLowerCase()}`}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (selectedOperator === "between") {
if (inputValue2) {
const val1 = parseFloat(inputValue);
const val2 = parseFloat(inputValue2);
if (!isNaN(val1) && !isNaN(val2)) {
handleApplyFilter([val1, val2]);
}
}
} else {
const val = parseFloat(inputValue);
if (!isNaN(val)) {
handleApplyFilter(val);
}
}
} else if (e.key === "Escape") {
e.stopPropagation();
handleBackToFilters();
}
}}
className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
{selectedOperator === "between" && (
<>
<span>and</span>
<Input
type="number"
placeholder={`Enter maximum`}
value={inputValue2}
onChange={(e) => setInputValue2(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
const val1 = parseFloat(inputValue);
const val2 = parseFloat(inputValue2);
if (!isNaN(val1) && !isNaN(val2)) {
handleApplyFilter([val1, val2]);
}
} else if (e.key === "Escape") {
e.stopPropagation();
handleBackToFilters();
}
}}
className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</>
)}
<Button
onClick={() => {
if (selectedOperator === "between") {
const val1 = parseFloat(inputValue);
const val2 = parseFloat(inputValue2);
if (!isNaN(val1) && !isNaN(val2)) {
handleApplyFilter([val1, val2]);
}
} else {
const val = parseFloat(inputValue);
if (!isNaN(val)) {
handleApplyFilter(val);
}
}
}}
>
Apply
</Button>
</div>
</div>
);
const getFilterDisplayValue = (filter: ActiveFilter) => { const getFilterDisplayValue = (filter: ActiveFilter) => {
const filterValue = activeFilters[filter.id]; const filterValue = activeFilters[filter.id];

View File

@@ -0,0 +1,55 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,183 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useQuery } from "@tanstack/react-query";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
Row,
Header,
HeaderGroup,
getExpandedRowModel,
} from "@tanstack/react-table";
import { columns, ForecastItem, renderSubComponent } from "@/components/forecasting/columns";
import { DateRange } from "react-day-picker";
import { addDays } from "date-fns";
import { DateRangePicker } from "@/components/ui/date-range-picker";
export default function Forecasting() {
const [selectedBrand, setSelectedBrand] = useState<string>("");
const [dateRange, setDateRange] = useState<DateRange>({
from: addDays(new Date(), -30),
to: new Date(),
});
const [sorting, setSorting] = useState<SortingState>([]);
const handleDateRangeChange = (range: DateRange | undefined) => {
if (range) {
setDateRange(range);
}
};
const { data: brands = [], isLoading: brandsLoading } = useQuery({
queryKey: ["brands"],
queryFn: async () => {
const response = await fetch("/api/products/brands");
if (!response.ok) {
throw new Error("Failed to fetch brands");
}
const data = await response.json();
return Array.isArray(data) ? data : [];
},
});
const { data: forecastData, isLoading: forecastLoading } = useQuery({
queryKey: ["forecast", selectedBrand, dateRange],
queryFn: async () => {
const params = new URLSearchParams({
brand: selectedBrand,
startDate: dateRange.from?.toISOString() || "",
endDate: dateRange.to?.toISOString() || "",
});
const response = await fetch(`/api/analytics/forecast?${params}`);
if (!response.ok) {
throw new Error("Failed to fetch forecast data");
}
const data = await response.json();
return data.map((item: any) => ({
category: item.category_name,
avgDailySales: Number(item.avg_daily_sales) || 0,
totalSold: Number(item.total_sold) || 0,
numProducts: Number(item.num_products) || 0,
avgPrice: Number(item.avg_price) || 0,
avgTotalSold: Number(item.avgTotalSold) || 0,
products: item.products?.map((p: any) => ({
product_id: p.product_id,
name: p.product_name,
sku: p.sku,
stock_quantity: Number(p.stock_quantity) || 0,
total_sold: Number(p.total_sold) || 0,
avg_price: Number(p.avg_price) || 0,
}))
}));
},
enabled: !!selectedBrand && !!dateRange.from && !!dateRange.to,
});
const table = useReactTable({
data: forecastData || [],
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
onSortingChange: setSorting,
state: {
sorting,
},
getRowCanExpand: () => true,
});
return (
<div className="container mx-auto py-10">
<Card>
<CardHeader>
<CardTitle>Sales Forecasting</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-4 mb-6">
<div className="w-[200px]">
<Select value={selectedBrand} onValueChange={setSelectedBrand}>
<SelectTrigger disabled={brandsLoading}>
<SelectValue placeholder="Select brand" />
</SelectTrigger>
<SelectContent>
{brands.map((brand: string) => (
<SelectItem key={brand} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DateRangePicker
value={dateRange}
onChange={handleDateRangeChange}
/>
</div>
{forecastLoading ? (
<div className="h-24 flex items-center justify-center">
Loading forecast data...
</div>
) : forecastData && (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup: HeaderGroup<ForecastItem>) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header: Header<ForecastItem, unknown>) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row: Row<ForecastItem>) => (
<>
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={columns.length}>
{renderSubComponent({ row })}
</TableCell>
</TableRow>
)}
</>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
interface Product {
product_id: string;
name: string;
sku: string;
stock_quantity: number;
total_sold: number;
avg_price: number;
}
interface CategoryMetrics {
category_id: string;
category_name: string;
brand: string;
num_products: number;
avg_daily_sales: number;
total_sold: number;
avgTotalSold: number;
avg_price: number;
products: Product[];
}
{data && (
<ScrollArea className="h-[600px] w-full rounded-md border p-4">
<Accordion type="single" collapsible className="w-full">
{data.map((category: CategoryMetrics, index: number) => (
<AccordionItem key={category.category_id} value={category.category_id}>
<AccordionTrigger className="hover:no-underline">
<div className="grid grid-cols-6 w-full text-sm">
<div className="text-left font-medium">{category.category_name}</div>
<div className="text-right">{category.num_products}</div>
<div className="text-right">{category.avg_daily_sales.toFixed(2)}</div>
<div className="text-right">{category.total_sold}</div>
<div className="text-right">${category.avg_price.toFixed(2)}</div>
<div className="text-right">{category.avgTotalSold.toFixed(2)}</div>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
<div className="grid grid-cols-5 text-sm font-medium text-muted-foreground mb-2">
<div>Product</div>
<div className="text-right">SKU</div>
<div className="text-right">Stock</div>
<div className="text-right">Total Sold</div>
<div className="text-right">Avg Price</div>
</div>
{JSON.parse(category.products).map((product: Product) => (
<div key={product.product_id} className="grid grid-cols-5 text-sm">
<div className="truncate">{product.name}</div>
<div className="text-right">{product.sku}</div>
<div className="text-right">{product.stock_quantity}</div>
<div className="text-right">{product.total_sold}</div>
<div className="text-right">${product.avg_price.toFixed(2)}</div>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</ScrollArea>
)}