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') }); 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
@@ -1149,8 +1149,12 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date
FROM ( FROM (
SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION 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 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION
SELECT 60 UNION SELECT 90 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 ) numbers
), ),
product_stats AS ( product_stats AS (
@@ -1160,7 +1164,15 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
STDDEV_SAMP(ds.daily_quantity) as std_daily_quantity, STDDEV_SAMP(ds.daily_quantity) as std_daily_quantity,
AVG(ds.daily_revenue) as avg_daily_revenue, AVG(ds.daily_revenue) as avg_daily_revenue,
STDDEV_SAMP(ds.daily_revenue) as std_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 FROM daily_sales ds
GROUP BY ds.product_id GROUP BY ds.product_id
) )
@@ -1178,14 +1190,24 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
)) ))
) as forecast_units, ) as forecast_units,
GREATEST(0, 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( (1 + COALESCE(
(SELECT seasonality_factor (SELECT seasonality_factor
FROM sales_seasonality FROM sales_seasonality
WHERE MONTH(fd.forecast_date) = month WHERE MONTH(fd.forecast_date) = month
LIMIT 1), LIMIT 1),
0 0
)) )) *
-- Add some randomness within a small range (±5%)
(0.95 + (RAND() * 0.1))
) as forecast_revenue, ) as forecast_revenue,
CASE CASE
WHEN ps.data_points >= 60 THEN 90 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 DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date
FROM ( FROM (
SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION 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 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION
SELECT 60 UNION SELECT 90 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 ) numbers
), ),
category_stats AS ( category_stats AS (
@@ -1242,7 +1268,15 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
STDDEV_SAMP(cds.daily_quantity) as std_daily_quantity, STDDEV_SAMP(cds.daily_quantity) as std_daily_quantity,
AVG(cds.daily_revenue) as avg_daily_revenue, AVG(cds.daily_revenue) as avg_daily_revenue,
STDDEV_SAMP(cds.daily_revenue) as std_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 FROM category_daily_sales cds
GROUP BY cds.category_id GROUP BY cds.category_id
) )
@@ -1260,14 +1294,24 @@ async function calculateSalesForecasts(connection, startTime, totalProducts) {
)) ))
) as forecast_units, ) as forecast_units,
GREATEST(0, 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( (1 + COALESCE(
(SELECT seasonality_factor (SELECT seasonality_factor
FROM sales_seasonality FROM sales_seasonality
WHERE MONTH(fd.forecast_date) = month WHERE MONTH(fd.forecast_date) = month
LIMIT 1), LIMIT 1),
0 0
)) )) *
-- Add some randomness within a small range (±5%)
(0.95 + (RAND() * 0.1))
) as forecast_revenue, ) as forecast_revenue,
CASE CASE
WHEN cs.data_points >= 60 THEN 90 WHEN cs.data_points >= 60 THEN 90

View File

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

View File

@@ -6,16 +6,24 @@ import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/lib/utils"
import { TrendingUp, DollarSign } from "lucide-react" import { TrendingUp, DollarSign } from "lucide-react"
import { DateRange } from "react-day-picker" 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" import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
interface ForecastData { interface ForecastData {
forecastSales: number forecastSales: number
forecastRevenue: number forecastRevenue: number
dailyForecast: { confidenceLevel: number
dailyForecasts: {
date: string date: string
sales: number units: number
revenue: 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), to: addDays(new Date(), 30),
}); });
const { data } = useQuery<ForecastData>({ const { data, error, isLoading } = useQuery<ForecastData>({
queryKey: ["forecast-metrics", dateRange], queryKey: ["forecast-metrics", dateRange],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({
startDate: dateRange.from?.toISOString() || "", startDate: dateRange.from?.toISOString() || "",
endDate: dateRange.to?.toISOString() || "", endDate: dateRange.to?.toISOString() || "",
}); });
console.log('Fetching forecast metrics with params:', params.toString());
const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?${params}`) const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?${params}`)
if (!response.ok) { 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 ( 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> <CardTitle className="text-xl font-medium">Forecast</CardTitle>
<div className="w-[230px]"> <div className="w-[230px]">
<DateRangePicker <DateRangePicker
@@ -54,66 +66,52 @@ export function ForecastMetrics() {
/> />
</div> </div>
</CardHeader> </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 flex-col gap-4">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-muted-foreground" /> <TrendingUp className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Forecast Sales</p> <p className="text-sm font-medium text-muted-foreground">Forecast Sales</p>
</div> </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>
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-muted-foreground" /> <DollarSign className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p> <p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
</div> </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> </div>
<div className="h-[300px] w-full"> <div className="h-[250px] w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={data?.dailyForecast || []}> <AreaChart
data={data?.dailyForecasts || []}
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
>
<XAxis <XAxis
dataKey="date" dataKey="date"
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
fontSize={12} tick={false}
/> />
<YAxis <YAxis
yAxisId="left"
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
fontSize={12} tick={false}
tickFormatter={(value) => value.toLocaleString()}
/>
<YAxis
yAxisId="right"
orientation="right"
tickLine={false}
axisLine={false}
fontSize={12}
tickFormatter={(value) => formatCurrency(value)}
/> />
<Tooltip <Tooltip
formatter={(value: number, name: string) => [ formatter={(value: number) => [formatCurrency(value), "Revenue"]}
name === "revenue" ? formatCurrency(value) : value.toLocaleString(), labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
name === "revenue" ? "Revenue" : "Sales"
]}
labelFormatter={(label) => `Date: ${label}`}
/> />
<Area <Area
yAxisId="left"
type="monotone"
dataKey="sales"
name="Sales"
stroke="#0088FE"
fill="#0088FE"
fillOpacity={0.2}
/>
<Area
yAxisId="right"
type="monotone" type="monotone"
dataKey="revenue" dataKey="revenue"
name="Revenue" name="Revenue"
@@ -124,6 +122,8 @@ export function ForecastMetrics() {
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</>
)}
</CardContent> </CardContent>
</> </>
) )

View File

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