Fix and restyle forecast component

This commit is contained in:
2025-01-17 23:44:13 -05:00
parent 5987b7173d
commit 9759bac94f
4 changed files with 137 additions and 95 deletions

View File

@@ -3,7 +3,7 @@ const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
const fs = require('fs');
// Configuration flags
// Set to 1 to skip product metrics and only calculate the remaining metrics
const SKIP_PRODUCT_METRICS = 0;
// Helper function to format elapsed time
@@ -1149,8 +1149,12 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date
FROM (
SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 14 UNION SELECT 30 UNION
SELECT 60 UNION SELECT 90
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION
SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 UNION
SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 UNION
SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 UNION
SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 UNION
SELECT 30
) numbers
),
product_stats AS (
@@ -1160,7 +1164,15 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
STDDEV_SAMP(ds.daily_quantity) as std_daily_quantity,
AVG(ds.daily_revenue) as avg_daily_revenue,
STDDEV_SAMP(ds.daily_revenue) as std_daily_revenue,
COUNT(*) as data_points
COUNT(*) as data_points,
-- Calculate day-of-week averages
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 1 THEN ds.daily_revenue END) as sunday_avg,
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 2 THEN ds.daily_revenue END) as monday_avg,
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 3 THEN ds.daily_revenue END) as tuesday_avg,
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 4 THEN ds.daily_revenue END) as wednesday_avg,
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 5 THEN ds.daily_revenue END) as thursday_avg,
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 6 THEN ds.daily_revenue END) as friday_avg,
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 7 THEN ds.daily_revenue END) as saturday_avg
FROM daily_sales ds
GROUP BY ds.product_id
)
@@ -1178,14 +1190,24 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
))
) as forecast_units,
GREATEST(0,
ps.avg_daily_revenue *
CASE DAYOFWEEK(fd.forecast_date)
WHEN 1 THEN COALESCE(ps.sunday_avg, ps.avg_daily_revenue)
WHEN 2 THEN COALESCE(ps.monday_avg, ps.avg_daily_revenue)
WHEN 3 THEN COALESCE(ps.tuesday_avg, ps.avg_daily_revenue)
WHEN 4 THEN COALESCE(ps.wednesday_avg, ps.avg_daily_revenue)
WHEN 5 THEN COALESCE(ps.thursday_avg, ps.avg_daily_revenue)
WHEN 6 THEN COALESCE(ps.friday_avg, ps.avg_daily_revenue)
WHEN 7 THEN COALESCE(ps.saturday_avg, ps.avg_daily_revenue)
END *
(1 + COALESCE(
(SELECT seasonality_factor
FROM sales_seasonality
WHERE MONTH(fd.forecast_date) = month
LIMIT 1),
0
))
)) *
-- Add some randomness within a small range (±5%)
(0.95 + (RAND() * 0.1))
) as forecast_revenue,
CASE
WHEN ps.data_points >= 60 THEN 90
@@ -1231,8 +1253,12 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date
FROM (
SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 14 UNION SELECT 30 UNION
SELECT 60 UNION SELECT 90
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION
SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 UNION
SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 UNION
SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 UNION
SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 UNION
SELECT 30
) numbers
),
category_stats AS (
@@ -1242,7 +1268,15 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
STDDEV_SAMP(cds.daily_quantity) as std_daily_quantity,
AVG(cds.daily_revenue) as avg_daily_revenue,
STDDEV_SAMP(cds.daily_revenue) as std_daily_revenue,
COUNT(*) as data_points
COUNT(*) as data_points,
-- Calculate day-of-week averages
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 1 THEN cds.daily_revenue END) as sunday_avg,
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 2 THEN cds.daily_revenue END) as monday_avg,
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 3 THEN cds.daily_revenue END) as tuesday_avg,
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 4 THEN cds.daily_revenue END) as wednesday_avg,
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 5 THEN cds.daily_revenue END) as thursday_avg,
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 6 THEN cds.daily_revenue END) as friday_avg,
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 7 THEN cds.daily_revenue END) as saturday_avg
FROM category_daily_sales cds
GROUP BY cds.category_id
)
@@ -1260,14 +1294,24 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
))
) as forecast_units,
GREATEST(0,
cs.avg_daily_revenue *
CASE DAYOFWEEK(fd.forecast_date)
WHEN 1 THEN COALESCE(cs.sunday_avg, cs.avg_daily_revenue)
WHEN 2 THEN COALESCE(cs.monday_avg, cs.avg_daily_revenue)
WHEN 3 THEN COALESCE(cs.tuesday_avg, cs.avg_daily_revenue)
WHEN 4 THEN COALESCE(cs.wednesday_avg, cs.avg_daily_revenue)
WHEN 5 THEN COALESCE(cs.thursday_avg, cs.avg_daily_revenue)
WHEN 6 THEN COALESCE(cs.friday_avg, cs.avg_daily_revenue)
WHEN 7 THEN COALESCE(cs.saturday_avg, cs.avg_daily_revenue)
END *
(1 + COALESCE(
(SELECT seasonality_factor
FROM sales_seasonality
WHERE MONTH(fd.forecast_date) = month
LIMIT 1),
0
))
)) *
-- Add some randomness within a small range (±5%)
(0.95 + (RAND() * 0.1))
) as forecast_revenue,
CASE
WHEN cs.data_points >= 60 THEN 90

