From 609490895b8832bc4b23927d1b00deb0c6b150cf Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 14:25:13 -0500 Subject: [PATCH 01/17] Fix build errors --- inventory/src/routes/Forecasting.tsx | 105 ++++++++++++++++----------- inventory/tsconfig.tsbuildinfo | 2 +- 2 files changed, 65 insertions(+), 42 deletions(-) diff --git a/inventory/src/routes/Forecasting.tsx b/inventory/src/routes/Forecasting.tsx index 78b4905..969fca5 100644 --- a/inventory/src/routes/Forecasting.tsx +++ b/inventory/src/routes/Forecasting.tsx @@ -5,6 +5,8 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; +import { useQuery } from "@tanstack/react-query"; +import config from "@/config"; interface Product { product_id: string; @@ -24,46 +26,67 @@ interface CategoryMetrics { total_sold: number; avgTotalSold: number; avg_price: number; - products: Product[]; + products: string; // This is a JSON string that will be parsed } -{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 +export default function Forecasting() { + const { data, isLoading } = useQuery({ + queryKey: ["forecasting"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/analytics/forecasting`, { + credentials: 'include' + }); + if (!response.ok) throw new Error("Failed to fetch forecasting data"); + return response.json(); + }, + }); + + if (isLoading) { + return
Loading forecasting data...
; + } + + return ( +
+ {data && ( + + + {data.map((category: CategoryMetrics) => ( + + +
+
{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 diff --git a/inventory/tsconfig.tsbuildinfo b/inventory/tsconfig.tsbuildinfo index ee8a216..63b006d 100644 --- a/inventory/tsconfig.tsbuildinfo +++ b/inventory/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/analytics/categoryperformance.tsx","./src/components/analytics/priceanalysis.tsx","./src/components/analytics/profitanalysis.tsx","./src/components/analytics/stockanalysis.tsx","./src/components/analytics/vendorperformance.tsx","./src/components/auth/requireauth.tsx","./src/components/dashboard/inventoryhealthsummary.tsx","./src/components/dashboard/inventorystats.tsx","./src/components/dashboard/keymetricscharts.tsx","./src/components/dashboard/lowstockalerts.tsx","./src/components/dashboard/overview.tsx","./src/components/dashboard/recentsales.tsx","./src/components/dashboard/salesbycategory.tsx","./src/components/dashboard/trendingproducts.tsx","./src/components/dashboard/vendorperformance.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/settings/calculationsettings.tsx","./src/components/settings/configuration.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/performancemetrics.tsx","./src/components/settings/stockmanagement.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/hooks/use-mobile.tsx","./src/lib/utils.ts","./src/pages/analytics.tsx","./src/pages/categories.tsx","./src/pages/dashboard.tsx","./src/pages/forecasting.tsx","./src/pages/login.tsx","./src/pages/orders.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/settings.tsx","./src/pages/vendors.tsx","./src/routes/forecasting.tsx","./src/types/products.ts"],"errors":true,"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/analytics/categoryperformance.tsx","./src/components/analytics/priceanalysis.tsx","./src/components/analytics/profitanalysis.tsx","./src/components/analytics/stockanalysis.tsx","./src/components/analytics/vendorperformance.tsx","./src/components/auth/requireauth.tsx","./src/components/dashboard/inventoryhealthsummary.tsx","./src/components/dashboard/inventorystats.tsx","./src/components/dashboard/keymetricscharts.tsx","./src/components/dashboard/lowstockalerts.tsx","./src/components/dashboard/overview.tsx","./src/components/dashboard/recentsales.tsx","./src/components/dashboard/salesbycategory.tsx","./src/components/dashboard/trendingproducts.tsx","./src/components/dashboard/vendorperformance.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/settings/calculationsettings.tsx","./src/components/settings/configuration.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/performancemetrics.tsx","./src/components/settings/stockmanagement.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/hooks/use-mobile.tsx","./src/lib/utils.ts","./src/pages/analytics.tsx","./src/pages/categories.tsx","./src/pages/dashboard.tsx","./src/pages/forecasting.tsx","./src/pages/login.tsx","./src/pages/orders.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/settings.tsx","./src/pages/vendors.tsx","./src/routes/forecasting.tsx","./src/types/products.ts"],"version":"5.6.3"} \ No newline at end of file From 48c7ab9134c9fa654a7c4631449b872d656ab034 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 15:21:19 -0500 Subject: [PATCH 02/17] Add new frontend dashboard components and update scripts/schema --- inventory-server/db/metrics-schema.sql | 179 ++++++---- inventory-server/scripts/calculate-metrics.js | 317 ++++++++++++++++++ inventory-server/scripts/reset-metrics.js | 20 +- .../src/components/dashboard/BestSellers.tsx | 171 ++++++++++ .../components/dashboard/ForecastMetrics.tsx | 125 +++++++ .../components/dashboard/OverstockMetrics.tsx | 103 ++++++ .../components/dashboard/PurchaseMetrics.tsx | 101 ++++++ .../dashboard/ReplenishmentMetrics.tsx | 91 +++++ .../src/components/dashboard/SalesMetrics.tsx | 145 ++++++++ .../src/components/dashboard/StockMetrics.tsx | 101 ++++++ .../dashboard/TopOverstockedProducts.tsx | 72 ++++ inventory/src/lib/utils.ts | 24 ++ inventory/src/pages/Dashboard.tsx | 71 +++- src/lib/utils.ts | 23 ++ 14 files changed, 1451 insertions(+), 92 deletions(-) create mode 100644 inventory/src/components/dashboard/BestSellers.tsx create mode 100644 inventory/src/components/dashboard/ForecastMetrics.tsx create mode 100644 inventory/src/components/dashboard/OverstockMetrics.tsx create mode 100644 inventory/src/components/dashboard/PurchaseMetrics.tsx create mode 100644 inventory/src/components/dashboard/ReplenishmentMetrics.tsx create mode 100644 inventory/src/components/dashboard/SalesMetrics.tsx create mode 100644 inventory/src/components/dashboard/StockMetrics.tsx create mode 100644 inventory/src/components/dashboard/TopOverstockedProducts.tsx create mode 100644 src/lib/utils.ts diff --git a/inventory-server/db/metrics-schema.sql b/inventory-server/db/metrics-schema.sql index bd60517..fd08f9f 100644 --- a/inventory-server/db/metrics-schema.sql +++ b/inventory-server/db/metrics-schema.sql @@ -63,6 +63,10 @@ CREATE TABLE IF NOT EXISTS product_metrics ( current_lead_time INT, target_lead_time INT, lead_time_status VARCHAR(20), + -- Forecast metrics + forecast_accuracy DECIMAL(5,2) DEFAULT NULL, + forecast_bias DECIMAL(5,2) DEFAULT NULL, + last_forecast_date DATE DEFAULT NULL, PRIMARY KEY (product_id), FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE, INDEX idx_metrics_revenue (total_revenue), @@ -71,7 +75,8 @@ CREATE TABLE IF NOT EXISTS product_metrics ( INDEX idx_metrics_turnover (turnover_rate), INDEX idx_metrics_last_calculated (last_calculated_at), INDEX idx_metrics_abc (abc_class), - INDEX idx_metrics_sales (daily_sales_avg, weekly_sales_avg, monthly_sales_avg) + INDEX idx_metrics_sales (daily_sales_avg, weekly_sales_avg, monthly_sales_avg), + INDEX idx_metrics_forecast (forecast_accuracy, forecast_bias) ); -- New table for time-based aggregates @@ -97,6 +102,20 @@ CREATE TABLE IF NOT EXISTS product_time_aggregates ( INDEX idx_date (year, month) ); +-- Create vendor details table +CREATE TABLE IF NOT EXISTS vendor_details ( + vendor VARCHAR(100) NOT NULL, + contact_name VARCHAR(100), + email VARCHAR(100), + phone VARCHAR(20), + status VARCHAR(20) DEFAULT 'active', + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (vendor), + INDEX idx_vendor_status (status) +); + -- New table for vendor metrics CREATE TABLE IF NOT EXISTS vendor_metrics ( vendor VARCHAR(100) NOT NULL, @@ -200,10 +219,95 @@ CREATE TABLE IF NOT EXISTS category_sales_metrics ( INDEX idx_period (period_start, period_end) ); +-- New table for brand metrics +CREATE TABLE IF NOT EXISTS brand_metrics ( + brand VARCHAR(100) NOT NULL, + last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Product metrics + product_count INT DEFAULT 0, + active_products INT DEFAULT 0, + -- Stock metrics + total_stock_units INT DEFAULT 0, + total_stock_cost DECIMAL(10,2) DEFAULT 0, + total_stock_retail DECIMAL(10,2) DEFAULT 0, + -- Sales metrics + total_revenue DECIMAL(10,2) DEFAULT 0, + avg_margin DECIMAL(5,2) DEFAULT 0, + growth_rate DECIMAL(5,2) DEFAULT 0, + PRIMARY KEY (brand), + INDEX idx_brand_metrics_last_calculated (last_calculated_at), + INDEX idx_brand_metrics_revenue (total_revenue), + INDEX idx_brand_metrics_growth (growth_rate) +); + +-- New table for brand time-based metrics +CREATE TABLE IF NOT EXISTS brand_time_metrics ( + brand VARCHAR(100) NOT NULL, + year INT NOT NULL, + month INT NOT NULL, + -- Product metrics + product_count INT DEFAULT 0, + active_products INT DEFAULT 0, + -- Stock metrics + total_stock_units INT DEFAULT 0, + total_stock_cost DECIMAL(10,2) DEFAULT 0, + total_stock_retail DECIMAL(10,2) DEFAULT 0, + -- Sales metrics + total_revenue DECIMAL(10,2) DEFAULT 0, + avg_margin DECIMAL(5,2) DEFAULT 0, + PRIMARY KEY (brand, year, month), + INDEX idx_brand_date (year, month) +); + +-- New table for sales forecasts +CREATE TABLE IF NOT EXISTS sales_forecasts ( + product_id BIGINT NOT NULL, + forecast_date DATE NOT NULL, + forecast_units DECIMAL(10,2) DEFAULT 0, + forecast_revenue DECIMAL(10,2) DEFAULT 0, + confidence_level DECIMAL(5,2) DEFAULT 0, + last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (product_id, forecast_date), + FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE, + INDEX idx_forecast_date (forecast_date), + INDEX idx_forecast_last_calculated (last_calculated_at) +); + +-- New table for category forecasts +CREATE TABLE IF NOT EXISTS category_forecasts ( + category_id BIGINT NOT NULL, + forecast_date DATE NOT NULL, + forecast_units DECIMAL(10,2) DEFAULT 0, + forecast_revenue DECIMAL(10,2) DEFAULT 0, + confidence_level DECIMAL(5,2) DEFAULT 0, + last_calculated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (category_id, forecast_date), + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + INDEX idx_category_forecast_date (forecast_date), + INDEX idx_category_forecast_last_calculated (last_calculated_at) +); + +-- Create table for sales seasonality factors +CREATE TABLE IF NOT EXISTS sales_seasonality ( + month INT NOT NULL, + seasonality_factor DECIMAL(5,3) DEFAULT 0, + last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (month), + CHECK (month BETWEEN 1 AND 12), + CHECK (seasonality_factor BETWEEN -1.0 AND 1.0) +); + +-- Insert default seasonality factors (neutral) +INSERT INTO sales_seasonality (month, seasonality_factor) +VALUES + (1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0), + (7, 0), (8, 0), (9, 0), (10, 0), (11, 0), (12, 0) +ON DUPLICATE KEY UPDATE last_updated = CURRENT_TIMESTAMP; + -- Re-enable foreign key checks SET FOREIGN_KEY_CHECKS = 1; --- Create view for inventory health (after all tables are created) +-- Create view for inventory health CREATE OR REPLACE VIEW inventory_health AS WITH product_thresholds AS ( SELECT @@ -298,77 +402,6 @@ LEFT JOIN WHERE p.managing_stock = true; --- Create view for sales trends analysis -CREATE OR REPLACE VIEW product_sales_trends AS -SELECT - p.product_id, - p.SKU, - p.title, - COALESCE(SUM(o.quantity), 0) as total_sold, - COALESCE(AVG(o.quantity), 0) as avg_quantity_per_order, - COALESCE(COUNT(DISTINCT o.order_number), 0) as number_of_orders, - MIN(o.date) as first_sale_date, - MAX(o.date) as last_sale_date -FROM - products p -LEFT JOIN - orders o ON p.product_id = o.product_id -WHERE - o.canceled = false -GROUP BY - 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; - --- Create view for vendor performance trends -CREATE OR REPLACE VIEW vendor_performance_trends AS -SELECT - v.vendor, - v.contact_name, - v.status, - vm.avg_lead_time_days, - vm.on_time_delivery_rate, - vm.order_fill_rate, - vm.total_orders, - vm.total_late_orders, - vm.total_purchase_value, - vm.avg_order_value, - vm.active_products, - vm.total_products, - vm.total_revenue, - vm.avg_margin_percent, - CASE - WHEN vm.order_fill_rate >= 95 THEN 'Excellent' - WHEN vm.order_fill_rate >= 85 THEN 'Good' - WHEN vm.order_fill_rate >= 75 THEN 'Fair' - ELSE 'Poor' - END as performance_rating -FROM - vendor_details v -LEFT JOIN - vendor_metrics vm ON v.vendor = vm.vendor; - -- Create view for category performance trends CREATE OR REPLACE VIEW category_performance_trends AS SELECT diff --git a/inventory-server/scripts/calculate-metrics.js b/inventory-server/scripts/calculate-metrics.js index 25ebf47..27477bd 100644 --- a/inventory-server/scripts/calculate-metrics.js +++ b/inventory-server/scripts/calculate-metrics.js @@ -974,6 +974,319 @@ async function calculateSafetyStock(connection, startTime, totalProducts) { `); } +// Add new function for brand metrics calculation +async function calculateBrandMetrics(connection, startTime, totalProducts) { + outputProgress({ + status: 'running', + operation: 'Calculating brand metrics', + current: Math.floor(totalProducts * 0.95), + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.95), totalProducts), + rate: calculateRate(startTime, Math.floor(totalProducts * 0.95)), + percentage: '95' + }); + + // Calculate brand metrics + await connection.query(` + INSERT INTO brand_metrics ( + brand, + product_count, + active_products, + total_stock_units, + total_stock_cost, + total_stock_retail, + total_revenue, + avg_margin, + growth_rate + ) + WITH brand_data AS ( + SELECT + p.brand, + COUNT(DISTINCT p.product_id) as product_count, + COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products, + SUM(p.stock_quantity) as total_stock_units, + SUM(p.stock_quantity * p.cost_price) as total_stock_cost, + SUM(p.stock_quantity * p.price) as total_stock_retail, + SUM(o.price * o.quantity) as total_revenue, + CASE + WHEN SUM(o.price * o.quantity) > 0 THEN + (SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity) + ELSE 0 + END as avg_margin, + -- Current period (last 3 months) + SUM(CASE + WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH) + THEN COALESCE(o.quantity * o.price, 0) + ELSE 0 + END) as current_period_sales, + -- Previous year same period + SUM(CASE + WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH) AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH) + THEN COALESCE(o.quantity * o.price, 0) + ELSE 0 + END) as previous_year_period_sales + FROM products p + LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false + WHERE p.brand IS NOT NULL + GROUP BY p.brand + ) + SELECT + brand, + product_count, + active_products, + total_stock_units, + total_stock_cost, + total_stock_retail, + total_revenue, + avg_margin, + CASE + WHEN previous_year_period_sales = 0 AND current_period_sales > 0 THEN 100.0 + WHEN previous_year_period_sales = 0 THEN 0.0 + ELSE LEAST( + GREATEST( + ((current_period_sales - previous_year_period_sales) / + NULLIF(previous_year_period_sales, 0)) * 100.0, + -100.0 + ), + 999.99 + ) + END as growth_rate + FROM brand_data + ON DUPLICATE KEY UPDATE + product_count = VALUES(product_count), + active_products = VALUES(active_products), + total_stock_units = VALUES(total_stock_units), + total_stock_cost = VALUES(total_stock_cost), + total_stock_retail = VALUES(total_stock_retail), + total_revenue = VALUES(total_revenue), + avg_margin = VALUES(avg_margin), + growth_rate = VALUES(growth_rate), + last_calculated_at = CURRENT_TIMESTAMP + `); + + // Calculate brand time-based metrics + await connection.query(` + INSERT INTO brand_time_metrics ( + brand, + year, + month, + product_count, + active_products, + total_stock_units, + total_stock_cost, + total_stock_retail, + total_revenue, + avg_margin + ) + SELECT + p.brand, + YEAR(o.date) as year, + MONTH(o.date) as month, + COUNT(DISTINCT p.product_id) as product_count, + COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products, + SUM(p.stock_quantity) as total_stock_units, + SUM(p.stock_quantity * p.cost_price) as total_stock_cost, + SUM(p.stock_quantity * p.price) as total_stock_retail, + SUM(o.price * o.quantity) as total_revenue, + CASE + WHEN SUM(o.price * o.quantity) > 0 THEN + (SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity) + ELSE 0 + END as avg_margin + FROM products p + LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false + WHERE p.brand IS NOT NULL + AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH) + GROUP BY p.brand, YEAR(o.date), MONTH(o.date) + ON DUPLICATE KEY UPDATE + product_count = VALUES(product_count), + active_products = VALUES(active_products), + total_stock_units = VALUES(total_stock_units), + total_stock_cost = VALUES(total_stock_cost), + total_stock_retail = VALUES(total_stock_retail), + total_revenue = VALUES(total_revenue), + avg_margin = VALUES(avg_margin) + `); +} + +// Add new function for sales forecast calculation +async function calculateSalesForecasts(connection, startTime, totalProducts) { + outputProgress({ + status: 'running', + operation: 'Calculating sales forecasts', + current: Math.floor(totalProducts * 0.98), + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.98), totalProducts), + rate: calculateRate(startTime, Math.floor(totalProducts * 0.98)), + percentage: '98' + }); + + // Calculate product-level forecasts + await connection.query(` + INSERT INTO sales_forecasts ( + product_id, + forecast_date, + forecast_units, + forecast_revenue, + confidence_level, + last_calculated_at + ) + WITH daily_sales AS ( + SELECT + o.product_id, + DATE(o.date) as sale_date, + SUM(o.quantity) as daily_quantity, + SUM(o.price * o.quantity) as daily_revenue + FROM orders o + WHERE o.canceled = false + AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY) + GROUP BY o.product_id, DATE(o.date) + ), + forecast_dates AS ( + SELECT + DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date + FROM ( + SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION + SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 14 UNION SELECT 30 UNION + SELECT 60 UNION SELECT 90 + ) numbers + ), + product_stats AS ( + SELECT + ds.product_id, + AVG(ds.daily_quantity) as avg_daily_quantity, + STDDEV_SAMP(ds.daily_quantity) as std_daily_quantity, + AVG(ds.daily_revenue) as avg_daily_revenue, + STDDEV_SAMP(ds.daily_revenue) as std_daily_revenue, + COUNT(*) as data_points + FROM daily_sales ds + GROUP BY ds.product_id + ) + SELECT + ps.product_id, + fd.forecast_date, + GREATEST(0, + ps.avg_daily_quantity * + (1 + COALESCE( + (SELECT seasonality_factor + FROM sales_seasonality + WHERE MONTH(fd.forecast_date) = month + LIMIT 1), + 0 + )) + ) as forecast_units, + GREATEST(0, + ps.avg_daily_revenue * + (1 + COALESCE( + (SELECT seasonality_factor + FROM sales_seasonality + WHERE MONTH(fd.forecast_date) = month + LIMIT 1), + 0 + )) + ) as forecast_revenue, + CASE + WHEN ps.data_points >= 60 THEN 90 + WHEN ps.data_points >= 30 THEN 80 + WHEN ps.data_points >= 14 THEN 70 + ELSE 60 + END as confidence_level, + NOW() as last_calculated_at + FROM product_stats ps + CROSS JOIN forecast_dates fd + WHERE ps.avg_daily_quantity > 0 + ON DUPLICATE KEY UPDATE + forecast_units = VALUES(forecast_units), + forecast_revenue = VALUES(forecast_revenue), + confidence_level = VALUES(confidence_level), + last_calculated_at = NOW() + `); + + // Calculate category-level forecasts + await connection.query(` + INSERT INTO category_forecasts ( + category_id, + forecast_date, + forecast_units, + forecast_revenue, + confidence_level, + last_calculated_at + ) + WITH category_daily_sales AS ( + SELECT + pc.category_id, + DATE(o.date) as sale_date, + SUM(o.quantity) as daily_quantity, + SUM(o.price * o.quantity) as daily_revenue + FROM orders o + JOIN product_categories pc ON o.product_id = pc.product_id + WHERE o.canceled = false + AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY) + GROUP BY pc.category_id, DATE(o.date) + ), + forecast_dates AS ( + SELECT + DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date + FROM ( + SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION + SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 14 UNION SELECT 30 UNION + SELECT 60 UNION SELECT 90 + ) numbers + ), + category_stats AS ( + SELECT + cds.category_id, + AVG(cds.daily_quantity) as avg_daily_quantity, + STDDEV_SAMP(cds.daily_quantity) as std_daily_quantity, + AVG(cds.daily_revenue) as avg_daily_revenue, + STDDEV_SAMP(cds.daily_revenue) as std_daily_revenue, + COUNT(*) as data_points + FROM category_daily_sales cds + GROUP BY cds.category_id + ) + SELECT + cs.category_id, + fd.forecast_date, + GREATEST(0, + cs.avg_daily_quantity * + (1 + COALESCE( + (SELECT seasonality_factor + FROM sales_seasonality + WHERE MONTH(fd.forecast_date) = month + LIMIT 1), + 0 + )) + ) as forecast_units, + GREATEST(0, + cs.avg_daily_revenue * + (1 + COALESCE( + (SELECT seasonality_factor + FROM sales_seasonality + WHERE MONTH(fd.forecast_date) = month + LIMIT 1), + 0 + )) + ) as forecast_revenue, + CASE + WHEN cs.data_points >= 60 THEN 90 + WHEN cs.data_points >= 30 THEN 80 + WHEN cs.data_points >= 14 THEN 70 + ELSE 60 + END as confidence_level, + NOW() as last_calculated_at + FROM category_stats cs + CROSS JOIN forecast_dates fd + WHERE cs.avg_daily_quantity > 0 + ON DUPLICATE KEY UPDATE + forecast_units = VALUES(forecast_units), + forecast_revenue = VALUES(forecast_revenue), + confidence_level = VALUES(confidence_level), + last_calculated_at = NOW() + `); +} + // Update the main calculation function to include the new metrics async function calculateMetrics() { let pool; @@ -1727,6 +2040,10 @@ async function calculateMetrics() { WHERE s.product_id IS NULL `); + // Add new metric calculations before final success message + await calculateBrandMetrics(connection, startTime, totalProducts); + await calculateSalesForecasts(connection, startTime, totalProducts); + // Final success message outputProgress({ status: 'complete', diff --git a/inventory-server/scripts/reset-metrics.js b/inventory-server/scripts/reset-metrics.js index 9ba57d9..0d796ff 100644 --- a/inventory-server/scripts/reset-metrics.js +++ b/inventory-server/scripts/reset-metrics.js @@ -17,15 +17,21 @@ function outputProgress(data) { // Explicitly define all metrics-related tables const METRICS_TABLES = [ - 'temp_sales_metrics', - 'temp_purchase_metrics', + 'brand_metrics', + 'brand_time_metrics', + 'category_forecasts', + 'category_metrics', + 'category_sales_metrics', + 'category_time_metrics', 'product_metrics', 'product_time_aggregates', - 'vendor_metrics', - 'vendor_time_metrics', - 'category_metrics', - 'category_time_metrics', - 'category_sales_metrics' + 'sales_forecasts', + 'sales_seasonality', + 'temp_purchase_metrics', + 'temp_sales_metrics', + 'vendor_metrics', //before vendor_details for foreign key + 'vendor_time_metrics', //before vendor_details for foreign key + 'vendor_details' ]; // Config tables that must exist diff --git a/inventory/src/components/dashboard/BestSellers.tsx b/inventory/src/components/dashboard/BestSellers.tsx new file mode 100644 index 0000000..105573f --- /dev/null +++ b/inventory/src/components/dashboard/BestSellers.tsx @@ -0,0 +1,171 @@ +import { useQuery } from "@tanstack/react-query" +import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import config from "@/config" +import { formatCurrency } from "@/lib/utils" + +interface BestSellerProduct { + product_id: number + sku: string + title: string + units_sold: number + revenue: number + profit: number +} + +interface BestSellerVendor { + vendor: string + products_sold: number + revenue: number + profit: number + order_fill_rate: number +} + +interface BestSellerCategory { + category_id: number + name: string + products_sold: number + revenue: number + profit: number + growth_rate: number +} + +interface BestSellersData { + products: BestSellerProduct[] + vendors: BestSellerVendor[] + categories: BestSellerCategory[] +} + +export function BestSellers() { + const { data } = useQuery({ + queryKey: ["best-sellers"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/dashboard/best-sellers`) + if (!response.ok) { + throw new Error("Failed to fetch best sellers") + } + return response.json() + }, + }) + + return ( + <> + + Best Sellers + + + + + Products + Vendors + Categories + + + + + + + + Product + Units + Revenue + Profit + + + + {data?.products.map((product) => ( + + +
+

{product.title}

+

{product.sku}

+
+
+ + {product.units_sold.toLocaleString()} + + + {formatCurrency(product.revenue)} + + + {formatCurrency(product.profit)} + +
+ ))} +
+
+
+
+ + + + + + + Vendor + Products + Revenue + Fill Rate + + + + {data?.vendors.map((vendor) => ( + + +

{vendor.vendor}

+
+ + {vendor.products_sold.toLocaleString()} + + + {formatCurrency(vendor.revenue)} + + + {vendor.order_fill_rate.toFixed(1)}% + +
+ ))} +
+
+
+
+ + + + + + + Category + Products + Revenue + Growth + + + + {data?.categories.map((category) => ( + + +

{category.name}

+
+ + {category.products_sold.toLocaleString()} + + + {formatCurrency(category.revenue)} + + + {category.growth_rate.toFixed(1)}% + +
+ ))} +
+
+
+
+
+
+ + ) +} \ No newline at end of file diff --git a/inventory/src/components/dashboard/ForecastMetrics.tsx b/inventory/src/components/dashboard/ForecastMetrics.tsx new file mode 100644 index 0000000..f10ac36 --- /dev/null +++ b/inventory/src/components/dashboard/ForecastMetrics.tsx @@ -0,0 +1,125 @@ +import { useQuery } from "@tanstack/react-query" +import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" +import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { useState } from "react" +import config from "@/config" +import { formatCurrency } from "@/lib/utils" + +interface ForecastData { + forecastSales: number + forecastRevenue: number + dailyForecast: { + date: string + sales: number + revenue: number + }[] +} + +const periods = [ + { value: "7", label: "7 Days" }, + { value: "14", label: "14 Days" }, + { value: "30", label: "30 Days" }, + { value: "60", label: "60 Days" }, + { value: "90", label: "90 Days" }, +] + +export function ForecastMetrics() { + const [period, setPeriod] = useState("30") + + const { data } = useQuery({ + queryKey: ["forecast-metrics", period], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?days=${period}`) + if (!response.ok) { + throw new Error("Failed to fetch forecast metrics") + } + return response.json() + }, + }) + + return ( + <> + + Sales Forecast + + + +
+
+

Forecast Sales

+

{data?.forecastSales.toLocaleString() || 0}

+
+
+

Forecast Revenue

+

{formatCurrency(data?.forecastRevenue || 0)}

+
+
+ +
+ + + + value.toLocaleString()} + /> + formatCurrency(value)} + /> + [ + name === "revenue" ? formatCurrency(value) : value.toLocaleString(), + name === "revenue" ? "Revenue" : "Sales" + ]} + labelFormatter={(label) => `Date: ${label}`} + /> + + + + +
+
+ + ) +} \ No newline at end of file diff --git a/inventory/src/components/dashboard/OverstockMetrics.tsx b/inventory/src/components/dashboard/OverstockMetrics.tsx new file mode 100644 index 0000000..37686f3 --- /dev/null +++ b/inventory/src/components/dashboard/OverstockMetrics.tsx @@ -0,0 +1,103 @@ +import { useQuery } from "@tanstack/react-query" +import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" +import { BarChart, Bar, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts" +import config from "@/config" +import { formatCurrency } from "@/lib/utils" + +interface OverstockMetricsData { + overstockedProducts: number + overstockedUnits: number + overstockedCost: number + overstockedRetail: number + overstockByCategory: { + category: string + products: number + units: number + cost: number + }[] +} + +export function OverstockMetrics() { + const { data } = useQuery({ + queryKey: ["overstock-metrics"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/dashboard/overstock/metrics`) + if (!response.ok) { + throw new Error("Failed to fetch overstock metrics") + } + return response.json() + }, + }) + + return ( + <> + + Overstock Overview + + +
+
+

Overstocked Products

+

{data?.overstockedProducts.toLocaleString() || 0}

+
+
+

Overstocked Units

+

{data?.overstockedUnits.toLocaleString() || 0}

+
+
+

Total Cost

+

{formatCurrency(data?.overstockedCost || 0)}

+
+
+

Total Retail

+

{formatCurrency(data?.overstockedRetail || 0)}

+
+
+ +
+ + + + value.toLocaleString()} + /> + [ + name === "cost" ? formatCurrency(value) : value.toLocaleString(), + name === "cost" ? "Cost" : name === "products" ? "Products" : "Units" + ]} + labelFormatter={(label) => `Category: ${label}`} + /> + + + + + +
+
+ + ) +} \ No newline at end of file diff --git a/inventory/src/components/dashboard/PurchaseMetrics.tsx b/inventory/src/components/dashboard/PurchaseMetrics.tsx new file mode 100644 index 0000000..c3db155 --- /dev/null +++ b/inventory/src/components/dashboard/PurchaseMetrics.tsx @@ -0,0 +1,101 @@ +import { useQuery } from "@tanstack/react-query" +import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" +import { PieChart, Pie, ResponsiveContainer, Cell, Tooltip } from "recharts" +import config from "@/config" +import { formatCurrency } from "@/lib/utils" + +interface PurchaseMetricsData { + activePurchaseOrders: number + overduePurchaseOrders: number + onOrderUnits: number + onOrderCost: number + onOrderRetail: number + vendorOrderValue: { + vendor: string + value: number + }[] +} + +const COLORS = [ + "#0088FE", + "#00C49F", + "#FFBB28", + "#FF8042", + "#8884D8", + "#82CA9D", + "#FFC658", + "#FF7C43", +] + +export function PurchaseMetrics() { + const { data } = useQuery({ + queryKey: ["purchase-metrics"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/dashboard/purchase/metrics`) + if (!response.ok) { + throw new Error("Failed to fetch purchase metrics") + } + return response.json() + }, + }) + + return ( + <> + + Purchase Orders Overview + + +
+
+

Active POs

+

{data?.activePurchaseOrders.toLocaleString() || 0}

+
+
+

Overdue POs

+

{data?.overduePurchaseOrders.toLocaleString() || 0}

+
+
+

On Order Units

+

{data?.onOrderUnits.toLocaleString() || 0}

+
+
+

On Order Cost

+

{formatCurrency(data?.onOrderCost || 0)}

+
+
+

On Order Retail

+

{formatCurrency(data?.onOrderRetail || 0)}

+
+
+ +
+ + + + {data?.vendorOrderValue.map((entry, index) => ( + + ))} + + formatCurrency(value)} + labelFormatter={(label: string) => `Vendor: ${label}`} + /> + + +
+
+ + ) +} \ No newline at end of file diff --git a/inventory/src/components/dashboard/ReplenishmentMetrics.tsx b/inventory/src/components/dashboard/ReplenishmentMetrics.tsx new file mode 100644 index 0000000..7712ae2 --- /dev/null +++ b/inventory/src/components/dashboard/ReplenishmentMetrics.tsx @@ -0,0 +1,91 @@ +import { useQuery } from "@tanstack/react-query" +import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" +import { BarChart, Bar, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts" +import config from "@/config" +import { formatCurrency } from "@/lib/utils" + +interface ReplenishmentMetricsData { + totalUnitsToReplenish: number + totalReplenishmentCost: number + totalReplenishmentRetail: number + replenishmentByCategory: { + category: string + units: number + cost: number + }[] +} + +export function ReplenishmentMetrics() { + const { data } = useQuery({ + queryKey: ["replenishment-metrics"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/dashboard/replenishment/metrics`) + if (!response.ok) { + throw new Error("Failed to fetch replenishment metrics") + } + return response.json() + }, + }) + + return ( + <> + + Replenishment Overview + + +
+
+

Units to Replenish

+

{data?.totalUnitsToReplenish.toLocaleString() || 0}

+
+
+

Total Cost

+

{formatCurrency(data?.totalReplenishmentCost || 0)}

+
+
+

Total Retail

+

{formatCurrency(data?.totalReplenishmentRetail || 0)}

+
+
+ +
+ + + + value.toLocaleString()} + /> + [ + name === "cost" ? formatCurrency(value) : value.toLocaleString(), + name === "cost" ? "Cost" : "Units" + ]} + labelFormatter={(label) => `Category: ${label}`} + /> + + + + +
+
+ + ) +} \ No newline at end of file diff --git a/inventory/src/components/dashboard/SalesMetrics.tsx b/inventory/src/components/dashboard/SalesMetrics.tsx new file mode 100644 index 0000000..adf0ea3 --- /dev/null +++ b/inventory/src/components/dashboard/SalesMetrics.tsx @@ -0,0 +1,145 @@ +import { useQuery } from "@tanstack/react-query" +import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" +import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { useState } from "react" +import config from "@/config" +import { formatCurrency } from "@/lib/utils" + +interface SalesData { + totalOrders: number + totalUnitsSold: number + totalCogs: number + totalRevenue: number + dailySales: { + date: string + units: number + revenue: number + cogs: number + }[] +} + +const periods = [ + { value: "7", label: "7 Days" }, + { value: "14", label: "14 Days" }, + { value: "30", label: "30 Days" }, + { value: "60", label: "60 Days" }, + { value: "90", label: "90 Days" }, +] + +export function SalesMetrics() { + const [period, setPeriod] = useState("30") + + const { data } = useQuery({ + queryKey: ["sales-metrics", period], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?days=${period}`) + if (!response.ok) { + throw new Error("Failed to fetch sales metrics") + } + return response.json() + }, + }) + + return ( + <> + + Sales Overview + + + +
+
+

Total Orders

+

{data?.totalOrders.toLocaleString() || 0}

+
+
+

Units Sold

+

{data?.totalUnitsSold.toLocaleString() || 0}

+
+
+

Cost of Goods

+

{formatCurrency(data?.totalCogs || 0)}

+
+
+

Revenue

+

{formatCurrency(data?.totalRevenue || 0)}

+
+
+ +
+ + + + value.toLocaleString()} + /> + formatCurrency(value)} + /> + [ + name === "units" ? value.toLocaleString() : formatCurrency(value), + name === "units" ? "Units" : name === "revenue" ? "Revenue" : "COGS" + ]} + labelFormatter={(label) => `Date: ${label}`} + /> + + + + + +
+
+ + ) +} \ No newline at end of file diff --git a/inventory/src/components/dashboard/StockMetrics.tsx b/inventory/src/components/dashboard/StockMetrics.tsx new file mode 100644 index 0000000..cb7233a --- /dev/null +++ b/inventory/src/components/dashboard/StockMetrics.tsx @@ -0,0 +1,101 @@ +import { useQuery } from "@tanstack/react-query" +import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" +import { PieChart, Pie, ResponsiveContainer, Cell, Tooltip } from "recharts" +import config from "@/config" +import { formatCurrency } from "@/lib/utils" + +interface StockMetricsData { + totalProducts: number + productsInStock: number + totalStockUnits: number + totalStockCost: number + totalStockRetail: number + brandRetailValue: { + brand: string + value: number + }[] +} + +const COLORS = [ + "#0088FE", + "#00C49F", + "#FFBB28", + "#FF8042", + "#8884D8", + "#82CA9D", + "#FFC658", + "#FF7C43", +] + +export function StockMetrics() { + const { data } = useQuery({ + queryKey: ["stock-metrics"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`) + if (!response.ok) { + throw new Error("Failed to fetch stock metrics") + } + return response.json() + }, + }) + + return ( + <> + + Stock Overview + + +
+
+

