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,
|
||||
avg_lead_time_days INT,
|
||||
last_purchase_date DATE,
|
||||
first_received_date DATE,
|
||||
last_received_date DATE,
|
||||
PRIMARY KEY (product_id)
|
||||
);
|
||||
@@ -51,6 +52,7 @@ CREATE TABLE IF NOT EXISTS product_metrics (
|
||||
-- Purchase metrics
|
||||
avg_lead_time_days INT,
|
||||
last_purchase_date DATE,
|
||||
first_received_date DATE,
|
||||
last_received_date DATE,
|
||||
-- Classification
|
||||
abc_class CHAR(1),
|
||||
@@ -107,6 +109,23 @@ CREATE TABLE IF NOT EXISTS vendor_metrics (
|
||||
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
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
@@ -223,4 +242,27 @@ LEFT JOIN
|
||||
WHERE
|
||||
o.canceled = false
|
||||
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() {
|
||||
let pool;
|
||||
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 cost_price * received ELSE 0 END) as total_cost,
|
||||
MAX(date) as last_purchase_date,
|
||||
MIN(received_date) as first_received_date,
|
||||
MAX(received_date) as last_received_date,
|
||||
AVG(lead_time_days) as avg_lead_time_days,
|
||||
COUNT(*) as orders_analyzed
|
||||
@@ -952,6 +1034,7 @@ async function calculateMetrics() {
|
||||
inventory_value || 0,
|
||||
purchases.avg_lead_time_days || null,
|
||||
purchases.last_purchase_date || null,
|
||||
purchases.first_received_date || null,
|
||||
purchases.last_received_date || null,
|
||||
stock_status,
|
||||
reorder_qty,
|
||||
@@ -985,6 +1068,7 @@ async function calculateMetrics() {
|
||||
inventory_value,
|
||||
avg_lead_time_days,
|
||||
last_purchase_date,
|
||||
first_received_date,
|
||||
last_received_date,
|
||||
stock_status,
|
||||
reorder_qty,
|
||||
@@ -1008,6 +1092,7 @@ async function calculateMetrics() {
|
||||
inventory_value = VALUES(inventory_value),
|
||||
avg_lead_time_days = VALUES(avg_lead_time_days),
|
||||
last_purchase_date = VALUES(last_purchase_date),
|
||||
first_received_date = VALUES(first_received_date),
|
||||
last_received_date = VALUES(last_received_date),
|
||||
stock_status = VALUES(stock_status),
|
||||
reorder_qty = VALUES(reorder_qty),
|
||||
@@ -1067,6 +1152,12 @@ async function calculateMetrics() {
|
||||
});
|
||||
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
|
||||
if (isCancelled) {
|
||||
throw new Error('Operation cancelled');
|
||||
|
||||
@@ -21,7 +21,8 @@ const METRICS_TABLES = [
|
||||
'temp_purchase_metrics',
|
||||
'product_metrics',
|
||||
'product_time_aggregates',
|
||||
'vendor_metrics'
|
||||
'vendor_metrics',
|
||||
'category_sales_metrics'
|
||||
];
|
||||
|
||||
// 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;
|
||||
@@ -6,6 +6,36 @@ const { importProductsFromCSV } = require('../utils/csvImporter');
|
||||
// Configure multer for file 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
|
||||
router.get('/', async (req, res) => {
|
||||
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/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.2",
|
||||
@@ -19,6 +20,7 @@
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.4",
|
||||
"@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-separator": "^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-tooltip": "^1.1.6",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@tabler/icons-react": "^3.28.1",
|
||||
"@tanstack/react-query": "^5.63.0",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tanstack/virtual-core": "^3.11.2",
|
||||
"chart.js": "^4.4.7",
|
||||
@@ -1243,6 +1247,37 @@
|
||||
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
|
||||
"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": {
|
||||
"version": "1.1.4",
|
||||
"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": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz",
|
||||
@@ -2512,6 +2578,32 @@
|
||||
"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": {
|
||||
"version": "5.62.16",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.16.tgz",
|
||||
@@ -2538,6 +2630,26 @@
|
||||
"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": {
|
||||
"version": "3.11.2",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.11.2",
|
||||
"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/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.2",
|
||||
@@ -21,6 +22,7 @@
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.4",
|
||||
"@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-separator": "^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-tooltip": "^1.1.6",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@tabler/icons-react": "^3.28.1",
|
||||
"@tanstack/react-query": "^5.63.0",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tanstack/virtual-core": "^3.11.2",
|
||||
"chart.js": "^4.4.7",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Login } from './pages/Login';
|
||||
import { useEffect } from 'react';
|
||||
import config from './config';
|
||||
import { RequireAuth } from './components/auth/RequireAuth';
|
||||
import Forecasting from "@/pages/Forecasting";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -61,6 +62,7 @@ function App() {
|
||||
<Route path="/purchase-orders" element={<PurchaseOrders />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/forecasting" element={<Forecasting />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</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 {
|
||||
Home,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
BarChart2,
|
||||
Settings,
|
||||
Box,
|
||||
ClipboardList,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import { IconCrystalBall } from "@tabler/icons-react";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -34,9 +34,9 @@ const items = [
|
||||
url: "/products",
|
||||
},
|
||||
{
|
||||
title: "Orders",
|
||||
icon: ShoppingCart,
|
||||
url: "/orders",
|
||||
title: "Forecasting",
|
||||
icon: IconCrystalBall,
|
||||
url: "/forecasting",
|
||||
},
|
||||
{
|
||||
title: "Purchase Orders",
|
||||
|
||||
@@ -392,93 +392,6 @@ export function ProductFilters({
|
||||
</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 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