View File

@@ -270,14 +270,13 @@ router.get('/forecast/metrics', async (req, res) => {
// Get daily forecasts
const [dailyForecasts] = await executeQuery(`
SELECT
forecast_date as date,
COALESCE(SUM(forecast_units), 0) as units,
DATE(forecast_date) as date,
COALESCE(SUM(forecast_revenue), 0) as revenue,
COALESCE(AVG(confidence_level), 0) as confidence
FROM sales_forecasts
WHERE forecast_date BETWEEN ? AND ?
GROUP BY forecast_date
ORDER BY forecast_date
GROUP BY DATE(forecast_date)
ORDER BY date
`, [startDate, endDate]);
// Get category forecasts
@@ -296,12 +295,11 @@ router.get('/forecast/metrics', async (req, res) => {
// Format response
const response = {
forecastSales: parseInt(metrics.total_forecast_units) || 0,
forecastRevenue: parseFloat(metrics.total_forecast_revenue) || 0,
confidenceLevel: parseFloat(metrics.overall_confidence) || 0,
forecastSales: parseInt(metrics[0].total_forecast_units) || 0,
forecastRevenue: parseFloat(metrics[0].total_forecast_revenue) || 0,
confidenceLevel: parseFloat(metrics[0].overall_confidence) || 0,
dailyForecasts: dailyForecasts.map(d => ({
date: d.date,
units: parseInt(d.units) || 0,
revenue: parseFloat(d.revenue) || 0,
confidence: parseFloat(d.confidence) || 0
})),

View File

@@ -6,16 +6,24 @@ import config from "@/config"
import { formatCurrency } from "@/lib/utils"
import { TrendingUp, DollarSign } from "lucide-react"
import { DateRange } from "react-day-picker"
import { addDays } from "date-fns"
import { addDays, format } from "date-fns"
import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
interface ForecastData {
forecastSales: number
forecastRevenue: number
dailyForecast: {
confidenceLevel: number
dailyForecasts: {
date: string
sales: number
units: number
revenue: number
confidence: number
}[]
categoryForecasts: {
category: string
units: number
revenue: number
confidence: number
}[]
}
@@ -25,24 +33,28 @@ export function ForecastMetrics() {
to: addDays(new Date(), 30),
});
const { data } = useQuery<ForecastData>({
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) {
throw new Error("Failed to fetch forecast metrics")
const text = await response.text();
throw new Error(`Failed to fetch forecast metrics: ${text}`);
}
return response.json()
const data = await response.json();
console.log('Forecast metrics response:', data);
return data;
},
})
return (
<>
<CardHeader className="flex flex-row items-center justify-between pr-4">
<CardHeader className="flex flex-row items-center justify-between pr-5">
<CardTitle className="text-xl font-medium">Forecast</CardTitle>
<div className="w-[230px]">
<DateRangePicker
@@ -54,66 +66,52 @@ export function ForecastMetrics() {
/>
</div>
</CardHeader>
<CardContent>
<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-2xl font-bold">{data?.forecastSales.toLocaleString() || 0}</p>
<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-2xl font-bold">{formatCurrency(data?.forecastRevenue || 0)}</p>
<p className="text-lg font-bold">{formatCurrency(data?.forecastRevenue || 0)}</p>
</div>
</div>
<div className="h-[300px] w-full">
<div className="h-[250px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data?.dailyForecast || []}>
<AreaChart
data={data?.dailyForecasts || []}
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
fontSize={12}
tick={false}
/>
<YAxis
yAxisId="left"
tickLine={false}
axisLine={false}
fontSize={12}
tickFormatter={(value) => value.toLocaleString()}
/>
<YAxis
yAxisId="right"
orientation="right"
tickLine={false}
axisLine={false}
fontSize={12}
tickFormatter={(value) => formatCurrency(value)}
tick={false}
/>
<Tooltip
formatter={(value: number, name: string) => [
name === "revenue" ? formatCurrency(value) : value.toLocaleString(),
name === "revenue" ? "Revenue" : "Sales"
]}
labelFormatter={(label) => `Date: ${label}`}
formatter={(value: number) => [formatCurrency(value), "Revenue"]}
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="sales"
name="Sales"
stroke="#0088FE"
fill="#0088FE"
fillOpacity={0.2}
/>
<Area
yAxisId="right"
type="monotone"
dataKey="revenue"
name="Revenue"
@@ -124,6 +122,8 @@ export function ForecastMetrics() {
</AreaChart>
</ResponsiveContainer>
</div>
</>
)}
</CardContent>
</>
)

View File

@@ -33,7 +33,7 @@ export function TopReplenishProducts() {
<CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="max-h-[650px] w-full overflow-y-auto">
<ScrollArea className="max-h-[530px] w-full overflow-y-auto">
<Table>
<TableHeader>
<TableRow>