Merge branch 'Redo-dashboard'

This commit is contained in:
2025-01-18 10:37:12 -05:00
21 changed files with 2890 additions and 477 deletions

View File

@@ -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

View File

@@ -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',

View File

@@ -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

View 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>
</>
)
}

View 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>
</>
)
}

View File

@@ -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

View 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>
</>
)
}

View 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>
</>
)
}

View 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>
</>
)
}

View 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>
</>
)
}

View 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>
</>
)
}

View 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 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>
</>
)
}

View 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>
</>
)
}

View File

@@ -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>
))} ))}

View File

@@ -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: "/",
}, },

View 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>
);
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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
View File

@@ -0,0 +1 @@