From c8c3d323a494788bbfc08b1ab8331aeb7cd0d947 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 15 Jan 2025 22:08:52 -0500 Subject: [PATCH] Add forecasting page --- inventory-server/db/metrics-schema.sql | 44 ++++- inventory-server/scripts/calculate-metrics.js | 93 ++++++++- inventory-server/scripts/reset-metrics.js | 3 +- inventory-server/src/routes/analytics.js | 68 +++++++ inventory-server/src/routes/products.js | 30 +++ inventory/package-lock.json | 125 ++++++++++++ inventory/package.json | 4 + inventory/src/App.tsx | 2 + .../src/components/forecasting/columns.tsx | 170 ++++++++++++++++ .../src/components/layout/AppSidebar.tsx | 8 +- .../components/products/ProductFilters.tsx | 87 --------- inventory/src/components/ui/accordion.tsx | 55 ++++++ inventory/src/components/ui/scroll-area.tsx | 46 +++++ inventory/src/pages/Forecasting.tsx | 183 ++++++++++++++++++ inventory/src/routes/Forecasting.tsx | 69 +++++++ 15 files changed, 893 insertions(+), 94 deletions(-) create mode 100644 inventory/src/components/forecasting/columns.tsx create mode 100644 inventory/src/components/ui/accordion.tsx create mode 100644 inventory/src/components/ui/scroll-area.tsx create mode 100644 inventory/src/pages/Forecasting.tsx create mode 100644 inventory/src/routes/Forecasting.tsx diff --git a/inventory-server/db/metrics-schema.sql b/inventory-server/db/metrics-schema.sql index e7d7bc3..9632237 100644 --- a/inventory-server/db/metrics-schema.sql +++ b/inventory-server/db/metrics-schema.sql @@ -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; \ No newline at end of file + 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; \ No newline at end of file diff --git a/inventory-server/scripts/calculate-metrics.js b/inventory-server/scripts/calculate-metrics.js index 5d72953..0a8d757 100644 --- a/inventory-server/scripts/calculate-metrics.js +++ b/inventory-server/scripts/calculate-metrics.js @@ -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'); diff --git a/inventory-server/scripts/reset-metrics.js b/inventory-server/scripts/reset-metrics.js index e3bf014..357a27e 100644 --- a/inventory-server/scripts/reset-metrics.js +++ b/inventory-server/scripts/reset-metrics.js @@ -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 diff --git a/inventory-server/src/routes/analytics.js b/inventory-server/src/routes/analytics.js index c0caf96..a463b89 100644 --- a/inventory-server/src/routes/analytics.js +++ b/inventory-server/src/routes/analytics.js @@ -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; \ No newline at end of file diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index 8f7644a..ac9b6bd 100755 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -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; diff --git a/inventory/package-lock.json b/inventory/package-lock.json index 3ebc03e..d3f5a14 100644 --- a/inventory/package-lock.json +++ b/inventory/package-lock.json @@ -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", diff --git a/inventory/package.json b/inventory/package.json index 7d65c8b..097e57b 100644 --- a/inventory/package.json +++ b/inventory/package.json @@ -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", diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index dc72e67..b0b5324 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/inventory/src/components/forecasting/columns.tsx b/inventory/src/components/forecasting/columns.tsx new file mode 100644 index 0000000..9b9bdce --- /dev/null +++ b/inventory/src/components/forecasting/columns.tsx @@ -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[] = [ + { + id: "expander", + header: () => null, + cell: ({ row }) => { + return row.original.products?.length ? ( + + ) : null; + }, + }, + { + accessorKey: "category", + header: ({ column }: { column: Column }) => { + return ( + + ); + }, + }, + { + accessorKey: "avgDailySales", + header: ({ column }: { column: Column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const value = row.getValue("avgDailySales") as number; + return value ? value.toFixed(2) : '0.00'; + }, + }, + { + accessorKey: "totalSold", + header: ({ column }: { column: Column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const value = row.getValue("totalSold") as number; + return value ? value.toLocaleString() : '0'; + }, + }, + { + accessorKey: "avgTotalSold", + header: ({ column }: { column: Column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const value = row.getValue("avgTotalSold") as number; + return value ? value.toFixed(2) : '0.00'; + }, + }, + { + accessorKey: "numProducts", + header: ({ column }: { column: Column }) => { + return ( + + ); + }, + }, + { + accessorKey: "avgPrice", + header: ({ column }: { column: Column }) => { + return ( + + ); + }, + 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 ( +
+
+
Name
+
SKU
+
Stock
+
Total Sold
+
Avg Price
+
+ {products.map((product: ProductDetail) => ( +
+
{product.name}
+
{product.sku}
+
{product.stock_quantity}
+
{product.total_sold}
+
${product.avg_price.toFixed(2)}
+
+ ))} +
+ ); +}; \ No newline at end of file diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index 73563b7..3464a15 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -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", diff --git a/inventory/src/components/products/ProductFilters.tsx b/inventory/src/components/products/ProductFilters.tsx index 82e3cb3..43fd4ea 100644 --- a/inventory/src/components/products/ProductFilters.tsx +++ b/inventory/src/components/products/ProductFilters.tsx @@ -392,93 +392,6 @@ export function ProductFilters({ ); - const renderNumberInput = () => ( -
-
- -
- {renderOperatorSelect()} -
- 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" && ( - <> - and - 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" - /> - - )} - -
-
- ); const getFilterDisplayValue = (filter: ActiveFilter) => { const filterValue = activeFilters[filter.id]; diff --git a/inventory/src/components/ui/accordion.tsx b/inventory/src/components/ui/accordion.tsx new file mode 100644 index 0000000..e1797c9 --- /dev/null +++ b/inventory/src/components/ui/accordion.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/inventory/src/components/ui/scroll-area.tsx b/inventory/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..cf253cf --- /dev/null +++ b/inventory/src/components/ui/scroll-area.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/inventory/src/pages/Forecasting.tsx b/inventory/src/pages/Forecasting.tsx new file mode 100644 index 0000000..5e48ed9 --- /dev/null +++ b/inventory/src/pages/Forecasting.tsx @@ -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(""); + const [dateRange, setDateRange] = useState({ + from: addDays(new Date(), -30), + to: new Date(), + }); + const [sorting, setSorting] = useState([]); + + 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 ( +
+ + + Sales Forecasting + + +
+
+ +
+ +
+ + {forecastLoading ? ( +
+ Loading forecast data... +
+ ) : forecastData && ( +
+ + + {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( + + {headerGroup.headers.map((header: Header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row: Row) => ( + <> + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + {row.getIsExpanded() && ( + + + {renderSubComponent({ row })} + + + )} + + )) + ) : ( + + + No results. + + + )} + +
+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/routes/Forecasting.tsx b/inventory/src/routes/Forecasting.tsx new file mode 100644 index 0000000..78b4905 --- /dev/null +++ b/inventory/src/routes/Forecasting.tsx @@ -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 && ( + + + {data.map((category: CategoryMetrics, index: number) => ( + + +
+
{category.category_name}
+
{category.num_products}
+
{category.avg_daily_sales.toFixed(2)}
+
{category.total_sold}
+
${category.avg_price.toFixed(2)}
+
{category.avgTotalSold.toFixed(2)}
+
+
+ +
+
+
Product
+
SKU
+
Stock
+
Total Sold
+
Avg Price
+
+ {JSON.parse(category.products).map((product: Product) => ( +
+
{product.name}
+
{product.sku}
+
{product.stock_quantity}
+
{product.total_sold}
+
${product.avg_price.toFixed(2)}
+
+ ))} +
+
+
+ ))} +
+
+)} \ No newline at end of file