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.
This commit is contained in:
jtricerolph 2026-07-25 13:25:28 +00:00
parent e447d48e37
commit d0cfc39012
2 changed files with 257 additions and 4 deletions

View file

@ -26,7 +26,7 @@ const NAV: { id: Page; label: string; icon: React.ElementType; cap?: string }[]
function Shell() {
const { user } = useAuth()
const [page, setPage] = useState<Page>('weekly')
const [page, setPage] = useState<Page>('dashboard')
const [menuOpen, setMenuOpen] = useState(false)
const visibleNav = NAV.filter(n => !n.cap || can(user, n.cap))

View file

@ -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<AIInsight | null>(null)
const [loading, setLoading] = useState(true)
@ -15,6 +68,14 @@ export default function Dashboard() {
const [genError, setGenError] = useState<string | null>(null)
const [historyKey, setHistoryKey] = useState(0)
const [week, setWeek] = useState<PeriodStats | null>(null)
const [month, setMonth] = useState<PeriodStats | null>(null)
const [overBudget, setOverBudget] = useState<DeptOverBudget[]>([])
const [weekLabel, setWeekLabel] = useState('')
const [monthLabel, setMonthLabel] = useState('')
const [statsLoading, setStatsLoading] = useState(true)
const [statsError, setStatsError] = useState<string | null>(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<string, number> = {}
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 (
<>
<div className="section-row-label">{label}</div>
<div className="summary-grid" style={{ marginBottom: 20 }}>
<div className="summary-card">
<div className="label">{actualLabel}</div>
<div className="value">{fmtMoney(stats.actual)}</div>
{stats.budgetActual != null && <div className="sub">Budget to date {fmtMoney(stats.budgetActual)}</div>}
</div>
<div className="summary-card">
<div className="label">{forecastLabel}</div>
<div className="value">{fmtMoney(stats.forecast)}</div>
{stats.budgetFull != null && <div className="sub">Budget {fmtMoney(stats.budgetFull)}</div>}
</div>
<div className="summary-card">
<div className="label">% Budget (Forecast)</div>
<div className="value" style={stats.pctBudgetForecast != null ? { color: budgetColour(stats.pctBudgetForecast) } : {}}>
{stats.pctBudgetForecast != null ? fmtDelta(stats.pctBudgetForecast) : '—'}
</div>
{stats.varianceForecast != null && (
<div className={`sub ${stats.varianceForecast > 0 ? 'variance-over' : 'variance-under'}`}>
{stats.varianceForecast > 0 ? `+${fmtMoney(stats.varianceForecast)} over` : `${fmtMoney(Math.abs(stats.varianceForecast))} under`}
</div>
)}
</div>
<div className="summary-card">
<div className="label">% Net Sales (Forecast)</div>
<div className="value">{stats.pctSalesForecast != null ? `${stats.pctSalesForecast.toFixed(1)}%` : '—'}</div>
</div>
</div>
</>
)
}
return (
<div>
<div className="page-header">
@ -52,6 +278,33 @@ export default function Dashboard() {
</button>
</div>
{statsError && <div className="state-center" style={{ color: '#dc2626', marginBottom: 16 }}>{statsError}</div>}
{statsLoading && <div className="state-center">Loading dashboard</div>}
{!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 && (
<div className="card">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<AlertTriangle size={16} strokeWidth={1.75} color={overBudget.length ? '#dc2626' : 'var(--app-primary)'} />
Budget Watch
</div>
{overBudget.length === 0 ? (
<div className="state-center">All departments forecast within this month's budget.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{overBudget.map(d => (
<div key={d.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13.5 }}>
<span>{d.name}</span>
<span className={`pct-badge ${pctClass(d.pct)}`}>{fmtDelta(d.pct)}</span>
</div>
))}
</div>
)}
</div>
)}
{genError && <div className="state-center" style={{ color: '#dc2626', marginBottom: 16 }}>{genError}</div>}
<div className="card">