Merge branch 'Redo-dashboard'
This commit is contained in:
@@ -63,6 +63,10 @@ CREATE TABLE IF NOT EXISTS product_metrics (
|
|||||||
current_lead_time INT,
|
current_lead_time INT,
|
||||||
target_lead_time INT,
|
target_lead_time INT,
|
||||||
lead_time_status VARCHAR(20),
|
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),
|
PRIMARY KEY (product_id),
|
||||||
FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE,
|
FOREIGN KEY (product_id) REFERENCES products(product_id) ON DELETE CASCADE,
|
||||||
INDEX idx_metrics_revenue (total_revenue),
|
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_turnover (turnover_rate),
|
||||||
INDEX idx_metrics_last_calculated (last_calculated_at),
|
INDEX idx_metrics_last_calculated (last_calculated_at),
|
||||||
INDEX idx_metrics_abc (abc_class),
|
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
|
-- New table for time-based aggregates
|
||||||
@@ -97,6 +102,20 @@ CREATE TABLE IF NOT EXISTS product_time_aggregates (
|
|||||||
INDEX idx_date (year, month)
|
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
|
-- New table for vendor metrics
|
||||||
CREATE TABLE IF NOT EXISTS vendor_metrics (
|
CREATE TABLE IF NOT EXISTS vendor_metrics (
|
||||||
vendor VARCHAR(100) NOT NULL,
|
vendor VARCHAR(100) NOT NULL,
|
||||||
@@ -200,10 +219,95 @@ CREATE TABLE IF NOT EXISTS category_sales_metrics (
|
|||||||
INDEX idx_period (period_start, period_end)
|
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
|
-- Re-enable foreign key checks
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
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
|
CREATE OR REPLACE VIEW inventory_health AS
|
||||||
WITH product_thresholds AS (
|
WITH product_thresholds AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -298,77 +402,6 @@ LEFT JOIN
|
|||||||
WHERE
|
WHERE
|
||||||
p.managing_stock = true;
|
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 view for category performance trends
|
||||||
CREATE OR REPLACE VIEW category_performance_trends AS
|
CREATE OR REPLACE VIEW category_performance_trends AS
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const path = require('path');
|
|||||||
require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
|
require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
// Configuration flags
|
// Set to 1 to skip product metrics and only calculate the remaining metrics
|
||||||
const SKIP_PRODUCT_METRICS = 0;
|
const SKIP_PRODUCT_METRICS = 0;
|
||||||
|
|
||||||
// Helper function to format elapsed time
|
// Helper function to format elapsed time
|
||||||
@@ -974,6 +974,363 @@ 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 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 (
|
||||||
|
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,
|
||||||
|
-- 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
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
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 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 (
|
||||||
|
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,
|
||||||
|
-- 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
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
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
|
// Update the main calculation function to include the new metrics
|
||||||
async function calculateMetrics() {
|
async function calculateMetrics() {
|
||||||
let pool;
|
let pool;
|
||||||
@@ -1727,6 +2084,10 @@ async function calculateMetrics() {
|
|||||||
WHERE s.product_id IS NULL
|
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
|
// Final success message
|
||||||
outputProgress({
|
outputProgress({
|
||||||
status: 'complete',
|
status: 'complete',
|
||||||
|
|||||||
@@ -17,15 +17,21 @@ function outputProgress(data) {
|
|||||||
|
|
||||||
// Explicitly define all metrics-related tables
|
// Explicitly define all metrics-related tables
|
||||||
const METRICS_TABLES = [
|
const METRICS_TABLES = [
|
||||||
'temp_sales_metrics',
|
'brand_metrics',
|
||||||
'temp_purchase_metrics',
|
'brand_time_metrics',
|
||||||
|
'category_forecasts',
|
||||||
|
'category_metrics',
|
||||||
|
'category_sales_metrics',
|
||||||
|
'category_time_metrics',
|
||||||
'product_metrics',
|
'product_metrics',
|
||||||
'product_time_aggregates',
|
'product_time_aggregates',
|
||||||
'vendor_metrics',
|
'sales_forecasts',
|
||||||
'vendor_time_metrics',
|
'sales_seasonality',
|
||||||
'category_metrics',
|
'temp_purchase_metrics',
|
||||||
'category_time_metrics',
|
'temp_sales_metrics',
|
||||||
'category_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
|
// Config tables that must exist
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
192
inventory/src/components/dashboard/BestSellers.tsx
Normal file
192
inventory/src/components/dashboard/BestSellers.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
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
|
||||||
|
growth_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BestSellerBrand {
|
||||||
|
brand: string
|
||||||
|
units_sold: number
|
||||||
|
revenue: number
|
||||||
|
profit: number
|
||||||
|
growth_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BestSellerCategory {
|
||||||
|
category_id: number
|
||||||
|
name: string
|
||||||
|
units_sold: number
|
||||||
|
revenue: number
|
||||||
|
profit: number
|
||||||
|
growth_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BestSellersData {
|
||||||
|
products: BestSellerProduct[]
|
||||||
|
brands: BestSellerBrand[]
|
||||||
|
categories: BestSellerCategory[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BestSellers() {
|
||||||
|
const { data } = useQuery<BestSellersData>({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Tabs defaultValue="products">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-lg font-medium">Best Sellers</CardTitle>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="products">Products</TabsTrigger>
|
||||||
|
<TabsTrigger value="brands">Brands</TabsTrigger>
|
||||||
|
<TabsTrigger value="categories">Categories</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TabsContent value="products">
|
||||||
|
<ScrollArea className="h-[385px] w-full">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40%]">Product</TableHead>
|
||||||
|
<TableHead className="w-[15%] text-right">Sales</TableHead>
|
||||||
|
<TableHead className="w-[15%] text-right">Revenue</TableHead>
|
||||||
|
<TableHead className="w-[15%] text-right">Profit</TableHead>
|
||||||
|
<TableHead className="w-[15%] text-right">Growth</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.products.map((product) => (
|
||||||
|
<TableRow key={product.product_id}>
|
||||||
|
<TableCell className="w-[40%]">
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={`https://backend.acherryontop.com/product/${product.product_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{product.title}
|
||||||
|
</a>
|
||||||
|
<p className="text-sm text-muted-foreground">{product.sku}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[15%] text-right">
|
||||||
|
{product.units_sold.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[15%] text-right">
|
||||||
|
{formatCurrency(product.revenue)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[15%] text-right">
|
||||||
|
{formatCurrency(product.profit)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[15%] text-right">
|
||||||
|
{product.growth_rate > 0 ? '+' : ''}{product.growth_rate.toFixed(1)}%
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="brands">
|
||||||
|
<ScrollArea className="h-[400px] w-full">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40%]">Brand</TableHead>
|
||||||
|
<TableHead className="w-[15%] text-right">Sales</TableHead>
|
||||||
|
<TableHead className="w-[15%] text-right">Revenue</TableHead>
|
||||||
|
<TableHead className="w-[15%] text-right">Profit</TableHead>
|
||||||
|
<TableHead className="w-[15%] text-right">Growth</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.brands.map((brand) => (
|
||||||
|
<TableRow key={brand.brand}>
|
||||||
|
<TableCell className="w-[40%]">
|
||||||
|
<p className="font-medium">{brand.brand}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[15%] text-right">
|
||||||
|
{brand.units_sold.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[15%] text-right">
|
||||||
|
{formatCurrency(brand.revenue)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[15%] text-right">
|
||||||
|
{formatCurrency(brand.profit)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[15%] text-right">
|
||||||
|
{brand.growth_rate > 0 ? '+' : ''}{brand.growth_rate.toFixed(1)}%
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="categories">
|
||||||
|
<ScrollArea className="h-[400px] w-full">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40%]">Category</TableHead>
|
||||||
|
<TableHead className="w-[15%] text-right">Sales</TableHead>
|
||||||
|
<TableHead className="w-[15%] text-right">Revenue</TableHead>
|
||||||
|
<TableHead className="w-[15%] text-right">Profit</TableHead>
|
||||||
|
<TableHead className="w-[15%] text-right">Growth</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.categories.map((category) => (
|
||||||
|
<TableRow key={category.category_id}>
|
||||||
|
<TableCell className="w-[40%]">
|
||||||
|
<p className="font-medium">{category.name}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[15%] text-right">
|
||||||
|
{category.units_sold.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[15%] text-right">
|
||||||
|
{formatCurrency(category.revenue)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[15%] text-right">
|
||||||
|
{formatCurrency(category.profit)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[15%] text-right">
|
||||||
|
{category.growth_rate > 0 ? '+' : ''}{category.growth_rate.toFixed(1)}%
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
</CardContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
inventory/src/components/dashboard/ForecastMetrics.tsx
Normal file
130
inventory/src/components/dashboard/ForecastMetrics.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||||
|
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts"
|
||||||
|
import { useState } from "react"
|
||||||
|
import config from "@/config"
|
||||||
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
import { TrendingUp, DollarSign } from "lucide-react"
|
||||||
|
import { DateRange } from "react-day-picker"
|
||||||
|
import { addDays, format } from "date-fns"
|
||||||
|
import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
||||||
|
|
||||||
|
interface ForecastData {
|
||||||
|
forecastSales: number
|
||||||
|
forecastRevenue: number
|
||||||
|
confidenceLevel: number
|
||||||
|
dailyForecasts: {
|
||||||
|
date: string
|
||||||
|
units: number
|
||||||
|
revenue: number
|
||||||
|
confidence: number
|
||||||
|
}[]
|
||||||
|
categoryForecasts: {
|
||||||
|
category: string
|
||||||
|
units: number
|
||||||
|
revenue: number
|
||||||
|
confidence: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForecastMetrics() {
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange>({
|
||||||
|
from: new Date(),
|
||||||
|
to: addDays(new Date(), 30),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, error, isLoading } = useQuery<ForecastData>({
|
||||||
|
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) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Failed to fetch forecast metrics: ${text}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Forecast metrics response:', data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pr-5">
|
||||||
|
<CardTitle className="text-xl font-medium">Forecast</CardTitle>
|
||||||
|
<div className="w-[230px]">
|
||||||
|
<DateRangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={(range) => {
|
||||||
|
if (range) setDateRange(range);
|
||||||
|
}}
|
||||||
|
future={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="py-0 -mb-2">
|
||||||
|
{error ? (
|
||||||
|
<div className="text-sm text-red-500">Error: {error.message}</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="text-sm">Loading forecast metrics...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Forecast Sales</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{data?.forecastSales.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data?.forecastRevenue || 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-[250px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart
|
||||||
|
data={data?.dailyForecasts || []}
|
||||||
|
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tick={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tick={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [formatCurrency(value), "Revenue"]}
|
||||||
|
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
name="Revenue"
|
||||||
|
stroke="#8884D8"
|
||||||
|
fill="#8884D8"
|
||||||
|
fillOpacity={0.2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,10 +14,10 @@ import config from "@/config"
|
|||||||
|
|
||||||
interface LowStockProduct {
|
interface LowStockProduct {
|
||||||
product_id: number
|
product_id: number
|
||||||
sku: string
|
SKU: string
|
||||||
title: string
|
title: string
|
||||||
stock_quantity: number
|
stock_quantity: number
|
||||||
reorder_point: number
|
reorder_qty: number
|
||||||
days_of_inventory: number
|
days_of_inventory: number
|
||||||
stock_status: "Critical" | "Reorder"
|
stock_status: "Critical" | "Reorder"
|
||||||
daily_sales_avg: number
|
daily_sales_avg: number
|
||||||
@@ -27,7 +27,7 @@ export function LowStockAlerts() {
|
|||||||
const { data: products } = useQuery<LowStockProduct[]>({
|
const { data: products } = useQuery<LowStockProduct[]>({
|
||||||
queryKey: ["low-stock"],
|
queryKey: ["low-stock"],
|
||||||
queryFn: async () => {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to fetch low stock products")
|
throw new Error("Failed to fetch low stock products")
|
||||||
}
|
}
|
||||||
@@ -54,10 +54,10 @@ export function LowStockAlerts() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{products?.map((product) => (
|
{products?.map((product) => (
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.product_id}>
|
||||||
<TableCell className="font-medium">{product.sku}</TableCell>
|
<TableCell className="font-medium">{product.SKU}</TableCell>
|
||||||
<TableCell>{product.title}</TableCell>
|
<TableCell>{product.title}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{product.stock_quantity} / {product.reorder_point}
|
{product.stock_quantity} / {product.reorder_qty}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
72
inventory/src/components/dashboard/OverstockMetrics.tsx
Normal file
72
inventory/src/components/dashboard/OverstockMetrics.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||||
|
import config from "@/config"
|
||||||
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react"
|
||||||
|
|
||||||
|
interface OverstockMetricsData {
|
||||||
|
overstockedProducts: number
|
||||||
|
total_excess_units: number
|
||||||
|
total_excess_cost: number
|
||||||
|
total_excess_retail: number
|
||||||
|
category_data: {
|
||||||
|
category: string
|
||||||
|
products: number
|
||||||
|
units: number
|
||||||
|
cost: number
|
||||||
|
retail: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverstockMetrics() {
|
||||||
|
const { data } = useQuery<OverstockMetricsData>({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-medium">Overstock</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Overstocked Products</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{data?.overstockedProducts.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Overstocked Units</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{data?.total_excess_units.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Overstocked Cost</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data?.total_excess_cost || 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data?.total_excess_retail || 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
204
inventory/src/components/dashboard/PurchaseMetrics.tsx
Normal file
204
inventory/src/components/dashboard/PurchaseMetrics.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||||
|
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
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
interface PurchaseMetricsData {
|
||||||
|
activePurchaseOrders: number
|
||||||
|
overduePurchaseOrders: number
|
||||||
|
onOrderUnits: number
|
||||||
|
onOrderCost: number
|
||||||
|
onOrderRetail: number
|
||||||
|
vendorOrders: {
|
||||||
|
vendor: string
|
||||||
|
orders: number
|
||||||
|
units: number
|
||||||
|
cost: number
|
||||||
|
retail: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"#0088FE",
|
||||||
|
"#00C49F",
|
||||||
|
"#FFBB28",
|
||||||
|
"#FF8042",
|
||||||
|
"#8884D8",
|
||||||
|
"#82CA9D",
|
||||||
|
"#FFC658",
|
||||||
|
"#FF7C43",
|
||||||
|
]
|
||||||
|
|
||||||
|
const renderActiveShape = (props: any) => {
|
||||||
|
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(' ');
|
||||||
|
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 (
|
||||||
|
<g>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
outerRadius={outerRadius}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
innerRadius={outerRadius - 1}
|
||||||
|
outerRadius={outerRadius + 4}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
dy={-20 + (i * 16)}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#888888"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
dy={lines.length * 16 - 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#000000"
|
||||||
|
className="text-base font-medium"
|
||||||
|
>
|
||||||
|
{formatCurrency(cost)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PurchaseMetrics() {
|
||||||
|
const [activeIndex, setActiveIndex] = useState<number | undefined>();
|
||||||
|
|
||||||
|
const { data, error, isLoading } = useQuery<PurchaseMetricsData>({
|
||||||
|
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) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.error('API Error:', text);
|
||||||
|
throw new Error(`Failed to fetch purchase metrics: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('API Response:', data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading...</div>;
|
||||||
|
if (error) return <div>Error loading purchase metrics</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-medium">Purchases</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex justify-between gap-8">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Active Purchase Orders</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{data?.activePurchaseOrders.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Overdue Purchase Orders</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{data?.overduePurchaseOrders.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">On Order Units</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{data?.onOrderUnits.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">On Order Cost</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data?.onOrderCost || 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">On Order Retail</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data?.onOrderRetail || 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-md flex justify-center font-medium">Purchase Orders By Vendor</div>
|
||||||
|
<div className="h-[180px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data?.vendorOrders || []}
|
||||||
|
dataKey="cost"
|
||||||
|
nameKey="vendor"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={1}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
activeShape={renderActiveShape}
|
||||||
|
onMouseEnter={(_, index) => setActiveIndex(index)}
|
||||||
|
onMouseLeave={() => setActiveIndex(undefined)}
|
||||||
|
>
|
||||||
|
{data?.vendorOrders?.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.vendor}
|
||||||
|
fill={COLORS[index % COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
inventory/src/components/dashboard/ReplenishmentMetrics.tsx
Normal file
77
inventory/src/components/dashboard/ReplenishmentMetrics.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||||
|
import config from "@/config"
|
||||||
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
import { Package, DollarSign, ShoppingCart } from "lucide-react" // Importing icons
|
||||||
|
|
||||||
|
interface ReplenishmentMetricsData {
|
||||||
|
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, error, isLoading } = useQuery<ReplenishmentMetricsData>({
|
||||||
|
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) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.error('API Error:', text);
|
||||||
|
throw new Error(`Failed to fetch replenishment metrics: ${response.status} ${response.statusText} - ${text}`)
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('API Response:', data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-8 text-center">Loading replenishment metrics...</div>;
|
||||||
|
if (error) return <div className="p-8 text-center text-red-500">Error: {error.message}</div>;
|
||||||
|
if (!data) return <div className="p-8 text-center">No replenishment data available</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-medium">Replenishment</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Units to Replenish</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{data.unitsToReplenish.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Replenishment Cost</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data.replenishmentCost || 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Replenishment Retail</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data.replenishmentRetail || 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
inventory/src/components/dashboard/SalesMetrics.tsx
Normal file
127
inventory/src/components/dashboard/SalesMetrics.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||||
|
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts"
|
||||||
|
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, format } from "date-fns"
|
||||||
|
import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
||||||
|
|
||||||
|
interface SalesData {
|
||||||
|
totalOrders: number
|
||||||
|
totalUnitsSold: number
|
||||||
|
totalCogs: number
|
||||||
|
totalRevenue: number
|
||||||
|
dailySales: {
|
||||||
|
date: string
|
||||||
|
units: number
|
||||||
|
revenue: number
|
||||||
|
cogs: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SalesMetrics() {
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange>({
|
||||||
|
from: addDays(new Date(), -30),
|
||||||
|
to: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = useQuery<SalesData>({
|
||||||
|
queryKey: ["sales-metrics", dateRange],
|
||||||
|
queryFn: async () => {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pr-5">
|
||||||
|
<CardTitle className="text-xl font-medium">Sales</CardTitle>
|
||||||
|
<div className="w-[230px]">
|
||||||
|
<DateRangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={(range) => {
|
||||||
|
if (range) setDateRange(range);
|
||||||
|
}}
|
||||||
|
future={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="py-0 -mb-2">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Total Orders</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{data?.totalOrders.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Units Sold</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{data?.totalUnitsSold.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data?.totalCogs || 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Revenue</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data?.totalRevenue || 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-[250px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart
|
||||||
|
data={data?.dailySales || []}
|
||||||
|
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tick={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tick={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [formatCurrency(value), "Revenue"]}
|
||||||
|
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
name="Revenue"
|
||||||
|
stroke="#00C49F"
|
||||||
|
fill="#00C49F"
|
||||||
|
fillOpacity={0.2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
204
inventory/src/components/dashboard/StockMetrics.tsx
Normal file
204
inventory/src/components/dashboard/StockMetrics.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||||
|
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"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
interface StockMetricsData {
|
||||||
|
totalProducts: number
|
||||||
|
productsInStock: number
|
||||||
|
totalStockUnits: number
|
||||||
|
totalStockCost: number
|
||||||
|
totalStockRetail: number
|
||||||
|
brandStock: {
|
||||||
|
brand: string
|
||||||
|
variants: number
|
||||||
|
units: number
|
||||||
|
cost: number
|
||||||
|
retail: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"#0088FE",
|
||||||
|
"#00C49F",
|
||||||
|
"#FFBB28",
|
||||||
|
"#FF8042",
|
||||||
|
"#8884D8",
|
||||||
|
"#82CA9D",
|
||||||
|
"#FFC658",
|
||||||
|
"#FF7C43",
|
||||||
|
]
|
||||||
|
|
||||||
|
const renderActiveShape = (props: any) => {
|
||||||
|
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(' ');
|
||||||
|
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 (
|
||||||
|
<g>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
outerRadius={outerRadius}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
innerRadius={outerRadius - 1}
|
||||||
|
outerRadius={outerRadius + 4}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
dy={-20 + (i * 16)}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#888888"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
dy={lines.length * 16 - 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#000000"
|
||||||
|
className="text-base font-medium"
|
||||||
|
>
|
||||||
|
{formatCurrency(retail)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StockMetrics() {
|
||||||
|
const [activeIndex, setActiveIndex] = useState<number | undefined>();
|
||||||
|
|
||||||
|
const { data, error, isLoading } = useQuery<StockMetricsData>({
|
||||||
|
queryKey: ["stock-metrics"],
|
||||||
|
queryFn: async () => {
|
||||||
|
console.log('Fetching from:', `${config.apiUrl}/dashboard/stock/metrics`);
|
||||||
|
const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.error('API Error:', text);
|
||||||
|
throw new Error(`Failed to fetch stock metrics: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('API Response:', data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading...</div>;
|
||||||
|
if (error) return <div>Error loading stock metrics</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-medium">Stock</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex justify-between gap-8">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Products</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{data?.totalProducts.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Products In Stock</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{data?.productsInStock.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Stock Units</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{data?.totalStockUnits.toLocaleString() || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Stock Cost</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data?.totalStockCost || 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Stock Retail</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold">{formatCurrency(data?.totalStockRetail || 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-md flex justify-center font-medium">Stock Retail By Brand</div>
|
||||||
|
<div className="h-[180px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data?.brandStock || []}
|
||||||
|
dataKey="retail"
|
||||||
|
nameKey="brand"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={1}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
activeShape={renderActiveShape}
|
||||||
|
onMouseEnter={(_, index) => setActiveIndex(index)}
|
||||||
|
onMouseLeave={() => setActiveIndex(undefined)}
|
||||||
|
>
|
||||||
|
{data?.brandStock?.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.brand}
|
||||||
|
fill={COLORS[index % COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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
|
||||||
|
stock_quantity: number
|
||||||
|
overstocked_amt: number
|
||||||
|
excess_cost: number
|
||||||
|
excess_retail: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopOverstockedProducts() {
|
||||||
|
const { data } = useQuery<OverstockedProduct[]>({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-medium">Top Overstocked Products</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-[300px] w-full">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead className="text-right">Current Stock</TableHead>
|
||||||
|
<TableHead className="text-right">Overstock Amt</TableHead>
|
||||||
|
<TableHead className="text-right">Overstock Cost</TableHead>
|
||||||
|
<TableHead className="text-right">Overstock Retail</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.map((product) => (
|
||||||
|
<TableRow key={product.product_id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={`https://backend.acherryontop.com/product/${product.product_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{product.title}
|
||||||
|
</a>
|
||||||
|
<p className="text-sm text-muted-foreground">{product.SKU}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{product.stock_quantity.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{product.overstocked_amt.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatCurrency(product.excess_cost)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatCurrency(product.excess_retail)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
inventory/src/components/dashboard/TopReplenishProducts.tsx
Normal file
83
inventory/src/components/dashboard/TopReplenishProducts.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopReplenishProducts() {
|
||||||
|
const { data } = useQuery<ReplenishProduct[]>({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="max-h-[530px] w-full overflow-y-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead className="text-right">Stock</TableHead>
|
||||||
|
<TableHead className="text-right">Replenish</TableHead>
|
||||||
|
<TableHead className="text-right">Cost</TableHead>
|
||||||
|
<TableHead className="text-right">Retail</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.map((product) => (
|
||||||
|
<TableRow key={product.product_id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={`https://backend.acherryontop.com/product/${product.product_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{product.title}
|
||||||
|
</a>
|
||||||
|
<p className="text-sm text-muted-foreground">{product.SKU}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{product.current_stock.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{product.replenish_qty.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatCurrency(product.replenish_cost)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatCurrency(product.replenish_retail)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,18 +13,19 @@ import config from "@/config"
|
|||||||
|
|
||||||
interface VendorMetrics {
|
interface VendorMetrics {
|
||||||
vendor: string
|
vendor: string
|
||||||
avg_lead_time_days: number
|
avg_lead_time: number
|
||||||
on_time_delivery_rate: number
|
on_time_delivery_rate: number
|
||||||
order_fill_rate: number
|
avg_fill_rate: number
|
||||||
total_orders: number
|
total_orders: number
|
||||||
total_late_orders: number
|
active_orders: number
|
||||||
|
overdue_orders: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorPerformance() {
|
export function VendorPerformance() {
|
||||||
const { data: vendors } = useQuery<VendorMetrics[]>({
|
const { data: vendors } = useQuery<VendorMetrics[]>({
|
||||||
queryKey: ["vendor-metrics"],
|
queryKey: ["vendor-metrics"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/vendors/metrics`)
|
const response = await fetch(`${config.apiUrl}/dashboard/vendor/performance`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to fetch vendor metrics")
|
throw new Error("Failed to fetch vendor metrics")
|
||||||
}
|
}
|
||||||
@@ -66,7 +67,7 @@ export function VendorPerformance() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{vendor.order_fill_rate.toFixed(0)}%
|
{vendor.avg_fill_rate.toFixed(0)}%
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { useLocation, useNavigate, Link } from "react-router-dom";
|
|||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
title: "Dashboard",
|
title: "Overview",
|
||||||
icon: Home,
|
icon: Home,
|
||||||
url: "/",
|
url: "/",
|
||||||
},
|
},
|
||||||
|
|||||||
135
inventory/src/components/ui/date-range-picker-narrow.tsx
Normal file
135
inventory/src/components/ui/date-range-picker-narrow.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={cn("grid gap-1", className)}>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild className="p-3">
|
||||||
|
<Button
|
||||||
|
id="date"
|
||||||
|
variant={"outline"}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-[230px] justify-start text-left font-normal",
|
||||||
|
!value && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="h-4 w-4" />
|
||||||
|
{value?.from ? (
|
||||||
|
value.to ? (
|
||||||
|
<>
|
||||||
|
{format(value.from, "LLL d, y")} -{" "}
|
||||||
|
{format(value.to, "LLL d, y")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
format(value.from, "LLL dd, y")
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span>Pick a date range</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-3" align="start">
|
||||||
|
<div className="flex gap-2 pb-4">
|
||||||
|
{presets.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.label}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onChange(preset.range)}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Calendar
|
||||||
|
initialFocus
|
||||||
|
mode="range"
|
||||||
|
defaultMonth={value?.from}
|
||||||
|
selected={value}
|
||||||
|
onSelect={(range) => {
|
||||||
|
if (range) onChange(range);
|
||||||
|
}}
|
||||||
|
numberOfMonths={2}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,3 +4,27 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,33 +1,64 @@
|
|||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { InventoryHealthSummary } from "@/components/dashboard/InventoryHealthSummary"
|
import { StockMetrics } from "@/components/dashboard/StockMetrics"
|
||||||
import { LowStockAlerts } from "@/components/dashboard/LowStockAlerts"
|
import { PurchaseMetrics } from "@/components/dashboard/PurchaseMetrics"
|
||||||
import { TrendingProducts } from "@/components/dashboard/TrendingProducts"
|
import { ReplenishmentMetrics } from "@/components/dashboard/ReplenishmentMetrics"
|
||||||
import { VendorPerformance } from "@/components/dashboard/VendorPerformance"
|
import { TopReplenishProducts } from "@/components/dashboard/TopReplenishProducts"
|
||||||
import { KeyMetricsCharts } from "@/components/dashboard/KeyMetricsCharts"
|
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"
|
import { motion } from "motion/react"
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
return (
|
return (
|
||||||
<motion.div layout className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
<motion.div layout className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||||
<div className="flex items-center justify-between space-y-2">
|
<div className="flex items-center justify-between space-y-2">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
<h2 className="text-3xl font-bold tracking-tight">Overview</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<InventoryHealthSummary />
|
{/* First row - Stock and Purchase metrics */}
|
||||||
</div>
|
<div className="grid gap-4 grid-cols-2">
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
<Card className="col-span-1">
|
||||||
<Card className="col-span-4">
|
<StockMetrics />
|
||||||
<KeyMetricsCharts />
|
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="col-span-3">
|
<Card className="col-span-1">
|
||||||
<LowStockAlerts />
|
<PurchaseMetrics />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
|
||||||
<Card className="col-span-4">
|
{/* Second row - Replenishment section */}
|
||||||
<TrendingProducts />
|
<div className="grid gap-4 grid-cols-3">
|
||||||
|
<Card className="col-span-2">
|
||||||
|
<TopReplenishProducts />
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="col-span-3">
|
<div className="col-span-1 grid gap-4">
|
||||||
<VendorPerformance />
|
<Card>
|
||||||
|
<ReplenishmentMetrics />
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<ForecastMetrics />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Third row - Overstock section */}
|
||||||
|
<div className="grid gap-4 grid-cols-3">
|
||||||
|
<Card className="col-span-1">
|
||||||
|
<OverstockMetrics />
|
||||||
|
</Card>
|
||||||
|
<Card className="col-span-2">
|
||||||
|
<TopOverstockedProducts />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fourth row - Best Sellers and Sales */}
|
||||||
|
<div className="grid gap-4 grid-cols-3">
|
||||||
|
<Card className="col-span-2">
|
||||||
|
<BestSellers />
|
||||||
|
</Card>
|
||||||
|
<Card className="col-span-1">
|
||||||
|
<SalesMetrics />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -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"],"version":"5.6.3"}
|
{"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/bestsellers.tsx","./src/components/dashboard/forecastmetrics.tsx","./src/components/dashboard/inventoryhealthsummary.tsx","./src/components/dashboard/inventorystats.tsx","./src/components/dashboard/keymetricscharts.tsx","./src/components/dashboard/lowstockalerts.tsx","./src/components/dashboard/overstockmetrics.tsx","./src/components/dashboard/overview.tsx","./src/components/dashboard/purchasemetrics.tsx","./src/components/dashboard/recentsales.tsx","./src/components/dashboard/replenishmentmetrics.tsx","./src/components/dashboard/salesbycategory.tsx","./src/components/dashboard/salesmetrics.tsx","./src/components/dashboard/stockmetrics.tsx","./src/components/dashboard/topoverstockedproducts.tsx","./src/components/dashboard/topreplenishproducts.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-narrow.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"}
|
||||||
1
src/lib/utils.ts
Normal file
1
src/lib/utils.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Reference in New Issue
Block a user