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:
parent
e447d48e37
commit
d0cfc39012
2 changed files with 257 additions and 4 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue