From d0cfc390124c2b1078dc6311b7d5f4abeb211efd Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sat, 25 Jul 2026 13:25:28 +0000 Subject: [PATCH] Add week/month summary stats + budget watch to Dashboard, make it the default page Dashboard now shows Actual/Forecast, % vs budget, and % net sales for both the current week and current month, plus a budget-watch callout listing any departments forecast to finish the month over their budget share. --- frontend/src/App.tsx | 2 +- frontend/src/pages/Dashboard.tsx | 259 ++++++++++++++++++++++++++++++- 2 files changed, 257 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9a1e0a7..827d8da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,7 +26,7 @@ const NAV: { id: Page; label: string; icon: React.ElementType; cap?: string }[] function Shell() { const { user } = useAuth() - const [page, setPage] = useState('weekly') + const [page, setPage] = useState('dashboard') const [menuOpen, setMenuOpen] = useState(false) const visibleNav = NAV.filter(n => !n.cap || can(user, n.cap)) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 42daa3f..f4765ed 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,12 +1,65 @@ import { useState, useEffect, useCallback } from 'react' -import { Bot, Clock, RefreshCw } from 'lucide-react' -import { getLatestInsight, generateInsight } from '../api' +import { Bot, Clock, RefreshCw, AlertTriangle } from 'lucide-react' +import { getLatestInsight, generateInsight, getActuals, getScheduled, getNetSales, getBudgets } from '../api' import { formatAge, renderContent } from '../lib/aiInsight' +import { forecastDayCost } from '../lib/forecast' import AIInsightHistory from '../components/AIInsightHistory' import type { AIInsight } from '../types' const REFRESH_MS = 5 * 60_000 +function localStr(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} +function mondayOf(d: Date): string { + const day = d.getDay() + const r = new Date(d) + r.setDate(d.getDate() + (day === 0 ? -6 : 1 - day)) + r.setHours(0, 0, 0, 0) + return localStr(r) +} +function addDaysStr(dateStr: string, n: number): string { + const d = new Date(dateStr + 'T00:00:00') + d.setDate(d.getDate() + n) + return localStr(d) +} +function daysInMonthFor(dateStr: string): number { + const d = new Date(dateStr + 'T00:00:00') + return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate() +} +function monthKeyOf(dateStr: string): string { + return dateStr.slice(0, 7) +} +function fmtDisplay(dateStr: string): string { + return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) +} +function fmtMoney(n: number): string { + return `£${n.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}` +} +function fmtDelta(pct: number): string { + const d = pct - 100 + return `${d >= 0 ? '+' : ''}${d.toFixed(1)}%` +} +function pctClass(pct: number): string { + return pct <= 100 ? 'pct-green' : pct <= 110 ? 'pct-amber' : 'pct-red' +} +function budgetColour(pct: number): string { + return pct <= 100 ? 'var(--app-primary)' : pct <= 110 ? '#b45309' : '#dc2626' +} + +type PeriodStats = { + actual: number + forecast: number + budgetActual: number | null + budgetFull: number | null + pctBudgetActual: number | null + pctBudgetForecast: number | null + varianceForecast: number | null + pctSalesForecast: number | null +} + +type DeptOverBudget = { name: string; pct: number } + export default function Dashboard() { const [insight, setInsight] = useState(null) const [loading, setLoading] = useState(true) @@ -15,6 +68,14 @@ export default function Dashboard() { const [genError, setGenError] = useState(null) const [historyKey, setHistoryKey] = useState(0) + const [week, setWeek] = useState(null) + const [month, setMonth] = useState(null) + const [overBudget, setOverBudget] = useState([]) + const [weekLabel, setWeekLabel] = useState('') + const [monthLabel, setMonthLabel] = useState('') + const [statsLoading, setStatsLoading] = useState(true) + const [statsError, setStatsError] = useState(null) + const load = useCallback(() => { getLatestInsight() .then(setInsight) @@ -22,11 +83,141 @@ export default function Dashboard() { .finally(() => setLoading(false)) }, []) + const loadStats = useCallback(async () => { + setStatsLoading(true); setStatsError(null) + try { + const todayStr = localStr(new Date()) + const yesterdayStr = addDaysStr(todayStr, -1) + + const weekFrom = mondayOf(new Date()) + const weekTo = addDaysStr(weekFrom, 6) + const prevWeekFrom = addDaysStr(weekFrom, -7) + const weekMonthKeys = Array.from(new Set([monthKeyOf(weekFrom), monthKeyOf(weekTo)])) + const weekDates = Array.from({ length: 7 }, (_, i) => addDaysStr(weekFrom, i)) + const weekElapsed = weekDates.filter(d => d <= yesterdayStr).length + + const monthKey = monthKeyOf(todayStr) + const monthFrom = `${monthKey}-01` + const dim = daysInMonthFor(monthFrom) + const monthTo = `${monthKey}-${String(dim).padStart(2, '0')}` + const monthDates = Array.from({ length: dim }, (_, i) => `${monthKey}-${String(i + 1).padStart(2, '0')}`) + const monthElapsed = monthDates.filter(d => d <= yesterdayStr).length + + const [weekActRes, weekSchedRes, weekSalesRes, monthActRes, monthSchedRes, monthSalesRes, budgetRes] = await Promise.all([ + getActuals(prevWeekFrom, weekTo), + getScheduled(weekFrom, weekTo), + getNetSales(weekFrom, weekTo), + getActuals(monthFrom, monthTo), + getScheduled(monthFrom, monthTo), + getNetSales(monthFrom, monthTo), + getBudgets(), + ]) + + const budgetsByMonth: Record = {} + for (const b of budgetRes.budgets) budgetsByMonth[b.month.slice(0, 7)] = b.budget_amount + + // ── Week ────────────────────────────────────────────────────── + const weekDeptRows = weekActRes.departments.map(dep => { + const actual = Object.entries(dep.days) + .filter(([d]) => d >= weekFrom && d <= yesterdayStr) + .reduce((s, [, v]) => s + v.cost, 0) + let forecast = actual + const schedDep = weekActRes.forecast_method === 'rota' + ? weekSchedRes.departments.find(s => s.department_id === dep.department_id) + : undefined + for (const dateStr of weekDates) { + if (dateStr <= yesterdayStr) continue + forecast += forecastDayCost(dateStr, dep.days, schedDep?.days, false).cost + } + return { actual, forecast } + }) + const weekActual = weekDeptRows.reduce((s, d) => s + d.actual, 0) + const weekForecast = weekDeptRows.reduce((s, d) => s + d.forecast, 0) + + const hasWeekBudget = weekMonthKeys.every(k => budgetsByMonth[k] != null) + const weekBudgetFull = hasWeekBudget + ? weekMonthKeys.reduce((sum, key) => { + const daysInThisMonth = weekDates.filter(d => monthKeyOf(d) === key).length + return sum + (budgetsByMonth[key] / daysInMonthFor(`${key}-01`)) * daysInThisMonth + }, 0) + : null + const weekBudgetActual = weekBudgetFull != null ? weekBudgetFull * (weekElapsed / 7) : null + + const weekNetSalesFull = weekSalesRes.days.reduce((s, d) => s + d.net_sales, 0) + + // ── Month ───────────────────────────────────────────────────── + const monthDeptRows = monthActRes.departments.map(dep => { + const actual = Object.entries(dep.days) + .filter(([d]) => d <= yesterdayStr) + .reduce((s, [, v]) => s + v.cost, 0) + let forecast = actual + const schedDep = monthActRes.forecast_method === 'rota' + ? monthSchedRes.departments.find(s => s.department_id === dep.department_id) + : undefined + for (const dateStr of monthDates) { + if (dateStr <= yesterdayStr) continue + forecast += forecastDayCost(dateStr, dep.days, schedDep?.days, false).cost + } + return { id: dep.department_id, name: dep.department_name, actual, forecast } + }) + const monthActual = monthDeptRows.reduce((s, d) => s + d.actual, 0) + const monthForecast = monthDeptRows.reduce((s, d) => s + d.forecast, 0) + + const monthBudgetFull = budgetsByMonth[monthKey] ?? null + const monthBudgetActual = monthBudgetFull != null ? monthBudgetFull * (monthElapsed / dim) : null + + const monthNetSalesFull = monthSalesRes.days.reduce((s, d) => s + d.net_sales, 0) + + // ── Budget-watch: departments forecast to finish the month over their budget share ── + const overBudgetList: DeptOverBudget[] = [] + if (monthBudgetFull != null) { + for (const dep of monthDeptRows) { + const share = monthActRes.dept_pcts[dep.id] ?? 0 + if (share <= 0) continue + const depBudget = monthBudgetFull * (share / 100) + if (depBudget <= 0) continue + const pct = (dep.forecast / depBudget) * 100 + if (pct > 100) overBudgetList.push({ name: dep.name, pct }) + } + overBudgetList.sort((a, b) => b.pct - a.pct) + } + + setWeek({ + actual: weekActual, + forecast: weekForecast, + budgetActual: weekBudgetActual, + budgetFull: weekBudgetFull, + pctBudgetActual: weekBudgetActual != null && weekBudgetActual > 0 ? (weekActual / weekBudgetActual) * 100 : null, + pctBudgetForecast: weekBudgetFull != null && weekBudgetFull > 0 ? (weekForecast / weekBudgetFull) * 100 : null, + varianceForecast: weekBudgetFull != null ? weekForecast - weekBudgetFull : null, + pctSalesForecast: weekNetSalesFull > 0 ? (weekForecast / weekNetSalesFull) * 100 : null, + }) + setMonth({ + actual: monthActual, + forecast: monthForecast, + budgetActual: monthBudgetActual, + budgetFull: monthBudgetFull, + pctBudgetActual: monthBudgetActual != null && monthBudgetActual > 0 ? (monthActual / monthBudgetActual) * 100 : null, + pctBudgetForecast: monthBudgetFull != null && monthBudgetFull > 0 ? (monthForecast / monthBudgetFull) * 100 : null, + varianceForecast: monthBudgetFull != null ? monthForecast - monthBudgetFull : null, + pctSalesForecast: monthNetSalesFull > 0 ? (monthForecast / monthNetSalesFull) * 100 : null, + }) + setOverBudget(overBudgetList.slice(0, 3)) + setWeekLabel(`${fmtDisplay(weekFrom)} – ${fmtDisplay(weekTo)}`) + setMonthLabel(new Date(monthFrom + 'T00:00:00').toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })) + } catch (e: unknown) { + setStatsError(e instanceof Error ? e.message : 'Failed to load dashboard stats') + } finally { + setStatsLoading(false) + } + }, []) + useEffect(() => { load() + loadStats() const id = setInterval(load, REFRESH_MS) return () => clearInterval(id) - }, [load]) + }, [load, loadStats]) const handleGenerate = async () => { setGenerating(true) @@ -42,6 +233,41 @@ export default function Dashboard() { } } + function renderPeriodGrid(label: string, actualLabel: string, forecastLabel: string, stats: PeriodStats) { + return ( + <> +
{label}
+
+
+
{actualLabel}
+
{fmtMoney(stats.actual)}
+ {stats.budgetActual != null &&
Budget to date {fmtMoney(stats.budgetActual)}
} +
+
+
{forecastLabel}
+
{fmtMoney(stats.forecast)}
+ {stats.budgetFull != null &&
Budget {fmtMoney(stats.budgetFull)}
} +
+
+
% Budget (Forecast)
+
+ {stats.pctBudgetForecast != null ? fmtDelta(stats.pctBudgetForecast) : '—'} +
+ {stats.varianceForecast != null && ( +
0 ? 'variance-over' : 'variance-under'}`}> + {stats.varianceForecast > 0 ? `+${fmtMoney(stats.varianceForecast)} over` : `${fmtMoney(Math.abs(stats.varianceForecast))} under`} +
+ )} +
+
+
% Net Sales (Forecast)
+
{stats.pctSalesForecast != null ? `${stats.pctSalesForecast.toFixed(1)}%` : '—'}
+
+
+ + ) + } + return (
@@ -52,6 +278,33 @@ export default function Dashboard() {
+ {statsError &&
{statsError}
} + {statsLoading &&
Loading dashboard…
} + + {!statsLoading && !statsError && week && renderPeriodGrid(`This Week (${weekLabel})`, 'Actual WTD', 'Forecast Full Week', week)} + {!statsLoading && !statsError && month && renderPeriodGrid(`This Month (${monthLabel})`, 'Actual MTD', 'Forecast EOM', month)} + + {!statsLoading && !statsError && month?.budgetFull != null && ( +
+
+ + Budget Watch +
+ {overBudget.length === 0 ? ( +
All departments forecast within this month's budget.
+ ) : ( +
+ {overBudget.map(d => ( +
+ {d.name} + {fmtDelta(d.pct)} +
+ ))} +
+ )} +
+ )} + {genError &&
{genError}
}