Add forecasting page
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
@@ -223,4 +242,27 @@ LEFT JOIN
|
|||||||
WHERE
|
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;
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
125
inventory/package-lock.json
generated
125
inventory/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
170
inventory/src/components/forecasting/columns.tsx
Normal file
170
inventory/src/components/forecasting/columns.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
55
inventory/src/components/ui/accordion.tsx
Normal file
55
inventory/src/components/ui/accordion.tsx
Normal 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 }
|
||||||
46
inventory/src/components/ui/scroll-area.tsx
Normal file
46
inventory/src/components/ui/scroll-area.tsx
Normal 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 }
|
||||||
183
inventory/src/pages/Forecasting.tsx
Normal file
183
inventory/src/pages/Forecasting.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
inventory/src/routes/Forecasting.tsx
Normal file
69
inventory/src/routes/Forecasting.tsx
Normal 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>
|
||||||
|
)}
|
||||||
Reference in New Issue
Block a user