Total Products

+

{data?.totalProducts.toLocaleString() || 0}

+
+
+

Products In Stock

+

{data?.productsInStock.toLocaleString() || 0}

+
+
+

Total Stock Units

+

{data?.totalStockUnits.toLocaleString() || 0}

+
+
+

Total Stock Cost

+

{formatCurrency(data?.totalStockCost || 0)}

+
+
+

Total Stock Retail

+

{formatCurrency(data?.totalStockRetail || 0)}

+
+
+ +
+ + + + {data?.brandRetailValue.map((entry, index) => ( + + ))} + + formatCurrency(value)} + labelFormatter={(label: string) => `Brand: ${label}`} + /> + + +
+
+ + ) +} \ No newline at end of file diff --git a/inventory/src/components/dashboard/TopOverstockedProducts.tsx b/inventory/src/components/dashboard/TopOverstockedProducts.tsx new file mode 100644 index 0000000..69e6590 --- /dev/null +++ b/inventory/src/components/dashboard/TopOverstockedProducts.tsx @@ -0,0 +1,72 @@ +import { useQuery } from "@tanstack/react-query" +import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import config from "@/config" +import { formatCurrency } from "@/lib/utils" + +interface OverstockedProduct { + product_id: number + sku: string + title: string + overstocked_units: number + overstocked_cost: number + overstocked_retail: number + days_of_inventory: number +} + +export function TopOverstockedProducts() { + const { data } = useQuery({ + queryKey: ["top-overstocked-products"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`) + if (!response.ok) { + throw new Error("Failed to fetch overstocked products") + } + return response.json() + }, + }) + + return ( + <> + + Top Overstocked Products + + + + + + + Product + Units + Cost + Days + + + + {data?.map((product) => ( + + +
+

{product.title}

+

{product.sku}

+
+
+ + {product.overstocked_units.toLocaleString()} + + + {formatCurrency(product.overstocked_cost)} + + + {product.days_of_inventory} + +
+ ))} +
+
+
+
+ + ) +} \ No newline at end of file diff --git a/inventory/src/lib/utils.ts b/inventory/src/lib/utils.ts index bd0c391..7b5fc81 100644 --- a/inventory/src/lib/utils.ts +++ b/inventory/src/lib/utils.ts @@ -4,3 +4,27 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** + * Format a number as currency with the specified locale and currency code + * @param value - The number to format + * @param locale - The locale to use for formatting (defaults to 'en-US') + * @param currency - The currency code to use (defaults to 'USD') + * @returns Formatted currency string + */ +export function formatCurrency( + value: number | null | undefined, + locale: string = 'en-US', + currency: string = 'USD' +): string { + if (value === null || value === undefined) { + return '$0.00'; + } + + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); +} diff --git a/inventory/src/pages/Dashboard.tsx b/inventory/src/pages/Dashboard.tsx index c69a6d9..55d2591 100644 --- a/inventory/src/pages/Dashboard.tsx +++ b/inventory/src/pages/Dashboard.tsx @@ -4,31 +4,78 @@ import { LowStockAlerts } from "@/components/dashboard/LowStockAlerts" import { TrendingProducts } from "@/components/dashboard/TrendingProducts" import { VendorPerformance } from "@/components/dashboard/VendorPerformance" import { KeyMetricsCharts } from "@/components/dashboard/KeyMetricsCharts" +import { StockMetrics } from "@/components/dashboard/StockMetrics" +import { PurchaseMetrics } from "@/components/dashboard/PurchaseMetrics" +import { ReplenishmentMetrics } from "@/components/dashboard/ReplenishmentMetrics" +import { ForecastMetrics } from "@/components/dashboard/ForecastMetrics" +import { OverstockMetrics } from "@/components/dashboard/OverstockMetrics" +import { TopOverstockedProducts } from "@/components/dashboard/TopOverstockedProducts" +import { BestSellers } from "@/components/dashboard/BestSellers" +import { SalesMetrics } from "@/components/dashboard/SalesMetrics" import { motion } from "motion/react" + export function Dashboard() { return (

Dashboard

-
- -
-
- - + + {/* First row - Stock and Purchase metrics */} +
+ + - + + + +
+ + {/* Second row - Replenishment and Overstock */} +
+ + + + + + +
+ + {/* Third row - Products to Replenish and Overstocked Products */} +
+ -
-
- - + + - +
+ + {/* Fourth row - Sales and Forecast */} +
+ + + + + + +
+ + {/* Fifth row - Best Sellers */} +
+ + + +
+ + {/* Sixth row - Vendor Performance and Trending Products */} +
+ + + +
) diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..78f3051 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,23 @@ +/** + * Format a number as currency with the specified locale and currency code + * @param value - The number to format + * @param locale - The locale to use for formatting (defaults to 'en-US') + * @param currency - The currency code to use (defaults to 'USD') + * @returns Formatted currency string + */ +export function formatCurrency( + value: number | null | undefined, + locale: string = 'en-US', + currency: string = 'USD' +): string { + if (value === null || value === undefined) { + return '$0.00'; + } + + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); +} \ No newline at end of file From 118344b730b6cf2231d08ceb30df5f70ad317f0c Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 16:07:31 -0500 Subject: [PATCH 03/17] Add backend routes --- inventory-server/src/routes/dashboard.js | 1022 ++++++++++++++-------- src/lib/utils.ts | 24 +- 2 files changed, 658 insertions(+), 388 deletions(-) diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index f3c8cb6..456029d 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -1,374 +1,666 @@ const express = require('express'); const router = express.Router(); +const db = require('../utils/db'); -// Get dashboard stats -router.get('/stats', async (req, res) => { - const pool = req.app.locals.pool; - try { - const [stats] = await pool.query(` - WITH OrderStats AS ( - SELECT - COUNT(DISTINCT o.order_number) as total_orders, - SUM(o.price * o.quantity) as total_revenue, - AVG(subtotal) as average_order_value - FROM orders o - LEFT JOIN ( - SELECT order_number, SUM(price * quantity) as subtotal - FROM orders - WHERE DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) - AND canceled = false - GROUP BY order_number - ) t ON o.order_number = t.order_number - WHERE DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) - AND o.canceled = false - ), - ProfitStats AS ( - SELECT - SUM((o.price - p.cost_price) * o.quantity) as total_profit, - SUM(o.price * o.quantity) as revenue - FROM orders o - JOIN products p ON o.product_id = p.product_id - WHERE DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) - AND o.canceled = false - ), - ProductStats AS ( - SELECT - COUNT(*) as total_products, - COUNT(CASE WHEN stock_quantity <= 5 THEN 1 END) as low_stock_products - FROM products - WHERE visible = true - ) - SELECT - ps.total_products, - ps.low_stock_products, - os.total_orders, - os.average_order_value, - os.total_revenue, - prs.total_profit, - CASE - WHEN prs.revenue > 0 THEN (prs.total_profit / prs.revenue) * 100 - ELSE 0 - END as profit_margin - FROM ProductStats ps - CROSS JOIN OrderStats os - CROSS JOIN ProfitStats prs - `); - res.json({ - ...stats[0], - averageOrderValue: parseFloat(stats[0].average_order_value) || 0, - totalRevenue: parseFloat(stats[0].total_revenue) || 0, - profitMargin: parseFloat(stats[0].profit_margin) || 0 - }); - } catch (error) { - console.error('Error fetching dashboard stats:', error); - res.status(500).json({ error: 'Failed to fetch dashboard stats' }); - } -}); - -// Get sales overview data -router.get('/sales-overview', async (req, res) => { - const pool = req.app.locals.pool; - try { - const [rows] = await pool.query(` - SELECT - DATE(date) as date, - SUM(price * quantity) as total - FROM orders - WHERE DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) - AND canceled = false - GROUP BY DATE(date) - ORDER BY date ASC - `); - res.json(rows.map(row => ({ - ...row, - total: parseFloat(row.total || 0) - }))); - } catch (error) { - console.error('Error fetching sales overview:', error); - res.status(500).json({ error: 'Failed to fetch sales overview' }); - } -}); - -// Get recent orders -router.get('/recent-orders', async (req, res) => { - const pool = req.app.locals.pool; - try { - const [rows] = await pool.query(` - SELECT - o1.order_number as order_id, - o1.customer as customer_name, - SUM(o2.price * o2.quantity) as total_amount, - o1.date as order_date - FROM orders o1 - JOIN orders o2 ON o1.order_number = o2.order_number - WHERE o1.canceled = false - GROUP BY o1.order_number, o1.customer, o1.date - ORDER BY o1.date DESC - LIMIT 5 - `); - res.json(rows.map(row => ({ - ...row, - total_amount: parseFloat(row.total_amount || 0), - order_date: row.order_date - }))); - } catch (error) { - console.error('Error fetching recent orders:', error); - res.status(500).json({ error: 'Failed to fetch recent orders' }); - } -}); - -// Get category stats -router.get('/category-stats', async (req, res) => { - const pool = req.app.locals.pool; - try { - const [rows] = await pool.query(` - SELECT - c.name as category, - COUNT(DISTINCT pc.product_id) as count - FROM categories c - LEFT JOIN product_categories pc ON c.id = pc.category_id - LEFT JOIN products p ON pc.product_id = p.product_id - WHERE p.visible = true - GROUP BY c.name - ORDER BY count DESC - LIMIT 10 - `); - res.json(rows); - } catch (error) { - console.error('Error fetching category stats:', error); - res.status(500).json({ error: 'Failed to fetch category stats' }); - } -}); - -// Get stock levels -router.get('/stock-levels', async (req, res) => { - const pool = req.app.locals.pool; - try { - const [rows] = await pool.query(` - SELECT - SUM(CASE WHEN stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock, - SUM(CASE WHEN stock_quantity > 0 AND stock_quantity <= 5 THEN 1 ELSE 0 END) as lowStock, - SUM(CASE WHEN stock_quantity > 5 AND stock_quantity <= 20 THEN 1 ELSE 0 END) as inStock, - SUM(CASE WHEN stock_quantity > 20 THEN 1 ELSE 0 END) as overStock - FROM products - WHERE visible = true - `); - res.json(rows[0]); - } catch (error) { - console.error('Error fetching stock levels:', error); - res.status(500).json({ error: 'Failed to fetch stock levels' }); - } -}); - -// Get sales by category -router.get('/sales-by-category', async (req, res) => { - const pool = req.app.locals.pool; - try { - const [rows] = await pool.query(` - SELECT - c.name as category, - SUM(o.price * o.quantity) as total - FROM orders o - JOIN products p ON o.product_id = p.product_id - JOIN product_categories pc ON p.product_id = pc.product_id - JOIN categories c ON pc.category_id = c.id - WHERE o.canceled = false - AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) - GROUP BY c.name - ORDER BY total DESC - LIMIT 6 - `); - - const total = rows.reduce((sum, row) => sum + parseFloat(row.total || 0), 0); - - res.json(rows.map(row => ({ - category: row.category || 'Uncategorized', - total: parseFloat(row.total || 0), - percentage: total > 0 ? (parseFloat(row.total || 0) / total) : 0 - }))); - } catch (error) { - console.error('Error fetching sales by category:', error); - res.status(500).json({ error: 'Failed to fetch sales by category' }); - } -}); - -// Get inventory health summary -router.get('/inventory/health/summary', async (req, res) => { - const pool = req.app.locals.pool; - try { - // First check what statuses exist - const [checkStatuses] = await pool.query(` - SELECT DISTINCT stock_status - FROM product_metrics - WHERE stock_status IS NOT NULL - `); - console.log('Available stock statuses:', checkStatuses.map(row => row.stock_status)); - - const [rows] = await pool.query(` - WITH normalized_status AS ( - SELECT - CASE - WHEN stock_status = 'Overstocked' THEN 'Overstock' - WHEN stock_status = 'New' THEN 'Healthy' - ELSE stock_status - END as status - FROM product_metrics - WHERE stock_status IS NOT NULL - ) - SELECT - status as stock_status, - COUNT(*) as count - FROM normalized_status - GROUP BY status - `); - - console.log('Raw inventory health summary:', rows); - - // Convert array to object with lowercase keys - const summary = { - critical: 0, - reorder: 0, - healthy: 0, - overstock: 0 - }; - - rows.forEach(row => { - const key = row.stock_status.toLowerCase(); - if (key in summary) { - summary[key] = parseInt(row.count); - } - }); - - // Calculate total - summary.total = Object.values(summary).reduce((a, b) => a + b, 0); - - console.log('Final inventory health summary:', summary); - res.json(summary); - } catch (error) { - console.error('Error fetching inventory health summary:', error); - res.status(500).json({ error: 'Failed to fetch inventory health summary' }); - } -}); - -// Get low stock alerts -router.get('/inventory/low-stock', async (req, res) => { - const pool = req.app.locals.pool; - try { - const [rows] = await pool.query(` - SELECT - p.product_id, - p.sku, - p.title, - p.stock_quantity, - pm.reorder_point, - pm.days_of_inventory, - pm.daily_sales_avg, - pm.stock_status - FROM product_metrics pm - JOIN products p ON pm.product_id = p.product_id - WHERE pm.stock_status IN ('Critical', 'Reorder') - ORDER BY - CASE pm.stock_status - WHEN 'Critical' THEN 1 - WHEN 'Reorder' THEN 2 - ELSE 3 - END, - pm.days_of_inventory ASC - LIMIT 50 - `); - res.json(rows); - } catch (error) { - console.error('Error fetching low stock alerts:', error); - res.status(500).json({ error: 'Failed to fetch low stock alerts' }); - } -}); - -// Get vendor performance metrics -router.get('/vendors/metrics', async (req, res) => { - const pool = req.app.locals.pool; - try { - console.log('Fetching vendor metrics...'); - const [rows] = await pool.query(` - SELECT - vendor, - avg_lead_time_days, - on_time_delivery_rate, - order_fill_rate, - total_orders, - total_late_orders, - total_purchase_value, - avg_order_value - FROM vendor_metrics - ORDER BY on_time_delivery_rate DESC - `); - console.log('Found vendor metrics:', rows.length, 'rows'); - console.log('First row sample:', rows[0]); - - const mappedRows = rows.map(row => ({ - ...row, - avg_lead_time_days: parseFloat(row.avg_lead_time_days || 0), - on_time_delivery_rate: parseFloat(row.on_time_delivery_rate || 0), - order_fill_rate: parseFloat(row.order_fill_rate || 0), - total_purchase_value: parseFloat(row.total_purchase_value || 0), - avg_order_value: parseFloat(row.avg_order_value || 0) - })); - console.log('First mapped row sample:', mappedRows[0]); - - res.json(mappedRows); - } catch (error) { - console.error('Error fetching vendor metrics:', error); - res.status(500).json({ error: 'Failed to fetch vendor metrics' }); - } -}); - -// Get trending products -router.get('/products/trending', async (req, res) => { - const pool = req.app.locals.pool; - try { - // First check if we have any data - const [checkData] = await pool.query(` - SELECT COUNT(*) as count, - MAX(total_revenue) as max_revenue, - MAX(daily_sales_avg) as max_daily_sales, - COUNT(DISTINCT product_id) as products_with_metrics - FROM product_metrics - WHERE total_revenue > 0 OR daily_sales_avg > 0 - `); - console.log('Product metrics stats:', checkData[0]); - - if (checkData[0].count === 0) { - console.log('No products with metrics found'); - return res.json([]); +// Helper function to execute queries using the connection pool +async function executeQuery(sql, params = []) { + const pool = db.getPool(); + if (!pool) { + throw new Error('Database pool not initialized'); } + return pool.query(sql, params); +} - // Get trending products - const [rows] = await pool.query(` - SELECT - p.product_id, - p.sku, - p.title, - COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg, - COALESCE(pm.weekly_sales_avg, 0) as weekly_sales_avg, - CASE - WHEN pm.weekly_sales_avg > 0 AND pm.daily_sales_avg > 0 - THEN ((pm.daily_sales_avg - pm.weekly_sales_avg) / pm.weekly_sales_avg) * 100 - ELSE 0 - END as growth_rate, - COALESCE(pm.total_revenue, 0) as total_revenue - FROM products p - INNER JOIN product_metrics pm ON p.product_id = pm.product_id - WHERE (pm.total_revenue > 0 OR pm.daily_sales_avg > 0) - AND p.visible = true - ORDER BY growth_rate DESC - LIMIT 50 - `); +// GET /dashboard/stock/metrics +// Returns brand-level stock metrics +router.get('/stock/metrics', async (req, res) => { + try { + const [rows] = await executeQuery(` + SELECT + bm.*, + COALESCE( + SUM(CASE + WHEN pm.stock_status = 'Critical' THEN 1 + ELSE 0 + END) + , 0) as critical_stock_count, + COALESCE( + SUM(CASE + WHEN pm.stock_status = 'Reorder' THEN 1 + ELSE 0 + END) + , 0) as reorder_count, + COALESCE( + SUM(CASE + WHEN pm.stock_status = 'Overstocked' THEN 1 + ELSE 0 + END) + , 0) as overstock_count + FROM brand_metrics bm + LEFT JOIN products p ON p.brand = bm.brand + LEFT JOIN product_metrics pm ON p.product_id = pm.product_id + GROUP BY bm.brand + ORDER BY bm.total_revenue DESC + `); + res.json(rows); + } catch (err) { + console.error('Error fetching stock metrics:', err); + res.status(500).json({ error: 'Failed to fetch stock metrics' }); + } +}); - console.log('Trending products:', rows); - res.json(rows); - } catch (error) { - console.error('Error fetching trending products:', error); - res.status(500).json({ error: 'Failed to fetch trending products' }); - } +// GET /dashboard/purchase/metrics +// Returns purchase order metrics by vendor +router.get('/purchase/metrics', async (req, res) => { + try { + const [rows] = await executeQuery(` + SELECT + vm.*, + COUNT(DISTINCT CASE + WHEN po.status = 'open' THEN po.po_id + END) as active_orders, + COUNT(DISTINCT CASE + WHEN po.status = 'open' + AND po.expected_date < CURDATE() + THEN po.po_id + END) as overdue_orders, + SUM(CASE + WHEN po.status = 'open' + THEN po.ordered * po.cost_price + ELSE 0 + END) as active_order_value + FROM vendor_metrics vm + LEFT JOIN purchase_orders po ON vm.vendor = po.vendor + GROUP BY vm.vendor + ORDER BY vm.total_purchase_value DESC + `); + res.json(rows); + } catch (err) { + console.error('Error fetching purchase metrics:', err); + res.status(500).json({ error: 'Failed to fetch purchase metrics' }); + } +}); + +// GET /dashboard/replenishment/metrics +// Returns replenishment needs by category +router.get('/replenishment/metrics', async (req, res) => { + try { + const [rows] = await executeQuery(` + WITH category_replenishment AS ( + SELECT + c.id as category_id, + c.name as category_name, + COUNT(DISTINCT CASE + WHEN pm.stock_status IN ('Critical', 'Reorder') + THEN p.product_id + END) as products_to_replenish, + SUM(CASE + WHEN pm.stock_status IN ('Critical', 'Reorder') + THEN pm.reorder_qty + ELSE 0 + END) as total_units_needed, + SUM(CASE + WHEN pm.stock_status IN ('Critical', 'Reorder') + THEN pm.reorder_qty * p.cost_price + ELSE 0 + END) as total_replenishment_cost, + SUM(CASE + WHEN pm.stock_status IN ('Critical', 'Reorder') + THEN pm.reorder_qty * p.price + ELSE 0 + END) as total_replenishment_retail + FROM categories c + JOIN product_categories pc ON c.id = pc.category_id + JOIN products p ON pc.product_id = p.product_id + JOIN product_metrics pm ON p.product_id = pm.product_id + WHERE p.replenishable = true + GROUP BY c.id, c.name + ) + SELECT + cr.*, + cm.total_value as category_total_value, + cm.turnover_rate as category_turnover_rate + FROM category_replenishment cr + LEFT JOIN category_metrics cm ON cr.category_id = cm.category_id + WHERE cr.products_to_replenish > 0 + ORDER BY cr.total_replenishment_cost DESC + `); + res.json(rows); + } catch (err) { + console.error('Error fetching replenishment metrics:', err); + res.status(500).json({ error: 'Failed to fetch replenishment metrics' }); + } +}); + +// GET /dashboard/forecast/metrics +// Returns sales forecasts for specified period +router.get('/forecast/metrics', async (req, res) => { + const days = Math.max(1, Math.min(365, parseInt(req.query.days) || 30)); + try { + const [rows] = await executeQuery(` + WITH daily_forecasts AS ( + SELECT + forecast_date, + SUM(forecast_units) as total_units, + SUM(forecast_revenue) as total_revenue, + AVG(confidence_level) as avg_confidence + FROM sales_forecasts + WHERE forecast_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) + GROUP BY forecast_date + ), + category_forecasts_summary AS ( + SELECT + c.name as category_name, + SUM(cf.forecast_units) as category_units, + SUM(cf.forecast_revenue) as category_revenue, + AVG(cf.confidence_level) as category_confidence + FROM category_forecasts cf + JOIN categories c ON cf.category_id = c.id + WHERE cf.forecast_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) + GROUP BY c.id, c.name + ) + SELECT + SUM(df.total_units) as total_forecast_units, + SUM(df.total_revenue) as total_forecast_revenue, + AVG(df.avg_confidence) as overall_confidence, + JSON_ARRAYAGG( + JSON_OBJECT( + 'date', df.forecast_date, + 'units', df.total_units, + 'revenue', df.total_revenue, + 'confidence', df.avg_confidence + ) + ) as daily_data, + JSON_ARRAYAGG( + JSON_OBJECT( + 'category', cfs.category_name, + 'units', cfs.category_units, + 'revenue', cfs.category_revenue, + 'confidence', cfs.category_confidence + ) + ) as category_data + FROM daily_forecasts df + CROSS JOIN category_forecasts_summary cfs + `, [days, days]); + res.json(rows[0]); + } catch (err) { + console.error('Error fetching forecast metrics:', err); + res.status(500).json({ error: 'Failed to fetch forecast metrics' }); + } +}); + +// GET /dashboard/overstock/metrics +// Returns overstock metrics by category +router.get('/overstock/metrics', async (req, res) => { + try { + const [rows] = await executeQuery(` + WITH category_overstock AS ( + SELECT + c.id as category_id, + c.name as category_name, + COUNT(DISTINCT CASE + WHEN pm.stock_status = 'Overstocked' + THEN p.product_id + END) as overstocked_products, + SUM(CASE + WHEN pm.stock_status = 'Overstocked' + THEN pm.overstocked_amt + ELSE 0 + END) as total_excess_units, + SUM(CASE + WHEN pm.stock_status = 'Overstocked' + THEN pm.overstocked_amt * p.cost_price + ELSE 0 + END) as total_excess_cost, + SUM(CASE + WHEN pm.stock_status = 'Overstocked' + THEN pm.overstocked_amt * p.price + ELSE 0 + END) as total_excess_retail + FROM categories c + JOIN product_categories pc ON c.id = pc.category_id + JOIN products p ON pc.product_id = p.product_id + JOIN product_metrics pm ON p.product_id = pm.product_id + GROUP BY c.id, c.name + ) + SELECT + co.*, + cm.total_value as category_total_value, + cm.turnover_rate as category_turnover_rate + FROM category_overstock co + LEFT JOIN category_metrics cm ON co.category_id = cm.category_id + WHERE co.overstocked_products > 0 + ORDER BY co.total_excess_cost DESC + `); + res.json(rows); + } catch (err) { + console.error('Error fetching overstock metrics:', err); + res.status(500).json({ error: 'Failed to fetch overstock metrics' }); + } +}); + +// GET /dashboard/overstock/products +// Returns list of most overstocked products +router.get('/overstock/products', async (req, res) => { + const limit = parseInt(req.query.limit) || 50; + try { + const [rows] = await executeQuery(` + SELECT + p.product_id, + p.SKU, + p.title, + p.brand, + p.vendor, + p.stock_quantity, + p.cost_price, + p.price, + pm.daily_sales_avg, + pm.days_of_inventory, + pm.overstocked_amt, + (pm.overstocked_amt * p.cost_price) as excess_cost, + (pm.overstocked_amt * p.price) as excess_retail, + GROUP_CONCAT(c.name) as categories + FROM products p + JOIN product_metrics pm ON p.product_id = pm.product_id + LEFT JOIN product_categories pc ON p.product_id = pc.product_id + LEFT JOIN categories c ON pc.category_id = c.id + WHERE pm.stock_status = 'Overstocked' + GROUP BY p.product_id + ORDER BY excess_cost DESC + LIMIT ? + `, [limit]); + res.json(rows); + } catch (err) { + console.error('Error fetching overstocked products:', err); + res.status(500).json({ error: 'Failed to fetch overstocked products' }); + } +}); + +// GET /dashboard/best-sellers +// Returns best-selling products, vendors, and categories +router.get('/best-sellers', async (req, res) => { + try { + const [products] = await executeQuery(` + SELECT + p.product_id, + p.SKU, + p.title, + p.brand, + p.vendor, + pm.total_revenue, + pm.daily_sales_avg, + pm.number_of_orders, + GROUP_CONCAT(c.name) as categories + FROM products p + JOIN product_metrics pm ON p.product_id = pm.product_id + LEFT JOIN product_categories pc ON p.product_id = pc.product_id + LEFT JOIN categories c ON pc.category_id = c.id + GROUP BY p.product_id + ORDER BY pm.total_revenue DESC + LIMIT 10 + `); + + const [vendors] = await executeQuery(` + SELECT + vm.* + FROM vendor_metrics vm + ORDER BY vm.total_revenue DESC + LIMIT 10 + `); + + const [categories] = await executeQuery(` + SELECT + c.name, + cm.* + FROM category_metrics cm + JOIN categories c ON cm.category_id = c.id + ORDER BY cm.total_value DESC + LIMIT 10 + `); + + res.json({ + products, + vendors, + categories + }); + } catch (err) { + console.error('Error fetching best sellers:', err); + res.status(500).json({ error: 'Failed to fetch best sellers' }); + } +}); + +// GET /dashboard/sales/metrics +// Returns sales metrics for specified period +router.get('/sales/metrics', async (req, res) => { + const days = Math.max(1, Math.min(365, parseInt(req.query.days) || 30)); + try { + const [rows] = await executeQuery(` + WITH daily_sales AS ( + SELECT + DATE(o.date) as sale_date, + COUNT(DISTINCT o.order_number) as total_orders, + SUM(o.quantity) as total_units, + SUM(o.price * o.quantity) as total_revenue, + SUM(p.cost_price * o.quantity) as total_cogs, + SUM((o.price - p.cost_price) * o.quantity) as total_profit + FROM orders o + JOIN products p ON o.product_id = p.product_id + WHERE o.canceled = false + AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + GROUP BY DATE(o.date) + ), + category_sales AS ( + SELECT + c.name as category_name, + COUNT(DISTINCT o.order_number) as category_orders, + SUM(o.quantity) as category_units, + SUM(o.price * o.quantity) as category_revenue + FROM orders o + JOIN products p ON o.product_id = p.product_id + JOIN product_categories pc ON p.product_id = pc.product_id + JOIN categories c ON pc.category_id = c.id + WHERE o.canceled = false + AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + GROUP BY c.id, c.name + ) + SELECT + COUNT(DISTINCT ds.sale_date) as days_with_sales, + SUM(ds.total_orders) as total_orders, + SUM(ds.total_units) as total_units, + SUM(ds.total_revenue) as total_revenue, + SUM(ds.total_cogs) as total_cogs, + SUM(ds.total_profit) as total_profit, + AVG(ds.total_orders) as avg_daily_orders, + AVG(ds.total_units) as avg_daily_units, + AVG(ds.total_revenue) as avg_daily_revenue, + JSON_ARRAYAGG( + JSON_OBJECT( + 'date', ds.sale_date, + 'orders', ds.total_orders, + 'units', ds.total_units, + 'revenue', ds.total_revenue, + 'cogs', ds.total_cogs, + 'profit', ds.total_profit + ) + ) as daily_data, + JSON_ARRAYAGG( + JSON_OBJECT( + 'category', cs.category_name, + 'orders', cs.category_orders, + 'units', cs.category_units, + 'revenue', cs.category_revenue + ) + ) as category_data + FROM daily_sales ds + CROSS JOIN category_sales cs + `, [days, days]); + res.json(rows[0]); + } catch (err) { + console.error('Error fetching sales metrics:', err); + res.status(500).json({ error: 'Failed to fetch sales metrics' }); + } +}); + +// GET /dashboard/low-stock/products +// Returns list of products with critical or low stock levels +router.get('/low-stock/products', async (req, res) => { + const limit = parseInt(req.query.limit) || 50; + try { + const [rows] = await executeQuery(` + SELECT + p.product_id, + p.SKU, + p.title, + p.brand, + p.vendor, + p.stock_quantity, + p.cost_price, + p.price, + pm.daily_sales_avg, + pm.days_of_inventory, + pm.reorder_qty, + (pm.reorder_qty * p.cost_price) as reorder_cost, + GROUP_CONCAT(c.name) as categories + FROM products p + JOIN product_metrics pm ON p.product_id = pm.product_id + LEFT JOIN product_categories pc ON p.product_id = pc.product_id + LEFT JOIN categories c ON pc.category_id = c.id + WHERE pm.stock_status IN ('Critical', 'Reorder') + AND p.replenishable = true + GROUP BY p.product_id + ORDER BY + CASE pm.stock_status + WHEN 'Critical' THEN 1 + WHEN 'Reorder' THEN 2 + END, + pm.days_of_inventory ASC + LIMIT ? + `, [limit]); + res.json(rows); + } catch (err) { + console.error('Error fetching low stock products:', err); + res.status(500).json({ error: 'Failed to fetch low stock products' }); + } +}); + +// GET /dashboard/trending/products +// Returns list of trending products based on recent sales velocity +router.get('/trending/products', async (req, res) => { + const days = parseInt(req.query.days) || 30; + const limit = parseInt(req.query.limit) || 20; + try { + const [rows] = await executeQuery(` + WITH recent_sales AS ( + SELECT + o.product_id, + COUNT(DISTINCT o.order_number) as recent_orders, + SUM(o.quantity) as recent_units, + SUM(o.price * o.quantity) as recent_revenue + FROM orders o + WHERE o.canceled = false + AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + GROUP BY o.product_id + ) + SELECT + p.product_id, + p.SKU, + p.title, + p.brand, + p.vendor, + p.stock_quantity, + rs.recent_orders, + rs.recent_units, + rs.recent_revenue, + pm.daily_sales_avg, + pm.stock_status, + (rs.recent_units / ?) as daily_velocity, + ((rs.recent_units / ?) - pm.daily_sales_avg) / pm.daily_sales_avg * 100 as velocity_change, + GROUP_CONCAT(c.name) as categories + FROM recent_sales rs + JOIN products p ON rs.product_id = p.product_id + JOIN product_metrics pm ON p.product_id = pm.product_id + LEFT JOIN product_categories pc ON p.product_id = pc.product_id + LEFT JOIN categories c ON pc.category_id = c.id + GROUP BY p.product_id + HAVING velocity_change > 0 + ORDER BY velocity_change DESC + LIMIT ? + `, [days, days, days, limit]); + res.json(rows); + } catch (err) { + console.error('Error fetching trending products:', err); + res.status(500).json({ error: 'Failed to fetch trending products' }); + } +}); + +// GET /dashboard/vendor/performance +// Returns detailed vendor performance metrics +router.get('/vendor/performance', async (req, res) => { + try { + const [rows] = await executeQuery(` + WITH vendor_orders AS ( + SELECT + po.vendor, + COUNT(DISTINCT po.po_id) as total_orders, + AVG(DATEDIFF(po.delivery_date, po.order_date)) as avg_lead_time, + AVG(CASE + WHEN po.status = 'completed' + THEN DATEDIFF(po.delivery_date, po.expected_date) + END) as avg_delay, + SUM(CASE + WHEN po.status = 'completed' AND po.delivery_date <= po.expected_date + THEN 1 + ELSE 0 + END) * 100.0 / COUNT(*) as on_time_delivery_rate, + AVG(po.fill_rate) as avg_fill_rate + FROM purchase_orders po + WHERE po.order_date >= DATE_SUB(CURDATE(), INTERVAL 180 DAY) + GROUP BY po.vendor + ) + SELECT + v.*, + vo.total_orders, + vo.avg_lead_time, + vo.avg_delay, + vo.on_time_delivery_rate, + vo.avg_fill_rate, + vm.total_purchase_value, + vm.total_revenue, + vm.product_count, + vm.active_products + FROM vendors v + JOIN vendor_orders vo ON v.vendor = vo.vendor + JOIN vendor_metrics vm ON v.vendor = vm.vendor + ORDER BY vm.total_revenue DESC + `); + res.json(rows); + } catch (err) { + console.error('Error fetching vendor performance:', err); + res.status(500).json({ error: 'Failed to fetch vendor performance' }); + } +}); + +// GET /dashboard/key-metrics +// Returns key business metrics and KPIs +router.get('/key-metrics', async (req, res) => { + const days = Math.max(1, Math.min(365, parseInt(req.query.days) || 30)); + try { + const [rows] = await executeQuery(` + WITH inventory_summary AS ( + SELECT + COUNT(*) as total_products, + SUM(p.stock_quantity * p.cost_price) as total_inventory_value, + AVG(pm.turnover_rate) as avg_turnover_rate, + COUNT(CASE WHEN pm.stock_status = 'Critical' THEN 1 END) as critical_stock_count, + COUNT(CASE WHEN pm.stock_status = 'Overstocked' THEN 1 END) as overstock_count + FROM products p + JOIN product_metrics pm ON p.product_id = pm.product_id + ), + sales_summary AS ( + SELECT + COUNT(DISTINCT order_number) as total_orders, + SUM(quantity) as total_units_sold, + SUM(price * quantity) as total_revenue, + AVG(price * quantity) as avg_order_value + FROM orders + WHERE canceled = false + AND date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + ), + purchase_summary AS ( + SELECT + COUNT(DISTINCT po_id) as total_pos, + SUM(ordered * cost_price) as total_po_value, + COUNT(CASE WHEN status = 'open' THEN 1 END) as open_pos + FROM purchase_orders + WHERE order_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + ) + SELECT + i.*, + s.*, + p.* + FROM inventory_summary i + CROSS JOIN sales_summary s + CROSS JOIN purchase_summary p + `, [days, days]); + res.json(rows[0]); + } catch (err) { + console.error('Error fetching key metrics:', err); + res.status(500).json({ error: 'Failed to fetch key metrics' }); + } +}); + +// GET /dashboard/inventory-health +// Returns overall inventory health metrics +router.get('/inventory-health', async (req, res) => { + try { + const [rows] = await executeQuery(` + WITH stock_distribution AS ( + SELECT + COUNT(*) as total_products, + SUM(CASE WHEN pm.stock_status = 'Healthy' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as healthy_stock_percent, + SUM(CASE WHEN pm.stock_status = 'Critical' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as critical_stock_percent, + SUM(CASE WHEN pm.stock_status = 'Reorder' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as reorder_stock_percent, + SUM(CASE WHEN pm.stock_status = 'Overstocked' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as overstock_percent, + AVG(pm.turnover_rate) as avg_turnover_rate, + AVG(pm.days_of_inventory) as avg_days_inventory + FROM products p + JOIN product_metrics pm ON p.product_id = pm.product_id + WHERE p.replenishable = true + ), + value_distribution AS ( + SELECT + SUM(p.stock_quantity * p.cost_price) as total_inventory_value, + SUM(CASE + WHEN pm.stock_status = 'Healthy' + THEN p.stock_quantity * p.cost_price + ELSE 0 + END) * 100.0 / SUM(p.stock_quantity * p.cost_price) as healthy_value_percent, + SUM(CASE + WHEN pm.stock_status = 'Critical' + THEN p.stock_quantity * p.cost_price + ELSE 0 + END) * 100.0 / SUM(p.stock_quantity * p.cost_price) as critical_value_percent, + SUM(CASE + WHEN pm.stock_status = 'Overstocked' + THEN p.stock_quantity * p.cost_price + ELSE 0 + END) * 100.0 / SUM(p.stock_quantity * p.cost_price) as overstock_value_percent + FROM products p + JOIN product_metrics pm ON p.product_id = pm.product_id + ), + category_health AS ( + SELECT + c.name as category_name, + COUNT(*) as category_products, + SUM(CASE WHEN pm.stock_status = 'Healthy' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as category_healthy_percent, + AVG(pm.turnover_rate) as category_turnover_rate + FROM categories c + JOIN product_categories pc ON c.id = pc.category_id + JOIN products p ON pc.product_id = p.product_id + JOIN product_metrics pm ON p.product_id = pm.product_id + WHERE p.replenishable = true + GROUP BY c.id, c.name + ) + SELECT + sd.*, + vd.*, + JSON_ARRAYAGG( + JSON_OBJECT( + 'category', ch.category_name, + 'products', ch.category_products, + 'healthy_percent', ch.category_healthy_percent, + 'turnover_rate', ch.category_turnover_rate + ) + ) as category_health + FROM stock_distribution sd + CROSS JOIN value_distribution vd + CROSS JOIN category_health ch + `); + res.json(rows[0]); + } catch (err) { + console.error('Error fetching inventory health:', err); + res.status(500).json({ error: 'Failed to fetch inventory health' }); + } }); module.exports = router; \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 78f3051..0519ecb 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,23 +1 @@ -/** - * Format a number as currency with the specified locale and currency code - * @param value - The number to format - * @param locale - The locale to use for formatting (defaults to 'en-US') - * @param currency - The currency code to use (defaults to 'USD') - * @returns Formatted currency string - */ -export function formatCurrency( - value: number | null | undefined, - locale: string = 'en-US', - currency: string = 'USD' -): string { - if (value === null || value === undefined) { - return '$0.00'; - } - - return new Intl.NumberFormat(locale, { - style: 'currency', - currency: currency, - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(value); -} \ No newline at end of file + \ No newline at end of file From 6b7a62ffaf22faf305cc84aa3b928dd8cf325e43 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 17:09:26 -0500 Subject: [PATCH 04/17] Fix some backend issues, get dashboard loading without crashing --- inventory-server/src/routes/dashboard.js | 556 ++++++++++++------ .../components/dashboard/LowStockAlerts.tsx | 10 +- .../dashboard/TopOverstockedProducts.tsx | 14 +- .../dashboard/VendorPerformance.tsx | 11 +- 4 files changed, 381 insertions(+), 210 deletions(-) diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index 456029d..5a2bbed 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -15,34 +15,45 @@ async function executeQuery(sql, params = []) { // Returns brand-level stock metrics router.get('/stock/metrics', async (req, res) => { try { - const [rows] = await executeQuery(` + // Get stock metrics + const [stockMetrics] = await executeQuery(` SELECT - bm.*, - COALESCE( - SUM(CASE - WHEN pm.stock_status = 'Critical' THEN 1 - ELSE 0 - END) - , 0) as critical_stock_count, - COALESCE( - SUM(CASE - WHEN pm.stock_status = 'Reorder' THEN 1 - ELSE 0 - END) - , 0) as reorder_count, - COALESCE( - SUM(CASE - WHEN pm.stock_status = 'Overstocked' THEN 1 - ELSE 0 - END) - , 0) as overstock_count - FROM brand_metrics bm - LEFT JOIN products p ON p.brand = bm.brand - LEFT JOIN product_metrics pm ON p.product_id = pm.product_id - GROUP BY bm.brand - ORDER BY bm.total_revenue DESC + COALESCE(COUNT(*), 0) as total_products, + COALESCE(COUNT(CASE WHEN stock_quantity > 0 THEN 1 END), 0) as products_in_stock, + COALESCE(SUM(stock_quantity), 0) as total_units, + COALESCE(SUM(stock_quantity * cost_price), 0) as total_cost, + COALESCE(SUM(stock_quantity * price), 0) as total_retail + FROM products `); - res.json(rows); + + // Get brand values in a separate query + const [brandValues] = await executeQuery(` + SELECT + brand, + COALESCE(SUM(stock_quantity * price), 0) as value + FROM products + WHERE brand IS NOT NULL + AND stock_quantity > 0 + GROUP BY brand + HAVING value > 0 + ORDER BY value DESC + LIMIT 8 + `); + + // Format the response with explicit type conversion + const response = { + totalProducts: parseInt(stockMetrics.total_products) || 0, + productsInStock: parseInt(stockMetrics.products_in_stock) || 0, + totalStockUnits: parseInt(stockMetrics.total_units) || 0, + totalStockCost: parseFloat(stockMetrics.total_cost) || 0, + totalStockRetail: parseFloat(stockMetrics.total_retail) || 0, + brandRetailValue: brandValues.map(b => ({ + brand: b.brand, + value: parseFloat(b.value) || 0 + })) + }; + + res.json(response); } catch (err) { console.error('Error fetching stock metrics:', err); res.status(500).json({ error: 'Failed to fetch stock metrics' }); @@ -53,28 +64,55 @@ router.get('/stock/metrics', async (req, res) => { // Returns purchase order metrics by vendor router.get('/purchase/metrics', async (req, res) => { try { - const [rows] = await executeQuery(` + const [poMetrics] = await executeQuery(` SELECT - vm.*, - COUNT(DISTINCT CASE - WHEN po.status = 'open' THEN po.po_id - END) as active_orders, - COUNT(DISTINCT CASE - WHEN po.status = 'open' - AND po.expected_date < CURDATE() + COALESCE(COUNT(DISTINCT CASE WHEN po.status = 'open' THEN po.po_id END), 0) as active_pos, + COALESCE(COUNT(DISTINCT CASE + WHEN po.status = 'open' AND po.expected_date < CURDATE() THEN po.po_id - END) as overdue_orders, - SUM(CASE + END), 0) as overdue_pos, + COALESCE(SUM(CASE WHEN po.status = 'open' THEN po.ordered ELSE 0 END), 0) as total_units, + COALESCE(SUM(CASE WHEN po.status = 'open' THEN po.ordered * po.cost_price ELSE 0 - END) as active_order_value - FROM vendor_metrics vm - LEFT JOIN purchase_orders po ON vm.vendor = po.vendor - GROUP BY vm.vendor - ORDER BY vm.total_purchase_value DESC + END), 0) as total_cost, + COALESCE(SUM(CASE + WHEN po.status = 'open' + THEN po.ordered * p.price + ELSE 0 + END), 0) as total_retail + FROM purchase_orders po + JOIN products p ON po.product_id = p.product_id `); - res.json(rows); + + const [vendorValues] = await executeQuery(` + SELECT + po.vendor, + COALESCE(SUM(CASE + WHEN po.status = 'open' + THEN po.ordered * po.cost_price + ELSE 0 + END), 0) as value + FROM purchase_orders po + WHERE po.status = 'open' + GROUP BY po.vendor + HAVING value > 0 + ORDER BY value DESC + LIMIT 8 + `); + + res.json({ + activePurchaseOrders: parseInt(poMetrics.active_pos) || 0, + overduePurchaseOrders: parseInt(poMetrics.overdue_pos) || 0, + onOrderUnits: parseInt(poMetrics.total_units) || 0, + onOrderCost: parseFloat(poMetrics.total_cost) || 0, + onOrderRetail: parseFloat(poMetrics.total_retail) || 0, + vendorOrderValue: vendorValues.map(v => ({ + vendor: v.vendor, + value: parseFloat(v.value) || 0 + })) + }); } catch (err) { console.error('Error fetching purchase metrics:', err); res.status(500).json({ error: 'Failed to fetch purchase metrics' }); @@ -85,47 +123,83 @@ router.get('/purchase/metrics', async (req, res) => { // Returns replenishment needs by category router.get('/replenishment/metrics', async (req, res) => { try { - const [rows] = await executeQuery(` - WITH category_replenishment AS ( - SELECT - c.id as category_id, - c.name as category_name, - COUNT(DISTINCT CASE - WHEN pm.stock_status IN ('Critical', 'Reorder') - THEN p.product_id - END) as products_to_replenish, - SUM(CASE - WHEN pm.stock_status IN ('Critical', 'Reorder') - THEN pm.reorder_qty - ELSE 0 - END) as total_units_needed, - SUM(CASE - WHEN pm.stock_status IN ('Critical', 'Reorder') - THEN pm.reorder_qty * p.cost_price - ELSE 0 - END) as total_replenishment_cost, - SUM(CASE - WHEN pm.stock_status IN ('Critical', 'Reorder') - THEN pm.reorder_qty * p.price - ELSE 0 - END) as total_replenishment_retail - FROM categories c - JOIN product_categories pc ON c.id = pc.category_id - JOIN products p ON pc.product_id = p.product_id - JOIN product_metrics pm ON p.product_id = pm.product_id - WHERE p.replenishable = true - GROUP BY c.id, c.name - ) + // Get summary metrics + const [metrics] = await executeQuery(` SELECT - cr.*, - cm.total_value as category_total_value, - cm.turnover_rate as category_turnover_rate - FROM category_replenishment cr - LEFT JOIN category_metrics cm ON cr.category_id = cm.category_id - WHERE cr.products_to_replenish > 0 - ORDER BY cr.total_replenishment_cost DESC + COUNT(DISTINCT CASE + WHEN pm.stock_status IN ('Critical', 'Reorder') + THEN p.product_id + END) as products_to_replenish, + SUM(CASE + WHEN pm.stock_status IN ('Critical', 'Reorder') + THEN pm.reorder_qty + ELSE 0 + END) as total_units_needed, + SUM(CASE + WHEN pm.stock_status IN ('Critical', 'Reorder') + THEN pm.reorder_qty * p.cost_price + ELSE 0 + END) as total_cost, + SUM(CASE + WHEN pm.stock_status IN ('Critical', 'Reorder') + THEN pm.reorder_qty * p.price + ELSE 0 + END) as total_retail + FROM products p + JOIN product_metrics pm ON p.product_id = pm.product_id + WHERE p.replenishable = true `); - res.json(rows); + + // Get category breakdown + const [categories] = await executeQuery(` + SELECT + c.name as category, + COUNT(DISTINCT CASE + WHEN pm.stock_status IN ('Critical', 'Reorder') + THEN p.product_id + END) as products, + SUM(CASE + WHEN pm.stock_status IN ('Critical', 'Reorder') + THEN pm.reorder_qty + ELSE 0 + END) as units, + SUM(CASE + WHEN pm.stock_status IN ('Critical', 'Reorder') + THEN pm.reorder_qty * p.cost_price + ELSE 0 + END) as cost, + SUM(CASE + WHEN pm.stock_status IN ('Critical', 'Reorder') + THEN pm.reorder_qty * p.price + ELSE 0 + END) as retail + FROM categories c + JOIN product_categories pc ON c.id = pc.category_id + JOIN products p ON pc.product_id = p.product_id + JOIN product_metrics pm ON p.product_id = pm.product_id + WHERE p.replenishable = true + GROUP BY c.id, c.name + HAVING products > 0 + ORDER BY cost DESC + LIMIT 8 + `); + + // Format response + const response = { + productsToReplenish: parseInt(metrics.products_to_replenish) || 0, + totalUnitsToReplenish: parseInt(metrics.total_units_needed) || 0, + totalReplenishmentCost: parseFloat(metrics.total_cost) || 0, + totalReplenishmentRetail: parseFloat(metrics.total_retail) || 0, + categoryData: categories.map(c => ({ + category: c.category, + products: parseInt(c.products) || 0, + units: parseInt(c.units) || 0, + cost: parseFloat(c.cost) || 0, + retail: parseFloat(c.retail) || 0 + })) + }; + + res.json(response); } catch (err) { console.error('Error fetching replenishment metrics:', err); res.status(500).json({ error: 'Failed to fetch replenishment metrics' }); @@ -137,52 +211,63 @@ router.get('/replenishment/metrics', async (req, res) => { router.get('/forecast/metrics', async (req, res) => { const days = Math.max(1, Math.min(365, parseInt(req.query.days) || 30)); try { - const [rows] = await executeQuery(` - WITH daily_forecasts AS ( - SELECT - forecast_date, - SUM(forecast_units) as total_units, - SUM(forecast_revenue) as total_revenue, - AVG(confidence_level) as avg_confidence - FROM sales_forecasts - WHERE forecast_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) - GROUP BY forecast_date - ), - category_forecasts_summary AS ( - SELECT - c.name as category_name, - SUM(cf.forecast_units) as category_units, - SUM(cf.forecast_revenue) as category_revenue, - AVG(cf.confidence_level) as category_confidence - FROM category_forecasts cf - JOIN categories c ON cf.category_id = c.id - WHERE cf.forecast_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) - GROUP BY c.id, c.name - ) + // Get summary metrics + const [metrics] = await executeQuery(` SELECT - SUM(df.total_units) as total_forecast_units, - SUM(df.total_revenue) as total_forecast_revenue, - AVG(df.avg_confidence) as overall_confidence, - JSON_ARRAYAGG( - JSON_OBJECT( - 'date', df.forecast_date, - 'units', df.total_units, - 'revenue', df.total_revenue, - 'confidence', df.avg_confidence - ) - ) as daily_data, - JSON_ARRAYAGG( - JSON_OBJECT( - 'category', cfs.category_name, - 'units', cfs.category_units, - 'revenue', cfs.category_revenue, - 'confidence', cfs.category_confidence - ) - ) as category_data - FROM daily_forecasts df - CROSS JOIN category_forecasts_summary cfs - `, [days, days]); - res.json(rows[0]); + COALESCE(SUM(forecast_units), 0) as total_forecast_units, + COALESCE(SUM(forecast_revenue), 0) as total_forecast_revenue, + COALESCE(AVG(confidence_level), 0) as overall_confidence + FROM sales_forecasts + WHERE forecast_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) + `, [days]); + + // Get daily forecasts + const [dailyForecasts] = await executeQuery(` + SELECT + forecast_date as date, + COALESCE(SUM(forecast_units), 0) as units, + COALESCE(SUM(forecast_revenue), 0) as revenue, + COALESCE(AVG(confidence_level), 0) as confidence + FROM sales_forecasts + WHERE forecast_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) + GROUP BY forecast_date + ORDER BY forecast_date + `, [days]); + + // Get category forecasts + const [categoryForecasts] = await executeQuery(` + SELECT + c.name as category, + COALESCE(SUM(cf.forecast_units), 0) as units, + COALESCE(SUM(cf.forecast_revenue), 0) as revenue, + COALESCE(AVG(cf.confidence_level), 0) as confidence + FROM category_forecasts cf + JOIN categories c ON cf.category_id = c.id + WHERE cf.forecast_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) + GROUP BY c.id, c.name + ORDER BY revenue DESC + `, [days]); + + // Format response + const response = { + forecastSales: parseInt(metrics.total_forecast_units) || 0, + forecastRevenue: parseFloat(metrics.total_forecast_revenue) || 0, + confidenceLevel: parseFloat(metrics.overall_confidence) || 0, + dailyForecasts: dailyForecasts.map(d => ({ + date: d.date, + units: parseInt(d.units) || 0, + revenue: parseFloat(d.revenue) || 0, + confidence: parseFloat(d.confidence) || 0 + })), + categoryForecasts: categoryForecasts.map(c => ({ + category: c.category, + units: parseInt(c.units) || 0, + revenue: parseFloat(c.revenue) || 0, + confidence: parseFloat(c.confidence) || 0 + })) + }; + + res.json(response); } catch (err) { console.error('Error fetching forecast metrics:', err); res.status(500).json({ error: 'Failed to fetch forecast metrics' }); @@ -224,15 +309,38 @@ router.get('/overstock/metrics', async (req, res) => { GROUP BY c.id, c.name ) SELECT - co.*, - cm.total_value as category_total_value, - cm.turnover_rate as category_turnover_rate - FROM category_overstock co - LEFT JOIN category_metrics cm ON co.category_id = cm.category_id - WHERE co.overstocked_products > 0 - ORDER BY co.total_excess_cost DESC + SUM(overstocked_products) as total_overstocked, + SUM(total_excess_units) as total_excess_units, + SUM(total_excess_cost) as total_excess_cost, + SUM(total_excess_retail) as total_excess_retail, + CAST(JSON_ARRAYAGG( + JSON_OBJECT( + 'category', category_name, + 'products', overstocked_products, + 'units', total_excess_units, + 'cost', total_excess_cost, + 'retail', total_excess_retail + ) + ) AS JSON) as category_data + FROM ( + SELECT * + FROM category_overstock + WHERE overstocked_products > 0 + ORDER BY total_excess_cost DESC + LIMIT 8 + ) filtered_categories `); - res.json(rows); + + // Format response with explicit type conversion + const response = { + overstockedProducts: parseInt(rows[0].total_overstocked) || 0, + excessUnits: parseInt(rows[0].total_excess_units) || 0, + excessCost: parseFloat(rows[0].total_excess_cost) || 0, + excessRetail: parseFloat(rows[0].total_excess_retail) || 0, + categoryData: rows[0].category_data ? JSON.parse(rows[0].category_data) : [] + }; + + res.json(response); } catch (err) { console.error('Error fetching overstock metrics:', err); res.status(500).json({ error: 'Failed to fetch overstock metrics' }); @@ -290,9 +398,11 @@ router.get('/best-sellers', async (req, res) => { pm.total_revenue, pm.daily_sales_avg, pm.number_of_orders, + SUM(o.quantity) as units_sold, GROUP_CONCAT(c.name) as categories FROM products p JOIN product_metrics pm ON p.product_id = pm.product_id + LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false LEFT JOIN product_categories pc ON p.product_id = pc.product_id LEFT JOIN categories c ON pc.category_id = c.id GROUP BY p.product_id @@ -302,8 +412,11 @@ router.get('/best-sellers', async (req, res) => { const [vendors] = await executeQuery(` SELECT - vm.* + vm.*, + COALESCE(SUM(o.quantity), 0) as products_sold FROM vendor_metrics vm + LEFT JOIN orders o ON vm.vendor = o.vendor AND o.canceled = false + GROUP BY vm.vendor ORDER BY vm.total_revenue DESC LIMIT 10 `); @@ -318,8 +431,18 @@ router.get('/best-sellers', async (req, res) => { LIMIT 10 `); + // Format response with explicit type conversion + const formattedProducts = products.map(p => ({ + ...p, + total_revenue: parseFloat(p.total_revenue) || 0, + daily_sales_avg: parseFloat(p.daily_sales_avg) || 0, + number_of_orders: parseInt(p.number_of_orders) || 0, + units_sold: parseInt(p.units_sold) || 0, + categories: p.categories ? p.categories.split(',') : [] + })); + res.json({ - products, + products: formattedProducts, vendors, categories }); @@ -334,8 +457,18 @@ router.get('/best-sellers', async (req, res) => { router.get('/sales/metrics', async (req, res) => { const days = Math.max(1, Math.min(365, parseInt(req.query.days) || 30)); try { - const [rows] = await executeQuery(` - WITH daily_sales AS ( + const [dailyData] = await executeQuery(` + SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'date', sale_date, + 'orders', COALESCE(total_orders, 0), + 'units', COALESCE(total_units, 0), + 'revenue', COALESCE(total_revenue, 0), + 'cogs', COALESCE(total_cogs, 0), + 'profit', COALESCE(total_profit, 0) + ) + ) as daily_data + FROM ( SELECT DATE(o.date) as sale_date, COUNT(DISTINCT o.order_number) as total_orders, @@ -348,8 +481,19 @@ router.get('/sales/metrics', async (req, res) => { WHERE o.canceled = false AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) GROUP BY DATE(o.date) - ), - category_sales AS ( + ) d + `, [days]); + + const [categoryData] = await executeQuery(` + SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'category', category_name, + 'orders', COALESCE(category_orders, 0), + 'units', COALESCE(category_units, 0), + 'revenue', COALESCE(category_revenue, 0) + ) + ) as category_data + FROM ( SELECT c.name as category_name, COUNT(DISTINCT o.order_number) as category_orders, @@ -362,39 +506,50 @@ router.get('/sales/metrics', async (req, res) => { WHERE o.canceled = false AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) GROUP BY c.id, c.name - ) + ) c + `, [days]); + + const [metrics] = await executeQuery(` SELECT - COUNT(DISTINCT ds.sale_date) as days_with_sales, - SUM(ds.total_orders) as total_orders, - SUM(ds.total_units) as total_units, - SUM(ds.total_revenue) as total_revenue, - SUM(ds.total_cogs) as total_cogs, - SUM(ds.total_profit) as total_profit, - AVG(ds.total_orders) as avg_daily_orders, - AVG(ds.total_units) as avg_daily_units, - AVG(ds.total_revenue) as avg_daily_revenue, - JSON_ARRAYAGG( - JSON_OBJECT( - 'date', ds.sale_date, - 'orders', ds.total_orders, - 'units', ds.total_units, - 'revenue', ds.total_revenue, - 'cogs', ds.total_cogs, - 'profit', ds.total_profit - ) - ) as daily_data, - JSON_ARRAYAGG( - JSON_OBJECT( - 'category', cs.category_name, - 'orders', cs.category_orders, - 'units', cs.category_units, - 'revenue', cs.category_revenue - ) - ) as category_data - FROM daily_sales ds - CROSS JOIN category_sales cs - `, [days, days]); - res.json(rows[0]); + COALESCE(COUNT(DISTINCT DATE(o.date)), 0) as days_with_sales, + COALESCE(COUNT(DISTINCT o.order_number), 0) as total_orders, + COALESCE(SUM(o.quantity), 0) as total_units, + COALESCE(SUM(o.price * o.quantity), 0) as total_revenue, + COALESCE(SUM(p.cost_price * o.quantity), 0) as total_cogs, + COALESCE(SUM((o.price - p.cost_price) * o.quantity), 0) as total_profit, + COALESCE(AVG(daily.orders), 0) as avg_daily_orders, + COALESCE(AVG(daily.units), 0) as avg_daily_units, + COALESCE(AVG(daily.revenue), 0) as avg_daily_revenue + FROM orders o + JOIN products p ON o.product_id = p.product_id + LEFT JOIN ( + SELECT + DATE(date) as sale_date, + COUNT(DISTINCT order_number) as orders, + SUM(quantity) as units, + SUM(price * quantity) as revenue + FROM orders + WHERE canceled = false + GROUP BY DATE(date) + ) daily ON DATE(o.date) = daily.sale_date + WHERE o.canceled = false + AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + `, [days]); + + const response = { + totalOrders: parseInt(metrics.total_orders) || 0, + totalUnitsSold: parseInt(metrics.total_units) || 0, + totalRevenue: parseFloat(metrics.total_revenue) || 0, + totalCogs: parseFloat(metrics.total_cogs) || 0, + dailySales: JSON.parse(dailyData.daily_data || '[]').map(day => ({ + date: day.date, + units: parseInt(day.units) || 0, + revenue: parseFloat(day.revenue) || 0, + cogs: parseFloat(day.cogs) || 0 + })) + }; + + res.json(response); } catch (err) { console.error('Error fetching sales metrics:', err); res.status(500).json({ error: 'Failed to fetch sales metrics' }); @@ -502,38 +657,53 @@ router.get('/vendor/performance', async (req, res) => { SELECT po.vendor, COUNT(DISTINCT po.po_id) as total_orders, - AVG(DATEDIFF(po.delivery_date, po.order_date)) as avg_lead_time, - AVG(CASE + CAST(AVG(DATEDIFF(po.received_date, po.date)) AS DECIMAL(10,2)) as avg_lead_time, + CAST(AVG(CASE WHEN po.status = 'completed' - THEN DATEDIFF(po.delivery_date, po.expected_date) - END) as avg_delay, - SUM(CASE - WHEN po.status = 'completed' AND po.delivery_date <= po.expected_date + THEN DATEDIFF(po.received_date, po.expected_date) + END) AS DECIMAL(10,2)) as avg_delay, + CAST(SUM(CASE + WHEN po.status = 'completed' AND po.received_date <= po.expected_date THEN 1 ELSE 0 - END) * 100.0 / COUNT(*) as on_time_delivery_rate, - AVG(po.fill_rate) as avg_fill_rate + END) * 100.0 / COUNT(*) AS DECIMAL(10,2)) as on_time_delivery_rate, + CAST(AVG(CASE + WHEN po.status = 'completed' + THEN po.received / po.ordered * 100 + ELSE NULL + END) AS DECIMAL(10,2)) as avg_fill_rate FROM purchase_orders po - WHERE po.order_date >= DATE_SUB(CURDATE(), INTERVAL 180 DAY) + WHERE po.date >= DATE_SUB(CURDATE(), INTERVAL 180 DAY) GROUP BY po.vendor ) SELECT - v.*, - vo.total_orders, + vd.vendor, + vd.contact_name, + vd.status, + CAST(vo.total_orders AS SIGNED) as total_orders, vo.avg_lead_time, vo.avg_delay, vo.on_time_delivery_rate, - vo.avg_fill_rate, - vm.total_purchase_value, - vm.total_revenue, - vm.product_count, - vm.active_products - FROM vendors v - JOIN vendor_orders vo ON v.vendor = vo.vendor - JOIN vendor_metrics vm ON v.vendor = vm.vendor - ORDER BY vm.total_revenue DESC + vo.avg_fill_rate + FROM vendor_details vd + JOIN vendor_orders vo ON vd.vendor = vo.vendor + WHERE vd.status = 'active' + ORDER BY vo.on_time_delivery_rate DESC `); - res.json(rows); + + // Format response with explicit number parsing + const formattedRows = rows.map(row => ({ + vendor: row.vendor, + contact_name: row.contact_name, + status: row.status, + total_orders: parseInt(row.total_orders) || 0, + avg_lead_time: parseFloat(row.avg_lead_time) || 0, + avg_delay: parseFloat(row.avg_delay) || 0, + on_time_delivery_rate: parseFloat(row.on_time_delivery_rate) || 0, + avg_fill_rate: parseFloat(row.avg_fill_rate) || 0 + })); + + res.json(formattedRows); } catch (err) { console.error('Error fetching vendor performance:', err); res.status(500).json({ error: 'Failed to fetch vendor performance' }); diff --git a/inventory/src/components/dashboard/LowStockAlerts.tsx b/inventory/src/components/dashboard/LowStockAlerts.tsx index 7e98db7..25b6991 100644 --- a/inventory/src/components/dashboard/LowStockAlerts.tsx +++ b/inventory/src/components/dashboard/LowStockAlerts.tsx @@ -14,10 +14,10 @@ import config from "@/config" interface LowStockProduct { product_id: number - sku: string + SKU: string title: string stock_quantity: number - reorder_point: number + reorder_qty: number days_of_inventory: number stock_status: "Critical" | "Reorder" daily_sales_avg: number @@ -27,7 +27,7 @@ export function LowStockAlerts() { const { data: products } = useQuery({ queryKey: ["low-stock"], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/inventory/low-stock`) + const response = await fetch(`${config.apiUrl}/dashboard/low-stock/products`) if (!response.ok) { throw new Error("Failed to fetch low stock products") } @@ -54,10 +54,10 @@ export function LowStockAlerts() { {products?.map((product) => ( - {product.sku} + {product.SKU} {product.title} - {product.stock_quantity} / {product.reorder_point} + {product.stock_quantity} / {product.reorder_qty}

{product.title}

-

{product.sku}

+

{product.SKU}

- {product.overstocked_units.toLocaleString()} + {product.overstocked_amt.toLocaleString()} - {formatCurrency(product.overstocked_cost)} + {formatCurrency(product.excess_cost)} {product.days_of_inventory} diff --git a/inventory/src/components/dashboard/VendorPerformance.tsx b/inventory/src/components/dashboard/VendorPerformance.tsx index 91a676c..52a0443 100644 --- a/inventory/src/components/dashboard/VendorPerformance.tsx +++ b/inventory/src/components/dashboard/VendorPerformance.tsx @@ -13,18 +13,19 @@ import config from "@/config" interface VendorMetrics { vendor: string - avg_lead_time_days: number + avg_lead_time: number on_time_delivery_rate: number - order_fill_rate: number + avg_fill_rate: number total_orders: number - total_late_orders: number + active_orders: number + overdue_orders: number } export function VendorPerformance() { const { data: vendors } = useQuery({ queryKey: ["vendor-metrics"], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/vendors/metrics`) + const response = await fetch(`${config.apiUrl}/dashboard/vendor/performance`) if (!response.ok) { throw new Error("Failed to fetch vendor metrics") } @@ -66,7 +67,7 @@ export function VendorPerformance() {
- {vendor.order_fill_rate.toFixed(0)}% + {vendor.avg_fill_rate.toFixed(0)}% ))} From c4128228811950fc83eb49801472d9cce55ffae3 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 18:56:17 -0500 Subject: [PATCH 05/17] Rearrange dashboard to match IP --- inventory-server/src/routes/dashboard.js | 174 ++++++++++++------ .../components/dashboard/PurchaseMetrics.tsx | 27 +-- .../src/components/dashboard/StockMetrics.tsx | 21 ++- .../dashboard/TopReplenishProducts.tsx | 77 ++++++++ inventory/src/pages/Dashboard.tsx | 70 +++---- 5 files changed, 244 insertions(+), 125 deletions(-) create mode 100644 inventory/src/components/dashboard/TopReplenishProducts.tsx diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index 5a2bbed..cb39cea 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -26,18 +26,20 @@ router.get('/stock/metrics', async (req, res) => { FROM products `); - // Get brand values in a separate query - const [brandValues] = await executeQuery(` + // Get vendor stock values + const [vendorValues] = await executeQuery(` SELECT - brand, - COALESCE(SUM(stock_quantity * price), 0) as value + vendor, + COUNT(DISTINCT product_id) as variant_count, + COALESCE(SUM(stock_quantity), 0) as stock_units, + COALESCE(SUM(stock_quantity * cost_price), 0) as stock_cost, + COALESCE(SUM(stock_quantity * price), 0) as stock_retail FROM products - WHERE brand IS NOT NULL + WHERE vendor IS NOT NULL AND stock_quantity > 0 - GROUP BY brand - HAVING value > 0 - ORDER BY value DESC - LIMIT 8 + GROUP BY vendor + HAVING stock_cost > 0 + ORDER BY stock_cost DESC `); // Format the response with explicit type conversion @@ -47,9 +49,12 @@ router.get('/stock/metrics', async (req, res) => { totalStockUnits: parseInt(stockMetrics.total_units) || 0, totalStockCost: parseFloat(stockMetrics.total_cost) || 0, totalStockRetail: parseFloat(stockMetrics.total_retail) || 0, - brandRetailValue: brandValues.map(b => ({ - brand: b.brand, - value: parseFloat(b.value) || 0 + vendorStock: vendorValues.map(v => ({ + vendor: v.vendor, + variants: parseInt(v.variant_count) || 0, + units: parseInt(v.stock_units) || 0, + cost: parseFloat(v.stock_cost) || 0, + retail: parseFloat(v.stock_retail) || 0 })) }; @@ -86,20 +91,19 @@ router.get('/purchase/metrics', async (req, res) => { JOIN products p ON po.product_id = p.product_id `); - const [vendorValues] = await executeQuery(` + const [vendorOrders] = await executeQuery(` SELECT po.vendor, - COALESCE(SUM(CASE - WHEN po.status = 'open' - THEN po.ordered * po.cost_price - ELSE 0 - END), 0) as value + COUNT(DISTINCT po.po_id) as order_count, + COALESCE(SUM(po.ordered), 0) as ordered_units, + COALESCE(SUM(po.ordered * po.cost_price), 0) as order_cost, + COALESCE(SUM(po.ordered * p.price), 0) as order_retail FROM purchase_orders po + JOIN products p ON po.product_id = p.product_id WHERE po.status = 'open' GROUP BY po.vendor - HAVING value > 0 - ORDER BY value DESC - LIMIT 8 + HAVING order_cost > 0 + ORDER BY order_cost DESC `); res.json({ @@ -108,9 +112,12 @@ router.get('/purchase/metrics', async (req, res) => { onOrderUnits: parseInt(poMetrics.total_units) || 0, onOrderCost: parseFloat(poMetrics.total_cost) || 0, onOrderRetail: parseFloat(poMetrics.total_retail) || 0, - vendorOrderValue: vendorValues.map(v => ({ + vendorOrders: vendorOrders.map(v => ({ vendor: v.vendor, - value: parseFloat(v.value) || 0 + orders: parseInt(v.order_count) || 0, + units: parseInt(v.ordered_units) || 0, + cost: parseFloat(v.order_cost) || 0, + retail: parseFloat(v.order_retail) || 0 })) }); } catch (err) { @@ -150,52 +157,45 @@ router.get('/replenishment/metrics', async (req, res) => { WHERE p.replenishable = true `); - // Get category breakdown - const [categories] = await executeQuery(` + // Get top variants to replenish + const [variants] = await executeQuery(` SELECT - c.name as category, - COUNT(DISTINCT CASE - WHEN pm.stock_status IN ('Critical', 'Reorder') - THEN p.product_id - END) as products, - SUM(CASE - WHEN pm.stock_status IN ('Critical', 'Reorder') - THEN pm.reorder_qty - ELSE 0 - END) as units, - SUM(CASE - WHEN pm.stock_status IN ('Critical', 'Reorder') - THEN pm.reorder_qty * p.cost_price - ELSE 0 - END) as cost, - SUM(CASE - WHEN pm.stock_status IN ('Critical', 'Reorder') - THEN pm.reorder_qty * p.price - ELSE 0 - END) as retail - FROM categories c - JOIN product_categories pc ON c.id = pc.category_id - JOIN products p ON pc.product_id = p.product_id + p.product_id, + p.title, + p.stock_quantity as current_stock, + pm.reorder_qty as replenish_qty, + (pm.reorder_qty * p.cost_price) as replenish_cost, + (pm.reorder_qty * p.price) as replenish_retail, + pm.stock_status, + DATE_FORMAT(pm.planning_period_end, '%b %d, %Y') as planning_period + FROM products p JOIN product_metrics pm ON p.product_id = pm.product_id WHERE p.replenishable = true - GROUP BY c.id, c.name - HAVING products > 0 - ORDER BY cost DESC - LIMIT 8 + AND pm.stock_status IN ('Critical', 'Reorder') + ORDER BY + CASE pm.stock_status + WHEN 'Critical' THEN 1 + WHEN 'Reorder' THEN 2 + END, + replenish_cost DESC + LIMIT 5 `); // Format response const response = { productsToReplenish: parseInt(metrics.products_to_replenish) || 0, - totalUnitsToReplenish: parseInt(metrics.total_units_needed) || 0, - totalReplenishmentCost: parseFloat(metrics.total_cost) || 0, - totalReplenishmentRetail: parseFloat(metrics.total_retail) || 0, - categoryData: categories.map(c => ({ - category: c.category, - products: parseInt(c.products) || 0, - units: parseInt(c.units) || 0, - cost: parseFloat(c.cost) || 0, - retail: parseFloat(c.retail) || 0 + unitsToReplenish: parseInt(metrics.total_units_needed) || 0, + replenishmentCost: parseFloat(metrics.total_cost) || 0, + replenishmentRetail: parseFloat(metrics.total_retail) || 0, + topVariants: variants.map(v => ({ + id: v.product_id, + title: v.title, + currentStock: parseInt(v.current_stock) || 0, + replenishQty: parseInt(v.replenish_qty) || 0, + replenishCost: parseFloat(v.replenish_cost) || 0, + replenishRetail: parseFloat(v.replenish_retail) || 0, + status: v.stock_status, + planningPeriod: v.planning_period })) }; @@ -833,4 +833,56 @@ router.get('/inventory-health', async (req, res) => { } }); +// GET /dashboard/replenish/products +// Returns top products that need replenishment +router.get('/replenish/products', async (req, res) => { + const limit = Math.max(1, Math.min(100, parseInt(req.query.limit) || 50)); + try { + const [products] = await executeQuery(` + SELECT + p.product_id, + p.SKU, + p.title, + p.stock_quantity as current_stock, + pm.reorder_qty as replenish_qty, + (pm.reorder_qty * p.cost_price) as replenish_cost, + (pm.reorder_qty * p.price) as replenish_retail, + CASE + WHEN pm.daily_sales_avg > 0 + THEN FLOOR(p.stock_quantity / pm.daily_sales_avg) + ELSE NULL + END as days_until_stockout + FROM products p + JOIN product_metrics pm ON p.product_id = pm.product_id + WHERE p.replenishable = true + AND pm.stock_status IN ('Critical', 'Reorder') + AND pm.reorder_qty > 0 + ORDER BY + CASE pm.stock_status + WHEN 'Critical' THEN 1 + WHEN 'Reorder' THEN 2 + END, + replenish_cost DESC + LIMIT ? + `, [limit]); + + // Format response + const response = products.map(p => ({ + product_id: p.product_id, + SKU: p.SKU, + title: p.title, + current_stock: parseInt(p.current_stock) || 0, + replenish_qty: parseInt(p.replenish_qty) || 0, + replenish_cost: parseFloat(p.replenish_cost) || 0, + replenish_retail: parseFloat(p.replenish_retail) || 0, + days_until_stockout: p.days_until_stockout + })); + + res.json(response); + } catch (err) { + console.error('Error fetching products to replenish:', err); + res.status(500).json({ error: 'Failed to fetch products to replenish' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/inventory/src/components/dashboard/PurchaseMetrics.tsx b/inventory/src/components/dashboard/PurchaseMetrics.tsx index c3db155..311126c 100644 --- a/inventory/src/components/dashboard/PurchaseMetrics.tsx +++ b/inventory/src/components/dashboard/PurchaseMetrics.tsx @@ -10,9 +10,12 @@ interface PurchaseMetricsData { onOrderUnits: number onOrderCost: number onOrderRetail: number - vendorOrderValue: { + vendorOrders: { vendor: string - value: number + orders: number + units: number + cost: number + retail: number }[] } @@ -42,28 +45,28 @@ export function PurchaseMetrics() { return ( <> - Purchase Orders Overview + Purchase Overview
-

Active POs

+

Active Orders

{data?.activePurchaseOrders.toLocaleString() || 0}

-

Overdue POs

-

{data?.overduePurchaseOrders.toLocaleString() || 0}

+

Overdue Orders

+

{data?.overduePurchaseOrders.toLocaleString() || 0}

-

On Order Units

+

Units On Order

{data?.onOrderUnits.toLocaleString() || 0}

-

On Order Cost

+

Order Cost

{formatCurrency(data?.onOrderCost || 0)}

-

On Order Retail

+

Order Retail

{formatCurrency(data?.onOrderRetail || 0)}

@@ -72,8 +75,8 @@ export function PurchaseMetrics() { - {data?.vendorOrderValue.map((entry, index) => ( + {data?.vendorOrders?.map((entry, index) => ( - {data?.brandRetailValue.map((entry, index) => ( + {data?.vendorStock?.map((entry, index) => ( ))} formatCurrency(value)} - labelFormatter={(label: string) => `Brand: ${label}`} + labelFormatter={(label: string) => `Vendor: ${label}`} /> diff --git a/inventory/src/components/dashboard/TopReplenishProducts.tsx b/inventory/src/components/dashboard/TopReplenishProducts.tsx new file mode 100644 index 0000000..6fc7c3c --- /dev/null +++ b/inventory/src/components/dashboard/TopReplenishProducts.tsx @@ -0,0 +1,77 @@ +import { useQuery } from "@tanstack/react-query" +import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import config from "@/config" +import { formatCurrency } from "@/lib/utils" + +interface ReplenishProduct { + product_id: number + SKU: string + title: string + current_stock: number + replenish_qty: number + replenish_cost: number + replenish_retail: number + days_until_stockout: number | null +} + +export function TopReplenishProducts() { + const { data } = useQuery({ + queryKey: ["top-replenish-products"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`) + if (!response.ok) { + throw new Error("Failed to fetch products to replenish") + } + return response.json() + }, + }) + + return ( + <> + + Top Products to Replenish + + + + + + + Product + Current + Replenish + Cost + Days + + + + {data?.map((product) => ( + + +
+

{product.title}

+

{product.SKU}

+
+
+ + {product.current_stock.toLocaleString()} + + + {product.replenish_qty.toLocaleString()} + + + {formatCurrency(product.replenish_cost)} + + + {product.days_until_stockout ?? "N/A"} + +
+ ))} +
+
+
+
+ + ) +} \ No newline at end of file diff --git a/inventory/src/pages/Dashboard.tsx b/inventory/src/pages/Dashboard.tsx index 55d2591..8b586f2 100644 --- a/inventory/src/pages/Dashboard.tsx +++ b/inventory/src/pages/Dashboard.tsx @@ -1,16 +1,12 @@ import { Card } from "@/components/ui/card" -import { InventoryHealthSummary } from "@/components/dashboard/InventoryHealthSummary" -import { LowStockAlerts } from "@/components/dashboard/LowStockAlerts" -import { TrendingProducts } from "@/components/dashboard/TrendingProducts" -import { VendorPerformance } from "@/components/dashboard/VendorPerformance" -import { KeyMetricsCharts } from "@/components/dashboard/KeyMetricsCharts" import { StockMetrics } from "@/components/dashboard/StockMetrics" import { PurchaseMetrics } from "@/components/dashboard/PurchaseMetrics" import { ReplenishmentMetrics } from "@/components/dashboard/ReplenishmentMetrics" -import { ForecastMetrics } from "@/components/dashboard/ForecastMetrics" +import { TopReplenishProducts } from "@/components/dashboard/TopReplenishProducts" import { OverstockMetrics } from "@/components/dashboard/OverstockMetrics" import { TopOverstockedProducts } from "@/components/dashboard/TopOverstockedProducts" import { BestSellers } from "@/components/dashboard/BestSellers" +import { ForecastMetrics } from "@/components/dashboard/ForecastMetrics" import { SalesMetrics } from "@/components/dashboard/SalesMetrics" import { motion } from "motion/react" @@ -22,59 +18,47 @@ export function Dashboard() { {/* First row - Stock and Purchase metrics */} -
- +
+ - +
- {/* Second row - Replenishment and Overstock */} -
- - - - - + {/* Second row - Replenishment section */} +
+ + +
+ + + + + + +
- {/* Third row - Products to Replenish and Overstocked Products */} -
- - + {/* Third row - Overstock section */} +
+ + - +
- {/* Fourth row - Sales and Forecast */} -
- - - - - - -
- - {/* Fifth row - Best Sellers */} -
- + {/* Fourth row - Best Sellers and Sales */} +
+ -
- - {/* Sixth row - Vendor Performance and Trending Products */} -
- - - - - + +
From 7a3a6fdb5299d1323486653c8889dc46baf592cf Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 20:01:47 -0500 Subject: [PATCH 06/17] Rearrange component contents to match IP --- .../src/components/dashboard/BestSellers.tsx | 17 ++- .../components/dashboard/ForecastMetrics.tsx | 19 ++- .../components/dashboard/OverstockMetrics.tsx | 78 ++++-------- .../components/dashboard/PurchaseMetrics.tsx | 120 +++++++++++------- .../dashboard/ReplenishmentMetrics.tsx | 65 +++------- .../src/components/dashboard/SalesMetrics.tsx | 33 +++-- .../src/components/dashboard/StockMetrics.tsx | 120 +++++++++++------- .../dashboard/TopOverstockedProducts.tsx | 4 +- .../dashboard/TopReplenishProducts.tsx | 4 +- .../src/components/layout/AppSidebar.tsx | 2 +- inventory/src/pages/Dashboard.tsx | 4 +- 11 files changed, 238 insertions(+), 228 deletions(-) diff --git a/inventory/src/components/dashboard/BestSellers.tsx b/inventory/src/components/dashboard/BestSellers.tsx index 105573f..fca9da0 100644 --- a/inventory/src/components/dashboard/BestSellers.tsx +++ b/inventory/src/components/dashboard/BestSellers.tsx @@ -53,16 +53,19 @@ export function BestSellers() { return ( <> - Best Sellers +
+ Best Sellers + + + Products + Vendors + Categories + + +
- - Products - Vendors - Categories - - diff --git a/inventory/src/components/dashboard/ForecastMetrics.tsx b/inventory/src/components/dashboard/ForecastMetrics.tsx index f10ac36..5db4cde 100644 --- a/inventory/src/components/dashboard/ForecastMetrics.tsx +++ b/inventory/src/components/dashboard/ForecastMetrics.tsx @@ -5,6 +5,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { useState } from "react" import config from "@/config" import { formatCurrency } from "@/lib/utils" +import { TrendingUp, DollarSign } from "lucide-react" // Importing icons interface ForecastData { forecastSales: number @@ -41,7 +42,7 @@ export function ForecastMetrics() { return ( <> - Sales Forecast + Forecast -
-
-

Forecast Sales

+
+
+
+ +

Forecast Sales

+

{data?.forecastSales.toLocaleString() || 0}

-
-

Forecast Revenue

+
+
+ +

Forecast Revenue

+

{formatCurrency(data?.forecastRevenue || 0)}

diff --git a/inventory/src/components/dashboard/OverstockMetrics.tsx b/inventory/src/components/dashboard/OverstockMetrics.tsx index 37686f3..8a405df 100644 --- a/inventory/src/components/dashboard/OverstockMetrics.tsx +++ b/inventory/src/components/dashboard/OverstockMetrics.tsx @@ -1,8 +1,8 @@ import { useQuery } from "@tanstack/react-query" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" -import { BarChart, Bar, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts" import config from "@/config" import { formatCurrency } from "@/lib/utils" +import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react" interface OverstockMetricsData { overstockedProducts: number @@ -32,71 +32,39 @@ export function OverstockMetrics() { return ( <> - Overstock Overview + Overstock -
-
-

Overstocked Products

+
+
+
+ +

Overstocked Products

+

{data?.overstockedProducts.toLocaleString() || 0}

-
-

Overstocked Units

+
+
+ +

Overstocked Units

+

{data?.overstockedUnits.toLocaleString() || 0}

-
-

Total Cost

+
+
+ +

Overstocked Cost

+

{formatCurrency(data?.overstockedCost || 0)}

-
-

Total Retail

+
+
+ +

Overstocked Retail

+

{formatCurrency(data?.overstockedRetail || 0)}

- -
- - - - value.toLocaleString()} - /> - [ - name === "cost" ? formatCurrency(value) : value.toLocaleString(), - name === "cost" ? "Cost" : name === "products" ? "Products" : "Units" - ]} - labelFormatter={(label) => `Category: ${label}`} - /> - - - - - -
) diff --git a/inventory/src/components/dashboard/PurchaseMetrics.tsx b/inventory/src/components/dashboard/PurchaseMetrics.tsx index 311126c..72274e6 100644 --- a/inventory/src/components/dashboard/PurchaseMetrics.tsx +++ b/inventory/src/components/dashboard/PurchaseMetrics.tsx @@ -3,6 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { PieChart, Pie, ResponsiveContainer, Cell, Tooltip } from "recharts" import config from "@/config" import { formatCurrency } from "@/lib/utils" +import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons interface PurchaseMetricsData { activePurchaseOrders: number @@ -45,58 +46,81 @@ export function PurchaseMetrics() { return ( <> - Purchase Overview + Purchases -
-
-

Active Orders

-

{data?.activePurchaseOrders.toLocaleString() || 0}

+
+
+
+
+
+ +

Active Purchase Orders

+
+

{data?.activePurchaseOrders.toLocaleString() || 0}

+
+
+
+ +

Overdue Purchase Orders

+
+

{data?.overduePurchaseOrders.toLocaleString() || 0}

+
+
+
+ +

On Order Units

+
+

{data?.onOrderUnits.toLocaleString() || 0}

+
+
+
+ +

On Order Cost

+
+

{formatCurrency(data?.onOrderCost || 0)}

+
+
+
+ +

On Order Retail

+
+

{formatCurrency(data?.onOrderRetail || 0)}

+
+
-
-

Overdue Orders

-

{data?.overduePurchaseOrders.toLocaleString() || 0}

+
+
+
Purchase Orders By Vendor
+
+ + + + {data?.vendorOrders?.map((entry, index) => ( + + ))} + + formatCurrency(value)} + labelFormatter={(label: string) => `Vendor: ${label}`} + /> + + +
+
-
-

Units On Order

-

{data?.onOrderUnits.toLocaleString() || 0}

-
-
-

Order Cost

-

{formatCurrency(data?.onOrderCost || 0)}

-
-
-

Order Retail

-

{formatCurrency(data?.onOrderRetail || 0)}

-
-
- -
- - - - {data?.vendorOrders?.map((entry, index) => ( - - ))} - - formatCurrency(value)} - labelFormatter={(label: string) => `Vendor: ${label}`} - /> - -
diff --git a/inventory/src/components/dashboard/ReplenishmentMetrics.tsx b/inventory/src/components/dashboard/ReplenishmentMetrics.tsx index 7712ae2..80502b6 100644 --- a/inventory/src/components/dashboard/ReplenishmentMetrics.tsx +++ b/inventory/src/components/dashboard/ReplenishmentMetrics.tsx @@ -1,8 +1,8 @@ import { useQuery } from "@tanstack/react-query" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" -import { BarChart, Bar, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts" import config from "@/config" import { formatCurrency } from "@/lib/utils" +import { Package, DollarSign, ShoppingCart } from "lucide-react" // Importing icons interface ReplenishmentMetricsData { totalUnitsToReplenish: number @@ -30,61 +30,32 @@ export function ReplenishmentMetrics() { return ( <> - Replenishment Overview + Replenishment -
-
-

Units to Replenish

+
+
+
+ +

Units to Replenish

+

{data?.totalUnitsToReplenish.toLocaleString() || 0}

-
-

Total Cost

+
+
+ +

Replenishment Cost

+

{formatCurrency(data?.totalReplenishmentCost || 0)}

-
-

Total Retail

+
+
+ +

Replenishment Retail

+

{formatCurrency(data?.totalReplenishmentRetail || 0)}

- -
- - - - value.toLocaleString()} - /> - [ - name === "cost" ? formatCurrency(value) : value.toLocaleString(), - name === "cost" ? "Cost" : "Units" - ]} - labelFormatter={(label) => `Category: ${label}`} - /> - - - - -
) diff --git a/inventory/src/components/dashboard/SalesMetrics.tsx b/inventory/src/components/dashboard/SalesMetrics.tsx index adf0ea3..477fb14 100644 --- a/inventory/src/components/dashboard/SalesMetrics.tsx +++ b/inventory/src/components/dashboard/SalesMetrics.tsx @@ -5,6 +5,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { useState } from "react" import config from "@/config" import { formatCurrency } from "@/lib/utils" +import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react" interface SalesData { totalOrders: number @@ -44,7 +45,7 @@ export function SalesMetrics() { return ( <> - Sales Overview + Sales Overview -
-
-

Total Orders

+
+
+
+ +

Total Orders

+

{data?.totalOrders.toLocaleString() || 0}

-
-

Units Sold

+
+
+ +

Units Sold

+

{data?.totalUnitsSold.toLocaleString() || 0}

-
-

Cost of Goods

+
+
+ +

Cost of Goods

+

{formatCurrency(data?.totalCogs || 0)}

-
-

Revenue

+
+
+ +

Revenue

+

{formatCurrency(data?.totalRevenue || 0)}

diff --git a/inventory/src/components/dashboard/StockMetrics.tsx b/inventory/src/components/dashboard/StockMetrics.tsx index cd9518f..53d0d5e 100644 --- a/inventory/src/components/dashboard/StockMetrics.tsx +++ b/inventory/src/components/dashboard/StockMetrics.tsx @@ -3,6 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { PieChart, Pie, ResponsiveContainer, Cell, Tooltip } from "recharts" import config from "@/config" import { formatCurrency } from "@/lib/utils" +import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons interface StockMetricsData { totalProducts: number @@ -45,58 +46,81 @@ export function StockMetrics() { return ( <> - Stock Overview + Stock -
-
-

Total Products

-

{data?.totalProducts.toLocaleString() || 0}

+
+
+
+
+
+ +

Products

+
+

{data?.totalProducts.toLocaleString() || 0}

+
+
+
+ +

Products In Stock

+
+

{data?.productsInStock.toLocaleString() || 0}

+
+
+
+ +

Stock Units

+
+

{data?.totalStockUnits.toLocaleString() || 0}

+
+
+
+ +

Stock Cost

+
+

{formatCurrency(data?.totalStockCost || 0)}

+
+
+
+ +

Stock Retail

+
+

{formatCurrency(data?.totalStockRetail || 0)}

+
+
-
-

Products In Stock

-

{data?.productsInStock.toLocaleString() || 0}

+
+
+
Stock Retail By Brand
+
+ + + + {data?.vendorStock?.map((entry, index) => ( + + ))} + + formatCurrency(value)} + labelFormatter={(label: string) => `Vendor: ${label}`} + /> + + +
+
-
-

Total Stock Units

-

{data?.totalStockUnits.toLocaleString() || 0}

-
-
-

Total Stock Cost

-

{formatCurrency(data?.totalStockCost || 0)}

-
-
-

Total Stock Retail

-

{formatCurrency(data?.totalStockRetail || 0)}

-
-
- -
- - - - {data?.vendorStock?.map((entry, index) => ( - - ))} - - formatCurrency(value)} - labelFormatter={(label: string) => `Vendor: ${label}`} - /> - -
diff --git a/inventory/src/components/dashboard/TopOverstockedProducts.tsx b/inventory/src/components/dashboard/TopOverstockedProducts.tsx index fc6fc08..ec6e175 100644 --- a/inventory/src/components/dashboard/TopOverstockedProducts.tsx +++ b/inventory/src/components/dashboard/TopOverstockedProducts.tsx @@ -30,10 +30,10 @@ export function TopOverstockedProducts() { return ( <> - Top Overstocked Products + Top Overstocked Products - +
diff --git a/inventory/src/components/dashboard/TopReplenishProducts.tsx b/inventory/src/components/dashboard/TopReplenishProducts.tsx index 6fc7c3c..ad8afd5 100644 --- a/inventory/src/components/dashboard/TopReplenishProducts.tsx +++ b/inventory/src/components/dashboard/TopReplenishProducts.tsx @@ -31,10 +31,10 @@ export function TopReplenishProducts() { return ( <> - Top Products to Replenish + Top Products To Replenish - +
diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index 3430de5..11a3c01 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -26,7 +26,7 @@ import { useLocation, useNavigate, Link } from "react-router-dom"; const items = [ { - title: "Dashboard", + title: "Overview", icon: Home, url: "/", }, diff --git a/inventory/src/pages/Dashboard.tsx b/inventory/src/pages/Dashboard.tsx index 8b586f2..36d6a7d 100644 --- a/inventory/src/pages/Dashboard.tsx +++ b/inventory/src/pages/Dashboard.tsx @@ -14,7 +14,7 @@ export function Dashboard() { return (
-

Dashboard

+

Overview

{/* First row - Stock and Purchase metrics */} @@ -32,7 +32,7 @@ export function Dashboard() { -
+
From 0727529edf6b18499aa5c757879e63dda196ed4d Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 20:06:09 -0500 Subject: [PATCH 07/17] Move pie chart labels to middle --- .../components/dashboard/PurchaseMetrics.tsx | 61 +++++++++++++++++-- .../src/components/dashboard/StockMetrics.tsx | 61 +++++++++++++++++-- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/inventory/src/components/dashboard/PurchaseMetrics.tsx b/inventory/src/components/dashboard/PurchaseMetrics.tsx index 72274e6..e6668f9 100644 --- a/inventory/src/components/dashboard/PurchaseMetrics.tsx +++ b/inventory/src/components/dashboard/PurchaseMetrics.tsx @@ -1,9 +1,10 @@ import { useQuery } from "@tanstack/react-query" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" -import { PieChart, Pie, ResponsiveContainer, Cell, Tooltip } from "recharts" +import { PieChart, Pie, ResponsiveContainer, Cell, Tooltip, Sector } from "recharts" import config from "@/config" import { formatCurrency } from "@/lib/utils" import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons +import { useState } from "react" interface PurchaseMetricsData { activePurchaseOrders: number @@ -31,7 +32,57 @@ const COLORS = [ "#FF7C43", ] +const renderActiveShape = (props: any) => { + const { cx, cy, innerRadius, vendor, cost } = props; + + // Split vendor name into words and create lines of max 12 chars + const words = vendor.split(' '); + const lines: string[] = []; + let currentLine = ''; + + words.forEach((word: string) => { + if ((currentLine + ' ' + word).length <= 12) { + currentLine = currentLine ? `${currentLine} ${word}` : word; + } else { + if (currentLine) lines.push(currentLine); + currentLine = word; + } + }); + if (currentLine) lines.push(currentLine); + + return ( + + {lines.map((line, i) => ( + + {line} + + ))} + + {formatCurrency(cost)} + + {props.children} + + ); +}; + export function PurchaseMetrics() { + const [activeIndex, setActiveIndex] = useState(); + const { data } = useQuery({ queryKey: ["purchase-metrics"], queryFn: async () => { @@ -104,6 +155,10 @@ export function PurchaseMetrics() { innerRadius={60} outerRadius={80} paddingAngle={2} + activeIndex={activeIndex} + activeShape={renderActiveShape} + onMouseEnter={(_, index) => setActiveIndex(index)} + onMouseLeave={() => setActiveIndex(undefined)} > {data?.vendorOrders?.map((entry, index) => ( ))} - formatCurrency(value)} - labelFormatter={(label: string) => `Vendor: ${label}`} - />
diff --git a/inventory/src/components/dashboard/StockMetrics.tsx b/inventory/src/components/dashboard/StockMetrics.tsx index 53d0d5e..e806c8e 100644 --- a/inventory/src/components/dashboard/StockMetrics.tsx +++ b/inventory/src/components/dashboard/StockMetrics.tsx @@ -1,9 +1,10 @@ import { useQuery } from "@tanstack/react-query" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" -import { PieChart, Pie, ResponsiveContainer, Cell, Tooltip } from "recharts" +import { PieChart, Pie, ResponsiveContainer, Cell, Tooltip, Sector } from "recharts" import config from "@/config" import { formatCurrency } from "@/lib/utils" import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons +import { useState } from "react" interface StockMetricsData { totalProducts: number @@ -31,7 +32,57 @@ const COLORS = [ "#FF7C43", ] +const renderActiveShape = (props: any) => { + const { cx, cy, innerRadius, vendor, cost } = props; + + // Split vendor name into words and create lines of max 12 chars + const words = vendor.split(' '); + const lines: string[] = []; + let currentLine = ''; + + words.forEach((word: string) => { + if ((currentLine + ' ' + word).length <= 12) { + currentLine = currentLine ? `${currentLine} ${word}` : word; + } else { + if (currentLine) lines.push(currentLine); + currentLine = word; + } + }); + if (currentLine) lines.push(currentLine); + + return ( + + {lines.map((line, i) => ( + + {line} + + ))} + + {formatCurrency(cost)} + + {props.children} + + ); +}; + export function StockMetrics() { + const [activeIndex, setActiveIndex] = useState(); + const { data } = useQuery({ queryKey: ["stock-metrics"], queryFn: async () => { @@ -104,6 +155,10 @@ export function StockMetrics() { innerRadius={60} outerRadius={80} paddingAngle={2} + activeIndex={activeIndex} + activeShape={renderActiveShape} + onMouseEnter={(_, index) => setActiveIndex(index)} + onMouseLeave={() => setActiveIndex(undefined)} > {data?.vendorStock?.map((entry, index) => ( ))} - formatCurrency(value)} - labelFormatter={(label: string) => `Vendor: ${label}`} - />
From de5bd785c1bcdbfb8c65b91b1f9fd1da86c0e0d2 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 20:35:53 -0500 Subject: [PATCH 08/17] Switch to daterangepicker for forecast and sales --- inventory-server/src/routes/dashboard.js | 28 ++-- .../components/dashboard/ForecastMetrics.tsx | 50 ++++--- .../src/components/dashboard/SalesMetrics.tsx | 48 +++---- .../ui/date-range-picker-narrow.tsx | 135 ++++++++++++++++++ 4 files changed, 196 insertions(+), 65 deletions(-) create mode 100644 inventory/src/components/ui/date-range-picker-narrow.tsx diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index cb39cea..5bccb6e 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -209,7 +209,7 @@ router.get('/replenishment/metrics', async (req, res) => { // GET /dashboard/forecast/metrics // Returns sales forecasts for specified period router.get('/forecast/metrics', async (req, res) => { - const days = Math.max(1, Math.min(365, parseInt(req.query.days) || 30)); + const { startDate, endDate } = req.query; try { // Get summary metrics const [metrics] = await executeQuery(` @@ -218,8 +218,8 @@ router.get('/forecast/metrics', async (req, res) => { COALESCE(SUM(forecast_revenue), 0) as total_forecast_revenue, COALESCE(AVG(confidence_level), 0) as overall_confidence FROM sales_forecasts - WHERE forecast_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) - `, [days]); + WHERE forecast_date BETWEEN ? AND ? + `, [startDate, endDate]); // Get daily forecasts const [dailyForecasts] = await executeQuery(` @@ -229,10 +229,10 @@ router.get('/forecast/metrics', async (req, res) => { COALESCE(SUM(forecast_revenue), 0) as revenue, COALESCE(AVG(confidence_level), 0) as confidence FROM sales_forecasts - WHERE forecast_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) + WHERE forecast_date BETWEEN ? AND ? GROUP BY forecast_date ORDER BY forecast_date - `, [days]); + `, [startDate, endDate]); // Get category forecasts const [categoryForecasts] = await executeQuery(` @@ -243,10 +243,10 @@ router.get('/forecast/metrics', async (req, res) => { COALESCE(AVG(cf.confidence_level), 0) as confidence FROM category_forecasts cf JOIN categories c ON cf.category_id = c.id - WHERE cf.forecast_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) + WHERE cf.forecast_date BETWEEN ? AND ? GROUP BY c.id, c.name ORDER BY revenue DESC - `, [days]); + `, [startDate, endDate]); // Format response const response = { @@ -455,7 +455,7 @@ router.get('/best-sellers', async (req, res) => { // GET /dashboard/sales/metrics // Returns sales metrics for specified period router.get('/sales/metrics', async (req, res) => { - const days = Math.max(1, Math.min(365, parseInt(req.query.days) || 30)); + const { startDate, endDate } = req.query; try { const [dailyData] = await executeQuery(` SELECT JSON_ARRAYAGG( @@ -479,10 +479,10 @@ router.get('/sales/metrics', async (req, res) => { FROM orders o JOIN products p ON o.product_id = p.product_id WHERE o.canceled = false - AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + AND o.date BETWEEN ? AND ? GROUP BY DATE(o.date) ) d - `, [days]); + `, [startDate, endDate]); const [categoryData] = await executeQuery(` SELECT JSON_ARRAYAGG( @@ -504,10 +504,10 @@ router.get('/sales/metrics', async (req, res) => { JOIN product_categories pc ON p.product_id = pc.product_id JOIN categories c ON pc.category_id = c.id WHERE o.canceled = false - AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + AND o.date BETWEEN ? AND ? GROUP BY c.id, c.name ) c - `, [days]); + `, [startDate, endDate]); const [metrics] = await executeQuery(` SELECT @@ -533,8 +533,8 @@ router.get('/sales/metrics', async (req, res) => { GROUP BY DATE(date) ) daily ON DATE(o.date) = daily.sale_date WHERE o.canceled = false - AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) - `, [days]); + AND o.date BETWEEN ? AND ? + `, [startDate, endDate]); const response = { totalOrders: parseInt(metrics.total_orders) || 0, diff --git a/inventory/src/components/dashboard/ForecastMetrics.tsx b/inventory/src/components/dashboard/ForecastMetrics.tsx index 5db4cde..baeda59 100644 --- a/inventory/src/components/dashboard/ForecastMetrics.tsx +++ b/inventory/src/components/dashboard/ForecastMetrics.tsx @@ -1,11 +1,13 @@ import { useQuery } from "@tanstack/react-query" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { useState } from "react" import config from "@/config" import { formatCurrency } from "@/lib/utils" -import { TrendingUp, DollarSign } from "lucide-react" // Importing icons +import { TrendingUp, DollarSign } from "lucide-react" +import { DateRange } from "react-day-picker" +import { addDays } from "date-fns" +import { DateRangePicker } from "@/components/ui/date-range-picker-narrow" interface ForecastData { forecastSales: number @@ -17,21 +19,20 @@ interface ForecastData { }[] } -const periods = [ - { value: "7", label: "7 Days" }, - { value: "14", label: "14 Days" }, - { value: "30", label: "30 Days" }, - { value: "60", label: "60 Days" }, - { value: "90", label: "90 Days" }, -] - export function ForecastMetrics() { - const [period, setPeriod] = useState("30") + const [dateRange, setDateRange] = useState({ + from: new Date(), + to: addDays(new Date(), 30), + }); const { data } = useQuery({ - queryKey: ["forecast-metrics", period], + queryKey: ["forecast-metrics", dateRange], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?days=${period}`) + const params = new URLSearchParams({ + startDate: dateRange.from?.toISOString() || "", + endDate: dateRange.to?.toISOString() || "", + }); + const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?${params}`) if (!response.ok) { throw new Error("Failed to fetch forecast metrics") } @@ -41,20 +42,17 @@ export function ForecastMetrics() { return ( <> - + Forecast - +
+ { + if (range) setDateRange(range); + }} + future={true} + /> +
diff --git a/inventory/src/components/dashboard/SalesMetrics.tsx b/inventory/src/components/dashboard/SalesMetrics.tsx index 477fb14..671f776 100644 --- a/inventory/src/components/dashboard/SalesMetrics.tsx +++ b/inventory/src/components/dashboard/SalesMetrics.tsx @@ -1,11 +1,13 @@ import { useQuery } from "@tanstack/react-query" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { useState } from "react" import config from "@/config" import { formatCurrency } from "@/lib/utils" import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react" +import { DateRange } from "react-day-picker" +import { addDays } from "date-fns" +import { DateRangePicker } from "@/components/ui/date-range-picker-narrow" interface SalesData { totalOrders: number @@ -20,21 +22,20 @@ interface SalesData { }[] } -const periods = [ - { value: "7", label: "7 Days" }, - { value: "14", label: "14 Days" }, - { value: "30", label: "30 Days" }, - { value: "60", label: "60 Days" }, - { value: "90", label: "90 Days" }, -] - export function SalesMetrics() { - const [period, setPeriod] = useState("30") + const [dateRange, setDateRange] = useState({ + from: addDays(new Date(), -30), + to: new Date(), + }); const { data } = useQuery({ - queryKey: ["sales-metrics", period], + queryKey: ["sales-metrics", dateRange], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?days=${period}`) + const params = new URLSearchParams({ + startDate: dateRange.from?.toISOString() || "", + endDate: dateRange.to?.toISOString() || "", + }); + const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`) if (!response.ok) { throw new Error("Failed to fetch sales metrics") } @@ -44,20 +45,17 @@ export function SalesMetrics() { return ( <> - + Sales Overview - +
+ { + if (range) setDateRange(range); + }} + future={false} + /> +
diff --git a/inventory/src/components/ui/date-range-picker-narrow.tsx b/inventory/src/components/ui/date-range-picker-narrow.tsx new file mode 100644 index 0000000..10162fb --- /dev/null +++ b/inventory/src/components/ui/date-range-picker-narrow.tsx @@ -0,0 +1,135 @@ +import { format, addDays, startOfYear, endOfYear, subDays } from "date-fns"; +import { Calendar as CalendarIcon } from "lucide-react"; +import { DateRange } from "react-day-picker"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +interface DateRangePickerProps { + value: DateRange; + onChange: (range: DateRange | undefined) => void; + className?: string; + future?: boolean; +} + +export function DateRangePicker({ + value, + onChange, + className, + future = false, +}: DateRangePickerProps) { + const today = new Date(); + + const presets = future ? [ + { + label: "Next 30 days", + range: { + from: today, + to: addDays(today, 30), + }, + }, + { + label: "Next 90 days", + range: { + from: today, + to: addDays(today, 90), + }, + }, + { + label: "Rest of year", + range: { + from: today, + to: endOfYear(today), + }, + }, + ] : [ + { + label: "Last 7 days", + range: { + from: subDays(today, 7), + to: today, + }, + }, + { + label: "Last 30 days", + range: { + from: subDays(today, 30), + to: today, + }, + }, + { + label: "Last 90 days", + range: { + from: subDays(today, 90), + to: today, + }, + }, + { + label: "Year to date", + range: { + from: startOfYear(today), + to: today, + }, + }, + ]; + + return ( +
+ + + + + +
+ {presets.map((preset) => ( + + ))} +
+ { + if (range) onChange(range); + }} + numberOfMonths={2} + /> +
+
+
+ ); +} \ No newline at end of file From f38174ca2ab4bfa1ff48d2beec401128fccd0c35 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 21:10:35 -0500 Subject: [PATCH 09/17] Fix and restyle stock and purchases components --- inventory-server/src/routes/dashboard.js | 86 ++++++++++++++----- .../components/dashboard/PurchaseMetrics.tsx | 26 ++++-- .../src/components/dashboard/StockMetrics.tsx | 57 ++++++------ 3 files changed, 113 insertions(+), 56 deletions(-) diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index 5bccb6e..d8e3f24 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -16,30 +16,60 @@ async function executeQuery(sql, params = []) { router.get('/stock/metrics', async (req, res) => { try { // Get stock metrics - const [stockMetrics] = await executeQuery(` + const [rows] = await executeQuery(` SELECT COALESCE(COUNT(*), 0) as total_products, COALESCE(COUNT(CASE WHEN stock_quantity > 0 THEN 1 END), 0) as products_in_stock, - COALESCE(SUM(stock_quantity), 0) as total_units, - COALESCE(SUM(stock_quantity * cost_price), 0) as total_cost, - COALESCE(SUM(stock_quantity * price), 0) as total_retail + COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity END), 0) as total_units, + COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity * cost_price END), 0) as total_cost, + COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity * price END), 0) as total_retail FROM products `); + const stockMetrics = rows[0]; - // Get vendor stock values - const [vendorValues] = await executeQuery(` - SELECT - vendor, - COUNT(DISTINCT product_id) as variant_count, - COALESCE(SUM(stock_quantity), 0) as stock_units, - COALESCE(SUM(stock_quantity * cost_price), 0) as stock_cost, - COALESCE(SUM(stock_quantity * price), 0) as stock_retail - FROM products - WHERE vendor IS NOT NULL - AND stock_quantity > 0 - GROUP BY vendor - HAVING stock_cost > 0 - ORDER BY stock_cost DESC + console.log('Raw stockMetrics from database:', stockMetrics); + console.log('stockMetrics.total_products:', stockMetrics.total_products); + console.log('stockMetrics.products_in_stock:', stockMetrics.products_in_stock); + console.log('stockMetrics.total_units:', stockMetrics.total_units); + console.log('stockMetrics.total_cost:', stockMetrics.total_cost); + console.log('stockMetrics.total_retail:', stockMetrics.total_retail); + + // Get brand stock values with Other category + const [brandValues] = await executeQuery(` + WITH brand_totals AS ( + SELECT + brand, + COUNT(DISTINCT product_id) as variant_count, + COALESCE(SUM(stock_quantity), 0) as stock_units, + COALESCE(SUM(stock_quantity * cost_price), 0) as stock_cost, + COALESCE(SUM(stock_quantity * price), 0) as stock_retail + FROM products + WHERE brand IS NOT NULL + AND stock_quantity > 0 + GROUP BY brand + HAVING stock_cost > 0 + ), + other_brands AS ( + SELECT + 'Other' as brand, + SUM(variant_count) as variant_count, + SUM(stock_units) as stock_units, + SUM(stock_cost) as stock_cost, + SUM(stock_retail) as stock_retail + FROM brand_totals + WHERE stock_cost <= 5000 + ), + main_brands AS ( + SELECT * + FROM brand_totals + WHERE stock_cost > 5000 + ORDER BY stock_cost DESC + ) + SELECT * FROM main_brands + UNION ALL + SELECT * FROM other_brands + WHERE stock_cost > 0 + ORDER BY CASE WHEN brand = 'Other' THEN 1 ELSE 0 END, stock_cost DESC `); // Format the response with explicit type conversion @@ -49,8 +79,8 @@ router.get('/stock/metrics', async (req, res) => { totalStockUnits: parseInt(stockMetrics.total_units) || 0, totalStockCost: parseFloat(stockMetrics.total_cost) || 0, totalStockRetail: parseFloat(stockMetrics.total_retail) || 0, - vendorStock: vendorValues.map(v => ({ - vendor: v.vendor, + brandStock: brandValues.map(v => ({ + brand: v.brand, variants: parseInt(v.variant_count) || 0, units: parseInt(v.stock_units) || 0, cost: parseFloat(v.stock_cost) || 0, @@ -69,7 +99,7 @@ router.get('/stock/metrics', async (req, res) => { // Returns purchase order metrics by vendor router.get('/purchase/metrics', async (req, res) => { try { - const [poMetrics] = await executeQuery(` + const [rows] = await executeQuery(` SELECT COALESCE(COUNT(DISTINCT CASE WHEN po.status = 'open' THEN po.po_id END), 0) as active_pos, COALESCE(COUNT(DISTINCT CASE @@ -90,6 +120,14 @@ router.get('/purchase/metrics', async (req, res) => { FROM purchase_orders po JOIN products p ON po.product_id = p.product_id `); + const poMetrics = rows[0]; + + console.log('Raw poMetrics from database:', poMetrics); + console.log('poMetrics.active_pos:', poMetrics.active_pos); + console.log('poMetrics.overdue_pos:', poMetrics.overdue_pos); + console.log('poMetrics.total_units:', poMetrics.total_units); + console.log('poMetrics.total_cost:', poMetrics.total_cost); + console.log('poMetrics.total_retail:', poMetrics.total_retail); const [vendorOrders] = await executeQuery(` SELECT @@ -106,7 +144,7 @@ router.get('/purchase/metrics', async (req, res) => { ORDER BY order_cost DESC `); - res.json({ + const response = { activePurchaseOrders: parseInt(poMetrics.active_pos) || 0, overduePurchaseOrders: parseInt(poMetrics.overdue_pos) || 0, onOrderUnits: parseInt(poMetrics.total_units) || 0, @@ -119,7 +157,9 @@ router.get('/purchase/metrics', async (req, res) => { cost: parseFloat(v.order_cost) || 0, retail: parseFloat(v.order_retail) || 0 })) - }); + }; + + res.json(response); } catch (err) { console.error('Error fetching purchase metrics:', err); res.status(500).json({ error: 'Failed to fetch purchase metrics' }); diff --git a/inventory/src/components/dashboard/PurchaseMetrics.tsx b/inventory/src/components/dashboard/PurchaseMetrics.tsx index e6668f9..4a7ce67 100644 --- a/inventory/src/components/dashboard/PurchaseMetrics.tsx +++ b/inventory/src/components/dashboard/PurchaseMetrics.tsx @@ -83,17 +83,25 @@ const renderActiveShape = (props: any) => { export function PurchaseMetrics() { const [activeIndex, setActiveIndex] = useState(); - const { data } = useQuery({ + const { data, error, isLoading } = useQuery({ queryKey: ["purchase-metrics"], queryFn: async () => { + console.log('Fetching from:', `${config.apiUrl}/dashboard/purchase/metrics`); const response = await fetch(`${config.apiUrl}/dashboard/purchase/metrics`) if (!response.ok) { - throw new Error("Failed to fetch purchase metrics") + const text = await response.text(); + console.error('API Error:', text); + throw new Error(`Failed to fetch purchase metrics: ${response.status} ${response.statusText}`); } - return response.json() + const data = await response.json(); + console.log('API Response:', data); + return data; }, }) + if (isLoading) return
Loading...
; + if (error) return
Error loading purchase metrics
; + return ( <> @@ -108,41 +116,41 @@ export function PurchaseMetrics() {

Active Purchase Orders

-

{data?.activePurchaseOrders.toLocaleString() || 0}

+

{data?.activePurchaseOrders.toLocaleString() || 0}

Overdue Purchase Orders

-

{data?.overduePurchaseOrders.toLocaleString() || 0}

+

{data?.overduePurchaseOrders.toLocaleString() || 0}

On Order Units

-

{data?.onOrderUnits.toLocaleString() || 0}

+

{data?.onOrderUnits.toLocaleString() || 0}

On Order Cost

-

{formatCurrency(data?.onOrderCost || 0)}

+

{formatCurrency(data?.onOrderCost || 0)}

On Order Retail

-

{formatCurrency(data?.onOrderRetail || 0)}

+

{formatCurrency(data?.onOrderRetail || 0)}

-
Purchase Orders By Vendor
+
Purchase Orders By Vendor
diff --git a/inventory/src/components/dashboard/StockMetrics.tsx b/inventory/src/components/dashboard/StockMetrics.tsx index e806c8e..108a80a 100644 --- a/inventory/src/components/dashboard/StockMetrics.tsx +++ b/inventory/src/components/dashboard/StockMetrics.tsx @@ -3,7 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { PieChart, Pie, ResponsiveContainer, Cell, Tooltip, Sector } from "recharts" import config from "@/config" import { formatCurrency } from "@/lib/utils" -import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons +import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react" import { useState } from "react" interface StockMetricsData { @@ -12,8 +12,8 @@ interface StockMetricsData { totalStockUnits: number totalStockCost: number totalStockRetail: number - vendorStock: { - vendor: string + brandStock: { + brand: string variants: number units: number cost: number @@ -33,10 +33,10 @@ const COLORS = [ ] const renderActiveShape = (props: any) => { - const { cx, cy, innerRadius, vendor, cost } = props; + const { cx, cy, innerRadius, brand, retail } = props; - // Split vendor name into words and create lines of max 12 chars - const words = vendor.split(' '); + // Split brand name into words and create lines of max 12 chars + const words = brand.split(' '); const lines: string[] = []; let currentLine = ''; @@ -73,7 +73,7 @@ const renderActiveShape = (props: any) => { fill="#000000" className="text-base font-medium" > - {formatCurrency(cost)} + {formatCurrency(retail)} {props.children} @@ -83,16 +83,24 @@ const renderActiveShape = (props: any) => { export function StockMetrics() { const [activeIndex, setActiveIndex] = useState(); - const { data } = useQuery({ + const { data, error, isLoading } = useQuery({ queryKey: ["stock-metrics"], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`) + console.log('Fetching from:', `${config.apiUrl}/dashboard/stock/metrics`); + const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`); if (!response.ok) { - throw new Error("Failed to fetch stock metrics") + const text = await response.text(); + console.error('API Error:', text); + throw new Error(`Failed to fetch stock metrics: ${response.status} ${response.statusText}`); } - return response.json() + const data = await response.json(); + console.log('API Response:', data); + return data; }, - }) + }); + + if (isLoading) return
Loading...
; + if (error) return
Error loading stock metrics
; return ( <> @@ -108,48 +116,48 @@ export function StockMetrics() {

Products

-

{data?.totalProducts.toLocaleString() || 0}

+

{data?.totalProducts.toLocaleString() || 0}

Products In Stock

-

{data?.productsInStock.toLocaleString() || 0}

+

{data?.productsInStock.toLocaleString() || 0}

Stock Units

-

{data?.totalStockUnits.toLocaleString() || 0}

+

{data?.totalStockUnits.toLocaleString() || 0}

Stock Cost

-

{formatCurrency(data?.totalStockCost || 0)}

+

{formatCurrency(data?.totalStockCost || 0)}

Stock Retail

-

{formatCurrency(data?.totalStockRetail || 0)}

+

{formatCurrency(data?.totalStockRetail || 0)}

-
Stock Retail By Brand
+
Stock Retail By Brand
setActiveIndex(index)} onMouseLeave={() => setActiveIndex(undefined)} > - {data?.vendorStock?.map((entry, index) => ( + {data?.brandStock?.map((entry, index) => ( ))} From b6e95aada9f69a3001d7b74cb2c71417d37acea1 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 21:30:30 -0500 Subject: [PATCH 10/17] More tweaks to stock and purchases components --- .../components/dashboard/PurchaseMetrics.tsx | 29 ++++++++++++++---- .../src/components/dashboard/StockMetrics.tsx | 30 ++++++++++++++----- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/inventory/src/components/dashboard/PurchaseMetrics.tsx b/inventory/src/components/dashboard/PurchaseMetrics.tsx index 4a7ce67..6a015da 100644 --- a/inventory/src/components/dashboard/PurchaseMetrics.tsx +++ b/inventory/src/components/dashboard/PurchaseMetrics.tsx @@ -33,7 +33,7 @@ const COLORS = [ ] const renderActiveShape = (props: any) => { - const { cx, cy, innerRadius, vendor, cost } = props; + const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, vendor, cost } = props; // Split vendor name into words and create lines of max 12 chars const words = vendor.split(' '); @@ -52,6 +52,24 @@ const renderActiveShape = (props: any) => { return ( + + {lines.map((line, i) => ( { > {formatCurrency(cost)} - {props.children} ); }; @@ -149,9 +166,9 @@ export function PurchaseMetrics() {
-
+
Purchase Orders By Vendor
-
+
setActiveIndex(index)} @@ -171,7 +188,7 @@ export function PurchaseMetrics() { {data?.vendorOrders?.map((entry, index) => ( ))} diff --git a/inventory/src/components/dashboard/StockMetrics.tsx b/inventory/src/components/dashboard/StockMetrics.tsx index 108a80a..62c53e3 100644 --- a/inventory/src/components/dashboard/StockMetrics.tsx +++ b/inventory/src/components/dashboard/StockMetrics.tsx @@ -33,7 +33,7 @@ const COLORS = [ ] const renderActiveShape = (props: any) => { - const { cx, cy, innerRadius, brand, retail } = props; + const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, brand, retail } = props; // Split brand name into words and create lines of max 12 chars const words = brand.split(' '); @@ -52,6 +52,24 @@ const renderActiveShape = (props: any) => { return ( + + {lines.map((line, i) => ( { > {formatCurrency(retail)} - {props.children} ); }; @@ -149,9 +166,9 @@ export function StockMetrics() {
-
-
Stock Retail By Brand
-
+
+
Stock Retail By Brand
+
setActiveIndex(index)} @@ -172,7 +189,6 @@ export function StockMetrics() { ))} From d85d387c1a33b70e37fc0332a483d955a2b3706b Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 21:41:43 -0500 Subject: [PATCH 11/17] Fix and restyle replenishmentmetrics component --- inventory-server/src/routes/dashboard.js | 68 ++++++++++--------- .../components/dashboard/PurchaseMetrics.tsx | 2 +- .../dashboard/ReplenishmentMetrics.tsx | 41 +++++++---- .../src/components/dashboard/StockMetrics.tsx | 2 +- 4 files changed, 67 insertions(+), 46 deletions(-) diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index d8e3f24..237d345 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -173,28 +173,25 @@ router.get('/replenishment/metrics', async (req, res) => { // Get summary metrics const [metrics] = await executeQuery(` SELECT - COUNT(DISTINCT CASE - WHEN pm.stock_status IN ('Critical', 'Reorder') - THEN p.product_id - END) as products_to_replenish, - SUM(CASE - WHEN pm.stock_status IN ('Critical', 'Reorder') - THEN pm.reorder_qty - ELSE 0 - END) as total_units_needed, - SUM(CASE - WHEN pm.stock_status IN ('Critical', 'Reorder') - THEN pm.reorder_qty * p.cost_price - ELSE 0 - END) as total_cost, - SUM(CASE - WHEN pm.stock_status IN ('Critical', 'Reorder') - THEN pm.reorder_qty * p.price - ELSE 0 - END) as total_retail + COUNT(DISTINCT p.product_id) as products_to_replenish, + COALESCE(SUM(CASE + WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty + ELSE pm.reorder_qty + END), 0) as total_units_needed, + COALESCE(SUM(CASE + WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price + ELSE pm.reorder_qty * p.cost_price + END), 0) as total_cost, + COALESCE(SUM(CASE + WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price + ELSE pm.reorder_qty * p.price + END), 0) as total_retail FROM products p JOIN product_metrics pm ON p.product_id = pm.product_id WHERE p.replenishable = true + AND (pm.stock_status IN ('Critical', 'Reorder') + OR p.stock_quantity < 0) + AND pm.reorder_qty > 0 `); // Get top variants to replenish @@ -203,15 +200,25 @@ router.get('/replenishment/metrics', async (req, res) => { p.product_id, p.title, p.stock_quantity as current_stock, - pm.reorder_qty as replenish_qty, - (pm.reorder_qty * p.cost_price) as replenish_cost, - (pm.reorder_qty * p.price) as replenish_retail, - pm.stock_status, - DATE_FORMAT(pm.planning_period_end, '%b %d, %Y') as planning_period + CASE + WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty + ELSE pm.reorder_qty + END as replenish_qty, + CASE + WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price + ELSE pm.reorder_qty * p.cost_price + END as replenish_cost, + CASE + WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price + ELSE pm.reorder_qty * p.price + END as replenish_retail, + pm.stock_status FROM products p JOIN product_metrics pm ON p.product_id = pm.product_id WHERE p.replenishable = true - AND pm.stock_status IN ('Critical', 'Reorder') + AND (pm.stock_status IN ('Critical', 'Reorder') + OR p.stock_quantity < 0) + AND pm.reorder_qty > 0 ORDER BY CASE pm.stock_status WHEN 'Critical' THEN 1 @@ -223,10 +230,10 @@ router.get('/replenishment/metrics', async (req, res) => { // Format response const response = { - productsToReplenish: parseInt(metrics.products_to_replenish) || 0, - unitsToReplenish: parseInt(metrics.total_units_needed) || 0, - replenishmentCost: parseFloat(metrics.total_cost) || 0, - replenishmentRetail: parseFloat(metrics.total_retail) || 0, + productsToReplenish: parseInt(metrics[0].products_to_replenish) || 0, + unitsToReplenish: parseInt(metrics[0].total_units_needed) || 0, + replenishmentCost: parseFloat(metrics[0].total_cost) || 0, + replenishmentRetail: parseFloat(metrics[0].total_retail) || 0, topVariants: variants.map(v => ({ id: v.product_id, title: v.title, @@ -234,8 +241,7 @@ router.get('/replenishment/metrics', async (req, res) => { replenishQty: parseInt(v.replenish_qty) || 0, replenishCost: parseFloat(v.replenish_cost) || 0, replenishRetail: parseFloat(v.replenish_retail) || 0, - status: v.stock_status, - planningPeriod: v.planning_period + status: v.stock_status })) }; diff --git a/inventory/src/components/dashboard/PurchaseMetrics.tsx b/inventory/src/components/dashboard/PurchaseMetrics.tsx index 6a015da..71ebe4d 100644 --- a/inventory/src/components/dashboard/PurchaseMetrics.tsx +++ b/inventory/src/components/dashboard/PurchaseMetrics.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" -import { PieChart, Pie, ResponsiveContainer, Cell, Tooltip, Sector } from "recharts" +import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts" import config from "@/config" import { formatCurrency } from "@/lib/utils" import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons diff --git a/inventory/src/components/dashboard/ReplenishmentMetrics.tsx b/inventory/src/components/dashboard/ReplenishmentMetrics.tsx index 80502b6..f94487f 100644 --- a/inventory/src/components/dashboard/ReplenishmentMetrics.tsx +++ b/inventory/src/components/dashboard/ReplenishmentMetrics.tsx @@ -5,28 +5,43 @@ import { formatCurrency } from "@/lib/utils" import { Package, DollarSign, ShoppingCart } from "lucide-react" // Importing icons interface ReplenishmentMetricsData { - totalUnitsToReplenish: number - totalReplenishmentCost: number - totalReplenishmentRetail: number - replenishmentByCategory: { - category: string - units: number - cost: number + productsToReplenish: number + unitsToReplenish: number + replenishmentCost: number + replenishmentRetail: number + topVariants: { + id: number + title: string + currentStock: number + replenishQty: number + replenishCost: number + replenishRetail: number + status: string + planningPeriod: string }[] } export function ReplenishmentMetrics() { - const { data } = useQuery({ + const { data, error, isLoading } = useQuery({ queryKey: ["replenishment-metrics"], queryFn: async () => { + console.log('Fetching from:', `${config.apiUrl}/dashboard/replenishment/metrics`); const response = await fetch(`${config.apiUrl}/dashboard/replenishment/metrics`) if (!response.ok) { - throw new Error("Failed to fetch replenishment metrics") + const text = await response.text(); + console.error('API Error:', text); + throw new Error(`Failed to fetch replenishment metrics: ${response.status} ${response.statusText} - ${text}`) } - return response.json() + const data = await response.json(); + console.log('API Response:', data); + return data; }, }) + if (isLoading) return
Loading replenishment metrics...
; + if (error) return
Error: {error.message}
; + if (!data) return
No replenishment data available
; + return ( <> @@ -39,21 +54,21 @@ export function ReplenishmentMetrics() {

Units to Replenish

-

{data?.totalUnitsToReplenish.toLocaleString() || 0}

+

{data.unitsToReplenish.toLocaleString() || 0}

Replenishment Cost

-

{formatCurrency(data?.totalReplenishmentCost || 0)}

+

{formatCurrency(data.replenishmentCost || 0)}

Replenishment Retail

-

{formatCurrency(data?.totalReplenishmentRetail || 0)}

+

{formatCurrency(data.replenishmentRetail || 0)}

diff --git a/inventory/src/components/dashboard/StockMetrics.tsx b/inventory/src/components/dashboard/StockMetrics.tsx index 62c53e3..a9f13c2 100644 --- a/inventory/src/components/dashboard/StockMetrics.tsx +++ b/inventory/src/components/dashboard/StockMetrics.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" -import { PieChart, Pie, ResponsiveContainer, Cell, Tooltip, Sector } from "recharts" +import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts" import config from "@/config" import { formatCurrency } from "@/lib/utils" import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react" From 5987b7173d5314a6ccf72849bf604e212b6054ae Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 21:46:46 -0500 Subject: [PATCH 12/17] Update topreplenishproducts component --- .../src/components/dashboard/TopReplenishProducts.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/inventory/src/components/dashboard/TopReplenishProducts.tsx b/inventory/src/components/dashboard/TopReplenishProducts.tsx index ad8afd5..9597596 100644 --- a/inventory/src/components/dashboard/TopReplenishProducts.tsx +++ b/inventory/src/components/dashboard/TopReplenishProducts.tsx @@ -13,7 +13,6 @@ interface ReplenishProduct { replenish_qty: number replenish_cost: number replenish_retail: number - days_until_stockout: number | null } export function TopReplenishProducts() { @@ -39,10 +38,10 @@ export function TopReplenishProducts() { Product - Current + Stock Replenish Cost - Days + Retail @@ -64,7 +63,7 @@ export function TopReplenishProducts() { {formatCurrency(product.replenish_cost)} - {product.days_until_stockout ?? "N/A"} + {formatCurrency(product.replenish_retail)} ))} From 9759bac94f5a0f1750f2be625d54334d4e89b143 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 23:44:13 -0500 Subject: [PATCH 13/17] Fix and restyle forecast component --- inventory-server/scripts/calculate-metrics.js | 66 ++++++-- inventory-server/src/routes/dashboard.js | 14 +- .../components/dashboard/ForecastMetrics.tsx | 150 +++++++++--------- .../dashboard/TopReplenishProducts.tsx | 2 +- 4 files changed, 137 insertions(+), 95 deletions(-) diff --git a/inventory-server/scripts/calculate-metrics.js b/inventory-server/scripts/calculate-metrics.js index 27477bd..a77de13 100644 --- a/inventory-server/scripts/calculate-metrics.js +++ b/inventory-server/scripts/calculate-metrics.js @@ -3,7 +3,7 @@ const path = require('path'); require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') }); const fs = require('fs'); -// Configuration flags +// Set to 1 to skip product metrics and only calculate the remaining metrics const SKIP_PRODUCT_METRICS = 0; // Helper function to format elapsed time @@ -1149,8 +1149,12 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) { DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date FROM ( SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION - SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 14 UNION SELECT 30 UNION - SELECT 60 UNION SELECT 90 + SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION + SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 UNION + SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 UNION + SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 UNION + SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 UNION + SELECT 30 ) numbers ), product_stats AS ( @@ -1160,7 +1164,15 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) { STDDEV_SAMP(ds.daily_quantity) as std_daily_quantity, AVG(ds.daily_revenue) as avg_daily_revenue, STDDEV_SAMP(ds.daily_revenue) as std_daily_revenue, - COUNT(*) as data_points + COUNT(*) as data_points, + -- Calculate day-of-week averages + AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 1 THEN ds.daily_revenue END) as sunday_avg, + AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 2 THEN ds.daily_revenue END) as monday_avg, + AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 3 THEN ds.daily_revenue END) as tuesday_avg, + AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 4 THEN ds.daily_revenue END) as wednesday_avg, + AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 5 THEN ds.daily_revenue END) as thursday_avg, + AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 6 THEN ds.daily_revenue END) as friday_avg, + AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 7 THEN ds.daily_revenue END) as saturday_avg FROM daily_sales ds GROUP BY ds.product_id ) @@ -1178,14 +1190,24 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) { )) ) as forecast_units, GREATEST(0, - ps.avg_daily_revenue * + CASE DAYOFWEEK(fd.forecast_date) + WHEN 1 THEN COALESCE(ps.sunday_avg, ps.avg_daily_revenue) + WHEN 2 THEN COALESCE(ps.monday_avg, ps.avg_daily_revenue) + WHEN 3 THEN COALESCE(ps.tuesday_avg, ps.avg_daily_revenue) + WHEN 4 THEN COALESCE(ps.wednesday_avg, ps.avg_daily_revenue) + WHEN 5 THEN COALESCE(ps.thursday_avg, ps.avg_daily_revenue) + WHEN 6 THEN COALESCE(ps.friday_avg, ps.avg_daily_revenue) + WHEN 7 THEN COALESCE(ps.saturday_avg, ps.avg_daily_revenue) + END * (1 + COALESCE( (SELECT seasonality_factor FROM sales_seasonality WHERE MONTH(fd.forecast_date) = month LIMIT 1), 0 - )) + )) * + -- Add some randomness within a small range (±5%) + (0.95 + (RAND() * 0.1)) ) as forecast_revenue, CASE WHEN ps.data_points >= 60 THEN 90 @@ -1231,8 +1253,12 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) { DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date FROM ( SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION - SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 14 UNION SELECT 30 UNION - SELECT 60 UNION SELECT 90 + SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION + SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 UNION + SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 UNION + SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 UNION + SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 UNION + SELECT 30 ) numbers ), category_stats AS ( @@ -1242,7 +1268,15 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) { STDDEV_SAMP(cds.daily_quantity) as std_daily_quantity, AVG(cds.daily_revenue) as avg_daily_revenue, STDDEV_SAMP(cds.daily_revenue) as std_daily_revenue, - COUNT(*) as data_points + COUNT(*) as data_points, + -- Calculate day-of-week averages + AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 1 THEN cds.daily_revenue END) as sunday_avg, + AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 2 THEN cds.daily_revenue END) as monday_avg, + AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 3 THEN cds.daily_revenue END) as tuesday_avg, + AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 4 THEN cds.daily_revenue END) as wednesday_avg, + AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 5 THEN cds.daily_revenue END) as thursday_avg, + AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 6 THEN cds.daily_revenue END) as friday_avg, + AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 7 THEN cds.daily_revenue END) as saturday_avg FROM category_daily_sales cds GROUP BY cds.category_id ) @@ -1260,14 +1294,24 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) { )) ) as forecast_units, GREATEST(0, - cs.avg_daily_revenue * + CASE DAYOFWEEK(fd.forecast_date) + WHEN 1 THEN COALESCE(cs.sunday_avg, cs.avg_daily_revenue) + WHEN 2 THEN COALESCE(cs.monday_avg, cs.avg_daily_revenue) + WHEN 3 THEN COALESCE(cs.tuesday_avg, cs.avg_daily_revenue) + WHEN 4 THEN COALESCE(cs.wednesday_avg, cs.avg_daily_revenue) + WHEN 5 THEN COALESCE(cs.thursday_avg, cs.avg_daily_revenue) + WHEN 6 THEN COALESCE(cs.friday_avg, cs.avg_daily_revenue) + WHEN 7 THEN COALESCE(cs.saturday_avg, cs.avg_daily_revenue) + END * (1 + COALESCE( (SELECT seasonality_factor FROM sales_seasonality WHERE MONTH(fd.forecast_date) = month LIMIT 1), 0 - )) + )) * + -- Add some randomness within a small range (±5%) + (0.95 + (RAND() * 0.1)) ) as forecast_revenue, CASE WHEN cs.data_points >= 60 THEN 90 diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index 237d345..19a1194 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -270,14 +270,13 @@ router.get('/forecast/metrics', async (req, res) => { // Get daily forecasts const [dailyForecasts] = await executeQuery(` SELECT - forecast_date as date, - COALESCE(SUM(forecast_units), 0) as units, + DATE(forecast_date) as date, COALESCE(SUM(forecast_revenue), 0) as revenue, COALESCE(AVG(confidence_level), 0) as confidence FROM sales_forecasts WHERE forecast_date BETWEEN ? AND ? - GROUP BY forecast_date - ORDER BY forecast_date + GROUP BY DATE(forecast_date) + ORDER BY date `, [startDate, endDate]); // Get category forecasts @@ -296,12 +295,11 @@ router.get('/forecast/metrics', async (req, res) => { // Format response const response = { - forecastSales: parseInt(metrics.total_forecast_units) || 0, - forecastRevenue: parseFloat(metrics.total_forecast_revenue) || 0, - confidenceLevel: parseFloat(metrics.overall_confidence) || 0, + forecastSales: parseInt(metrics[0].total_forecast_units) || 0, + forecastRevenue: parseFloat(metrics[0].total_forecast_revenue) || 0, + confidenceLevel: parseFloat(metrics[0].overall_confidence) || 0, dailyForecasts: dailyForecasts.map(d => ({ date: d.date, - units: parseInt(d.units) || 0, revenue: parseFloat(d.revenue) || 0, confidence: parseFloat(d.confidence) || 0 })), diff --git a/inventory/src/components/dashboard/ForecastMetrics.tsx b/inventory/src/components/dashboard/ForecastMetrics.tsx index baeda59..87fe747 100644 --- a/inventory/src/components/dashboard/ForecastMetrics.tsx +++ b/inventory/src/components/dashboard/ForecastMetrics.tsx @@ -6,16 +6,24 @@ import config from "@/config" import { formatCurrency } from "@/lib/utils" import { TrendingUp, DollarSign } from "lucide-react" import { DateRange } from "react-day-picker" -import { addDays } from "date-fns" +import { addDays, format } from "date-fns" import { DateRangePicker } from "@/components/ui/date-range-picker-narrow" interface ForecastData { forecastSales: number forecastRevenue: number - dailyForecast: { + confidenceLevel: number + dailyForecasts: { date: string - sales: number + units: number revenue: number + confidence: number + }[] + categoryForecasts: { + category: string + units: number + revenue: number + confidence: number }[] } @@ -25,24 +33,28 @@ export function ForecastMetrics() { to: addDays(new Date(), 30), }); - const { data } = useQuery({ + const { data, error, isLoading } = useQuery({ queryKey: ["forecast-metrics", dateRange], queryFn: async () => { const params = new URLSearchParams({ startDate: dateRange.from?.toISOString() || "", endDate: dateRange.to?.toISOString() || "", }); + console.log('Fetching forecast metrics with params:', params.toString()); const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?${params}`) if (!response.ok) { - throw new Error("Failed to fetch forecast metrics") + const text = await response.text(); + throw new Error(`Failed to fetch forecast metrics: ${text}`); } - return response.json() + const data = await response.json(); + console.log('Forecast metrics response:', data); + return data; }, }) return ( <> - + Forecast
- -
-
-
- -

Forecast Sales

+ + {error ? ( +
Error: {error.message}
+ ) : isLoading ? ( +
Loading forecast metrics...
+ ) : ( + <> +
+
+
+ +

Forecast Sales

+
+

{data?.forecastSales.toLocaleString() || 0}

+
+
+
+ +

Forecast Revenue

+
+

{formatCurrency(data?.forecastRevenue || 0)}

+
-

{data?.forecastSales.toLocaleString() || 0}

-
-
-
- -

Forecast Revenue

-
-

{formatCurrency(data?.forecastRevenue || 0)}

-
-
-
- - - - value.toLocaleString()} - /> - formatCurrency(value)} - /> - [ - name === "revenue" ? formatCurrency(value) : value.toLocaleString(), - name === "revenue" ? "Revenue" : "Sales" - ]} - labelFormatter={(label) => `Date: ${label}`} - /> - - - - -
+
+ + + + + [formatCurrency(value), "Revenue"]} + labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')} + /> + + + +
+ + )} ) diff --git a/inventory/src/components/dashboard/TopReplenishProducts.tsx b/inventory/src/components/dashboard/TopReplenishProducts.tsx index 9597596..f420b24 100644 --- a/inventory/src/components/dashboard/TopReplenishProducts.tsx +++ b/inventory/src/components/dashboard/TopReplenishProducts.tsx @@ -33,7 +33,7 @@ export function TopReplenishProducts() { Top Products To Replenish - +
From 1b4447f8863dcec62bfb37df137150085eeca356 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 18 Jan 2025 00:43:57 -0500 Subject: [PATCH 14/17] Fix and restyle overstockmetrics and topoverstockedproducts, link products to backend --- inventory-server/src/routes/dashboard.js | 19 ++++++++++----- .../components/dashboard/OverstockMetrics.tsx | 17 +++++++------- .../dashboard/TopOverstockedProducts.tsx | 23 ++++++++++++++----- .../dashboard/TopReplenishProducts.tsx | 9 +++++++- 4 files changed, 47 insertions(+), 21 deletions(-) diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index 19a1194..ed1a3ac 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -357,7 +357,7 @@ router.get('/overstock/metrics', async (req, res) => { SUM(total_excess_units) as total_excess_units, SUM(total_excess_cost) as total_excess_cost, SUM(total_excess_retail) as total_excess_retail, - CAST(JSON_ARRAYAGG( + CONCAT('[', GROUP_CONCAT( JSON_OBJECT( 'category', category_name, 'products', overstocked_products, @@ -365,7 +365,7 @@ router.get('/overstock/metrics', async (req, res) => { 'cost', total_excess_cost, 'retail', total_excess_retail ) - ) AS JSON) as category_data + ), ']') as category_data FROM ( SELECT * FROM category_overstock @@ -378,10 +378,17 @@ router.get('/overstock/metrics', async (req, res) => { // Format response with explicit type conversion const response = { overstockedProducts: parseInt(rows[0].total_overstocked) || 0, - excessUnits: parseInt(rows[0].total_excess_units) || 0, - excessCost: parseFloat(rows[0].total_excess_cost) || 0, - excessRetail: parseFloat(rows[0].total_excess_retail) || 0, - categoryData: rows[0].category_data ? JSON.parse(rows[0].category_data) : [] + total_excess_units: parseInt(rows[0].total_excess_units) || 0, + total_excess_cost: parseFloat(rows[0].total_excess_cost) || 0, + total_excess_retail: parseFloat(rows[0].total_excess_retail) || 0, + category_data: rows[0].category_data ? + JSON.parse(rows[0].category_data).map(obj => ({ + category: obj.category, + products: parseInt(obj.products) || 0, + units: parseInt(obj.units) || 0, + cost: parseFloat(obj.cost) || 0, + retail: parseFloat(obj.retail) || 0 + })) : [] }; res.json(response); diff --git a/inventory/src/components/dashboard/OverstockMetrics.tsx b/inventory/src/components/dashboard/OverstockMetrics.tsx index 8a405df..c026a73 100644 --- a/inventory/src/components/dashboard/OverstockMetrics.tsx +++ b/inventory/src/components/dashboard/OverstockMetrics.tsx @@ -6,14 +6,15 @@ import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react" interface OverstockMetricsData { overstockedProducts: number - overstockedUnits: number - overstockedCost: number - overstockedRetail: number - overstockByCategory: { + total_excess_units: number + total_excess_cost: number + total_excess_retail: number + category_data: { category: string products: number units: number cost: number + retail: number }[] } @@ -41,28 +42,28 @@ export function OverstockMetrics() {

Overstocked Products

-

{data?.overstockedProducts.toLocaleString() || 0}

+

{data?.overstockedProducts.toLocaleString() || 0}

Overstocked Units

-

{data?.overstockedUnits.toLocaleString() || 0}

+

{data?.total_excess_units.toLocaleString() || 0}

Overstocked Cost

-

{formatCurrency(data?.overstockedCost || 0)}

+

{formatCurrency(data?.total_excess_cost || 0)}

Overstocked Retail

-

{formatCurrency(data?.overstockedRetail || 0)}

+

{formatCurrency(data?.total_excess_retail || 0)}

diff --git a/inventory/src/components/dashboard/TopOverstockedProducts.tsx b/inventory/src/components/dashboard/TopOverstockedProducts.tsx index ec6e175..f963504 100644 --- a/inventory/src/components/dashboard/TopOverstockedProducts.tsx +++ b/inventory/src/components/dashboard/TopOverstockedProducts.tsx @@ -9,10 +9,10 @@ interface OverstockedProduct { product_id: number SKU: string title: string + stock_quantity: number overstocked_amt: number excess_cost: number excess_retail: number - days_of_inventory: number } export function TopOverstockedProducts() { @@ -38,9 +38,10 @@ export function TopOverstockedProducts() { Product - Units - Cost - Days + Current Stock + Overstock Amt + Overstock Cost + Overstock Retail @@ -48,10 +49,20 @@ export function TopOverstockedProducts() {
-

{product.title}

+ + {product.title} +

{product.SKU}

+ + {product.stock_quantity.toLocaleString()} + {product.overstocked_amt.toLocaleString()} @@ -59,7 +70,7 @@ export function TopOverstockedProducts() { {formatCurrency(product.excess_cost)} - {product.days_of_inventory} + {formatCurrency(product.excess_retail)}
))} diff --git a/inventory/src/components/dashboard/TopReplenishProducts.tsx b/inventory/src/components/dashboard/TopReplenishProducts.tsx index f420b24..41df022 100644 --- a/inventory/src/components/dashboard/TopReplenishProducts.tsx +++ b/inventory/src/components/dashboard/TopReplenishProducts.tsx @@ -49,7 +49,14 @@ export function TopReplenishProducts() {
-

{product.title}

+ + {product.title} +

{product.SKU}

From 9003300d0d2e5ffde3a751e5a263e082ee0963e2 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 18 Jan 2025 01:01:37 -0500 Subject: [PATCH 15/17] Fix and update bestsellers component --- inventory-server/src/routes/dashboard.js | 208 ++++++++++++++---- .../src/components/dashboard/BestSellers.tsx | 122 +++++----- 2 files changed, 240 insertions(+), 90 deletions(-) diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index ed1a3ac..a4d8224 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -440,62 +440,194 @@ router.get('/overstock/products', async (req, res) => { router.get('/best-sellers', async (req, res) => { try { const [products] = await executeQuery(` + WITH product_sales AS ( + SELECT + p.product_id, + p.SKU as sku, + p.title, + -- Current period (last 30 days) + SUM(CASE + WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) + THEN o.quantity + ELSE 0 + END) as units_sold, + SUM(CASE + WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) + THEN o.price * o.quantity + ELSE 0 + END) as revenue, + SUM(CASE + WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) + THEN (o.price - p.cost_price) * o.quantity + ELSE 0 + END) as profit, + -- Previous period (30-60 days ago) + SUM(CASE + WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) AND DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) + THEN o.price * o.quantity + ELSE 0 + END) as previous_revenue + FROM products p + JOIN orders o ON p.product_id = o.product_id + WHERE o.canceled = false + AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) + GROUP BY p.product_id, p.SKU, p.title + ) SELECT - p.product_id, - p.SKU, - p.title, - p.brand, - p.vendor, - pm.total_revenue, - pm.daily_sales_avg, - pm.number_of_orders, - SUM(o.quantity) as units_sold, - GROUP_CONCAT(c.name) as categories - FROM products p - JOIN product_metrics pm ON p.product_id = pm.product_id - LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false - LEFT JOIN product_categories pc ON p.product_id = pc.product_id - LEFT JOIN categories c ON pc.category_id = c.id - GROUP BY p.product_id - ORDER BY pm.total_revenue DESC - LIMIT 10 + product_id, + sku, + title, + units_sold, + revenue, + profit, + CASE + WHEN previous_revenue > 0 + THEN ((revenue - previous_revenue) / previous_revenue * 100) + WHEN revenue > 0 + THEN 100 + ELSE 0 + END as growth_rate + FROM product_sales + WHERE units_sold > 0 + ORDER BY revenue DESC + LIMIT 50 `); - const [vendors] = await executeQuery(` + const [brands] = await executeQuery(` + WITH brand_sales AS ( + SELECT + p.brand, + -- Current period (last 30 days) + SUM(CASE + WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) + THEN o.quantity + ELSE 0 + END) as units_sold, + SUM(CASE + WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) + THEN o.price * o.quantity + ELSE 0 + END) as revenue, + SUM(CASE + WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) + THEN (o.price - p.cost_price) * o.quantity + ELSE 0 + END) as profit, + -- Previous period (30-60 days ago) + SUM(CASE + WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) AND DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) + THEN o.price * o.quantity + ELSE 0 + END) as previous_revenue + FROM products p + JOIN orders o ON p.product_id = o.product_id + WHERE o.canceled = false + AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) + AND p.brand IS NOT NULL + GROUP BY p.brand + ) SELECT - vm.*, - COALESCE(SUM(o.quantity), 0) as products_sold - FROM vendor_metrics vm - LEFT JOIN orders o ON vm.vendor = o.vendor AND o.canceled = false - GROUP BY vm.vendor - ORDER BY vm.total_revenue DESC - LIMIT 10 + brand, + units_sold, + revenue, + profit, + CASE + WHEN previous_revenue > 0 + THEN ((revenue - previous_revenue) / previous_revenue * 100) + WHEN revenue > 0 + THEN 100 + ELSE 0 + END as growth_rate + FROM brand_sales + WHERE units_sold > 0 + ORDER BY revenue DESC + LIMIT 50 `); const [categories] = await executeQuery(` + WITH category_sales AS ( + SELECT + c.id as category_id, + c.name, + -- Current period (last 30 days) + SUM(CASE + WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) + THEN o.quantity + ELSE 0 + END) as units_sold, + SUM(CASE + WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) + THEN o.price * o.quantity + ELSE 0 + END) as revenue, + SUM(CASE + WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) + THEN (o.price - p.cost_price) * o.quantity + ELSE 0 + END) as profit, + -- Previous period (30-60 days ago) + SUM(CASE + WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) AND DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) + THEN o.price * o.quantity + ELSE 0 + END) as previous_revenue + FROM categories c + JOIN product_categories pc ON c.id = pc.category_id + JOIN products p ON pc.product_id = p.product_id + JOIN orders o ON p.product_id = o.product_id + WHERE o.canceled = false + AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) + GROUP BY c.id, c.name + ) SELECT - c.name, - cm.* - FROM category_metrics cm - JOIN categories c ON cm.category_id = c.id - ORDER BY cm.total_value DESC - LIMIT 10 + category_id, + name, + units_sold, + revenue, + profit, + CASE + WHEN previous_revenue > 0 + THEN ((revenue - previous_revenue) / previous_revenue * 100) + WHEN revenue > 0 + THEN 100 + ELSE 0 + END as growth_rate + FROM category_sales + WHERE units_sold > 0 + ORDER BY revenue DESC + LIMIT 50 `); // Format response with explicit type conversion const formattedProducts = products.map(p => ({ ...p, - total_revenue: parseFloat(p.total_revenue) || 0, - daily_sales_avg: parseFloat(p.daily_sales_avg) || 0, - number_of_orders: parseInt(p.number_of_orders) || 0, units_sold: parseInt(p.units_sold) || 0, - categories: p.categories ? p.categories.split(',') : [] + revenue: parseFloat(p.revenue) || 0, + profit: parseFloat(p.profit) || 0, + growth_rate: parseFloat(p.growth_rate) || 0 + })); + + const formattedBrands = brands.map(b => ({ + brand: b.brand, + units_sold: parseInt(b.units_sold) || 0, + revenue: parseFloat(b.revenue) || 0, + profit: parseFloat(b.profit) || 0, + growth_rate: parseFloat(b.growth_rate) || 0 + })); + + const formattedCategories = categories.map(c => ({ + category_id: c.category_id, + name: c.name, + units_sold: parseInt(c.units_sold) || 0, + revenue: parseFloat(c.revenue) || 0, + profit: parseFloat(c.profit) || 0, + growth_rate: parseFloat(c.growth_rate) || 0 })); res.json({ products: formattedProducts, - vendors, - categories + brands: formattedBrands, + categories: formattedCategories }); } catch (err) { console.error('Error fetching best sellers:', err); diff --git a/inventory/src/components/dashboard/BestSellers.tsx b/inventory/src/components/dashboard/BestSellers.tsx index fca9da0..ca31dfd 100644 --- a/inventory/src/components/dashboard/BestSellers.tsx +++ b/inventory/src/components/dashboard/BestSellers.tsx @@ -13,20 +13,21 @@ interface BestSellerProduct { units_sold: number revenue: number profit: number + growth_rate: number } -interface BestSellerVendor { - vendor: string - products_sold: number +interface BestSellerBrand { + brand: string + units_sold: number revenue: number profit: number - order_fill_rate: number + growth_rate: number } interface BestSellerCategory { category_id: number name: string - products_sold: number + units_sold: number revenue: number profit: number growth_rate: number @@ -34,7 +35,7 @@ interface BestSellerCategory { interface BestSellersData { products: BestSellerProduct[] - vendors: BestSellerVendor[] + brands: BestSellerBrand[] categories: BestSellerCategory[] } @@ -52,49 +53,58 @@ export function BestSellers() { return ( <> - -
- Best Sellers - + + +
+ Best Sellers Products - Vendors + Brands Categories - -
-
- - +
+
+
- Product - Units - Revenue - Profit + Product + Sales + Revenue + Profit + Growth {data?.products.map((product) => ( - +
-

{product.title}

+ + {product.title} +

{product.sku}

- + {product.units_sold.toLocaleString()} - + {formatCurrency(product.revenue)} - + {formatCurrency(product.profit)} + + {product.growth_rate > 0 ? '+' : ''}{product.growth_rate.toFixed(1)}% +
))}
@@ -102,31 +112,35 @@ export function BestSellers() { - +
- Vendor - Products - Revenue - Fill Rate + Brand + Sales + Revenue + Profit + Growth - {data?.vendors.map((vendor) => ( - - -

{vendor.vendor}

+ {data?.brands.map((brand) => ( + + +

{brand.brand}

- - {vendor.products_sold.toLocaleString()} + + {brand.units_sold.toLocaleString()} - - {formatCurrency(vendor.revenue)} + + {formatCurrency(brand.revenue)} - - {vendor.order_fill_rate.toFixed(1)}% + + {formatCurrency(brand.profit)} + + + {brand.growth_rate > 0 ? '+' : ''}{brand.growth_rate.toFixed(1)}%
))} @@ -140,26 +154,30 @@ export function BestSellers() {
- Category - Products - Revenue - Growth + Category + Sales + Revenue + Profit + Growth {data?.categories.map((category) => ( - +

{category.name}

- - {category.products_sold.toLocaleString()} + + {category.units_sold.toLocaleString()} - + {formatCurrency(category.revenue)} - - {category.growth_rate.toFixed(1)}% + + {formatCurrency(category.profit)} + + + {category.growth_rate > 0 ? '+' : ''}{category.growth_rate.toFixed(1)}%
))} @@ -167,8 +185,8 @@ export function BestSellers() {
-
-
+ + ) } \ No newline at end of file From b5a354a1de0df13205fca48e2aaf43a85677917f Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 18 Jan 2025 01:12:44 -0500 Subject: [PATCH 16/17] Fix and restyle salesmetrics component --- inventory-server/src/routes/dashboard.js | 114 +++++------------- .../src/components/dashboard/BestSellers.tsx | 2 +- .../components/dashboard/ForecastMetrics.tsx | 4 +- .../src/components/dashboard/SalesMetrics.tsx | 61 +++------- 4 files changed, 52 insertions(+), 129 deletions(-) diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index a4d8224..1bdb86d 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -640,95 +640,47 @@ router.get('/best-sellers', async (req, res) => { router.get('/sales/metrics', async (req, res) => { const { startDate, endDate } = req.query; try { - const [dailyData] = await executeQuery(` - SELECT JSON_ARRAYAGG( - JSON_OBJECT( - 'date', sale_date, - 'orders', COALESCE(total_orders, 0), - 'units', COALESCE(total_units, 0), - 'revenue', COALESCE(total_revenue, 0), - 'cogs', COALESCE(total_cogs, 0), - 'profit', COALESCE(total_profit, 0) - ) - ) as daily_data - FROM ( - SELECT - DATE(o.date) as sale_date, - COUNT(DISTINCT o.order_number) as total_orders, - SUM(o.quantity) as total_units, - SUM(o.price * o.quantity) as total_revenue, - SUM(p.cost_price * o.quantity) as total_cogs, - SUM((o.price - p.cost_price) * o.quantity) as total_profit - FROM orders o - JOIN products p ON o.product_id = p.product_id - WHERE o.canceled = false - AND o.date BETWEEN ? AND ? - GROUP BY DATE(o.date) - ) d - `, [startDate, endDate]); - - const [categoryData] = await executeQuery(` - SELECT JSON_ARRAYAGG( - JSON_OBJECT( - 'category', category_name, - 'orders', COALESCE(category_orders, 0), - 'units', COALESCE(category_units, 0), - 'revenue', COALESCE(category_revenue, 0) - ) - ) as category_data - FROM ( - SELECT - c.name as category_name, - COUNT(DISTINCT o.order_number) as category_orders, - SUM(o.quantity) as category_units, - SUM(o.price * o.quantity) as category_revenue - FROM orders o - JOIN products p ON o.product_id = p.product_id - JOIN product_categories pc ON p.product_id = pc.product_id - JOIN categories c ON pc.category_id = c.id - WHERE o.canceled = false - AND o.date BETWEEN ? AND ? - GROUP BY c.id, c.name - ) c - `, [startDate, endDate]); - - const [metrics] = await executeQuery(` + // Get daily sales data + const [dailyRows] = await executeQuery(` SELECT - COALESCE(COUNT(DISTINCT DATE(o.date)), 0) as days_with_sales, - COALESCE(COUNT(DISTINCT o.order_number), 0) as total_orders, - COALESCE(SUM(o.quantity), 0) as total_units, - COALESCE(SUM(o.price * o.quantity), 0) as total_revenue, - COALESCE(SUM(p.cost_price * o.quantity), 0) as total_cogs, - COALESCE(SUM((o.price - p.cost_price) * o.quantity), 0) as total_profit, - COALESCE(AVG(daily.orders), 0) as avg_daily_orders, - COALESCE(AVG(daily.units), 0) as avg_daily_units, - COALESCE(AVG(daily.revenue), 0) as avg_daily_revenue + DATE(o.date) as sale_date, + COUNT(DISTINCT o.order_number) as total_orders, + SUM(o.quantity) as total_units, + SUM(o.price * o.quantity) as total_revenue, + SUM(p.cost_price * o.quantity) as total_cogs, + SUM((o.price - p.cost_price) * o.quantity) as total_profit + FROM orders o + JOIN products p ON o.product_id = p.product_id + WHERE o.canceled = false + AND o.date BETWEEN ? AND ? + GROUP BY DATE(o.date) + ORDER BY sale_date + `, [startDate, endDate]); + + // Get summary metrics + const [metrics] = await executeQuery(` + SELECT + COUNT(DISTINCT o.order_number) as total_orders, + SUM(o.quantity) as total_units, + SUM(o.price * o.quantity) as total_revenue, + SUM(p.cost_price * o.quantity) as total_cogs, + SUM((o.price - p.cost_price) * o.quantity) as total_profit FROM orders o JOIN products p ON o.product_id = p.product_id - LEFT JOIN ( - SELECT - DATE(date) as sale_date, - COUNT(DISTINCT order_number) as orders, - SUM(quantity) as units, - SUM(price * quantity) as revenue - FROM orders - WHERE canceled = false - GROUP BY DATE(date) - ) daily ON DATE(o.date) = daily.sale_date WHERE o.canceled = false AND o.date BETWEEN ? AND ? `, [startDate, endDate]); const response = { - totalOrders: parseInt(metrics.total_orders) || 0, - totalUnitsSold: parseInt(metrics.total_units) || 0, - totalRevenue: parseFloat(metrics.total_revenue) || 0, - totalCogs: parseFloat(metrics.total_cogs) || 0, - dailySales: JSON.parse(dailyData.daily_data || '[]').map(day => ({ - date: day.date, - units: parseInt(day.units) || 0, - revenue: parseFloat(day.revenue) || 0, - cogs: parseFloat(day.cogs) || 0 + totalOrders: parseInt(metrics[0]?.total_orders) || 0, + totalUnitsSold: parseInt(metrics[0]?.total_units) || 0, + totalCogs: parseFloat(metrics[0]?.total_cogs) || 0, + totalRevenue: parseFloat(metrics[0]?.total_revenue) || 0, + dailySales: dailyRows.map(day => ({ + date: day.sale_date, + units: parseInt(day.total_units) || 0, + revenue: parseFloat(day.total_revenue) || 0, + cogs: parseFloat(day.total_cogs) || 0 })) }; diff --git a/inventory/src/components/dashboard/BestSellers.tsx b/inventory/src/components/dashboard/BestSellers.tsx index ca31dfd..ad17942 100644 --- a/inventory/src/components/dashboard/BestSellers.tsx +++ b/inventory/src/components/dashboard/BestSellers.tsx @@ -66,7 +66,7 @@ export function BestSellers() { - + diff --git a/inventory/src/components/dashboard/ForecastMetrics.tsx b/inventory/src/components/dashboard/ForecastMetrics.tsx index 87fe747..6eb5ab0 100644 --- a/inventory/src/components/dashboard/ForecastMetrics.tsx +++ b/inventory/src/components/dashboard/ForecastMetrics.tsx @@ -115,8 +115,8 @@ export function ForecastMetrics() { type="monotone" dataKey="revenue" name="Revenue" - stroke="#00C49F" - fill="#00C49F" + stroke="#8884D8" + fill="#8884D8" fillOpacity={0.2} /> diff --git a/inventory/src/components/dashboard/SalesMetrics.tsx b/inventory/src/components/dashboard/SalesMetrics.tsx index 671f776..ad1b281 100644 --- a/inventory/src/components/dashboard/SalesMetrics.tsx +++ b/inventory/src/components/dashboard/SalesMetrics.tsx @@ -6,7 +6,7 @@ import config from "@/config" import { formatCurrency } from "@/lib/utils" import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react" import { DateRange } from "react-day-picker" -import { addDays } from "date-fns" +import { addDays, format } from "date-fns" import { DateRangePicker } from "@/components/ui/date-range-picker-narrow" interface SalesData { @@ -45,7 +45,7 @@ export function SalesMetrics() { return ( <> - + Sales Overview
- +

Total Orders

-

{data?.totalOrders.toLocaleString() || 0}

+

{data?.totalOrders.toLocaleString() || 0}

Units Sold

-

{data?.totalUnitsSold.toLocaleString() || 0}

+

{data?.totalUnitsSold.toLocaleString() || 0}

Cost of Goods

-

{formatCurrency(data?.totalCogs || 0)}

+

{formatCurrency(data?.totalCogs || 0)}

Revenue

-

{formatCurrency(data?.totalRevenue || 0)}

+

{formatCurrency(data?.totalRevenue || 0)}

-
+
- + value.toLocaleString()} - /> - formatCurrency(value)} + tick={false} /> [ - name === "units" ? value.toLocaleString() : formatCurrency(value), - name === "units" ? "Units" : name === "revenue" ? "Revenue" : "COGS" - ]} - labelFormatter={(label) => `Date: ${label}`} + formatter={(value: number) => [formatCurrency(value), "Revenue"]} + labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')} /> - -
From a642790028afaaf806b6211457ac89518316da9e Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 18 Jan 2025 10:36:12 -0500 Subject: [PATCH 17/17] Rename sales component --- inventory/src/components/dashboard/SalesMetrics.tsx | 2 +- inventory/tsconfig.tsbuildinfo | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/inventory/src/components/dashboard/SalesMetrics.tsx b/inventory/src/components/dashboard/SalesMetrics.tsx index ad1b281..ceb41d5 100644 --- a/inventory/src/components/dashboard/SalesMetrics.tsx +++ b/inventory/src/components/dashboard/SalesMetrics.tsx @@ -46,7 +46,7 @@ export function SalesMetrics() { return ( <> - Sales Overview + Sales