import { useState, useEffect, useCallback } from 'react' import { ChevronLeft, ChevronRight, ChevronDown, Download } from 'lucide-react' import { BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, Cell, } from 'recharts' import { getActuals, getScheduled, getNetSales, getBudgets, getDeptDetail, downloadExport } from '../api' import type { DeptActuals, DeptScheduled, ForecastMethod, WageBudget, EmployeeDetail } from '../types' import { DeptDetailModal } from '../components/DeptDetailModal' import { forecastDayCost } from '../lib/forecast' function fmt(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } function addDays(d: Date, n: number): Date { const r = new Date(d); r.setDate(r.getDate() + n); return r } function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() } function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` } function pctClass(pct: number): string { return pct <= 100 ? 'pct-green' : pct <= 110 ? 'pct-amber' : 'pct-red' } function fmtDelta(pct: number): string { const d = pct - 100; return `${d >= 0 ? '+' : ''}${d.toFixed(1)}%` } function fmtDisplay(dateStr: string): string { return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) } function budgetColour(pct: number): string { return pct <= 100 ? 'var(--app-primary)' : pct <= 110 ? '#b45309' : '#dc2626' } const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d'] export default function Monthly() { const today = new Date() const todayStr = fmt(today) const yesterdayStr = fmt(addDays(today, -1)) const todayYear = parseInt(todayStr.slice(0, 4)) const todayMonth = parseInt(todayStr.slice(5, 7)) const [year, setYear] = useState(todayYear) const [month, setMonth] = useState(todayMonth) const [depts, setDepts] = useState([]) const [scheduled, setScheduled] = useState([]) const [forecastMethod, setForecastMethod] = useState('repeat') const [includeUnpublished, setIncludeUnpublished] = useState(false) const [netSalesMTD, setNetSalesMTD] = useState(0) const [netSalesFull, setNetSalesFull] = useState(0) const [pySalesMTD, setPySalesMTD] = useState(0) const [pySalesFull, setPySalesFull] = useState(0) const [pyWages, setPyWages] = useState(null) const [pyWagesFull, setPyWagesFull] = useState(null) const [pyDepts, setPyDepts] = useState<{id: string; name: string; cost: number; costFull: number}[]>([]) const [pyTableOpen, setPyTableOpen] = useState(false) const [prevMonthDepts, setPrevMonthDepts] = useState<{id: string; cost: number}[]>([]) const [budget, setBudget] = useState(null) const [pySalesByDay, setPySalesByDay] = useState>({}) const [deptPcts, setDeptPcts] = useState>({}) const [showOncosts, setShowOncosts] = useState(true) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [modal, setModal] = useState<{ deptId: string; deptName: string } | null>(null) const [modalEmps, setModalEmps] = useState(null) const [modalLoad, setModalLoad] = useState(false) const dim = daysInMonth(year, month) const monthStr = `${year}-${String(month).padStart(2, '0')}` const fromStr = `${monthStr}-01` const toStr = `${monthStr}-${String(dim).padStart(2, '0')}` const isCurrentMonth = year === todayYear && month === todayMonth // forecastDayCost hops back up to 42 days looking for a same-weekday actual — fetch that // much extra history before the month start so early-month dates can actually find it, // instead of falling through to the 'none' tier once rota runs out. actualMTD/etc. below // still filter to >= fromStr, so this extra history is only ever used for the hop fallback. const actualsFromStr = fmt(addDays(new Date(fromStr + 'T00:00:00'), -42)) // Prior year: always fetch full month (derive both MTD and full totals from one call) const pyDim = daysInMonth(year - 1, month) const pyFromStr = `${year - 1}-${String(month).padStart(2, '0')}-01` const pyToStr = `${year - 1}-${String(month).padStart(2, '0')}-${String(pyDim).padStart(2, '0')}` // Previous month: always fetch full month for comparison const pmYear = month === 1 ? year - 1 : year const pmMonth = month === 1 ? 12 : month - 1 const pmDim = daysInMonth(pmYear, pmMonth) const pmFromStr = `${pmYear}-${String(pmMonth).padStart(2, '0')}-01` const pmToStr = `${pmYear}-${String(pmMonth).padStart(2, '0')}-${String(pmDim).padStart(2, '0')}` const load = useCallback(async () => { setLoading(true); setError(null) try { // Net sales: full month — OTB/forecast for future dates, actuals for past dates const [actRes, schedRes, salesRes, budRes, pyActRes, pmActRes] = await Promise.all([ getActuals(actualsFromStr, toStr), getScheduled(fromStr, toStr), getNetSales(fromStr, toStr), getBudgets(), getActuals(pyFromStr, pyToStr), getActuals(pmFromStr, pmToStr), ]) setDepts(actRes.departments) setScheduled(schedRes.departments) setShowOncosts(actRes.show_oncosts) setDeptPcts(actRes.dept_pcts) setForecastMethod(actRes.forecast_method) // Cut-off for MTD = yesterday (avoid partial clockins today) const cutoff = isCurrentMonth ? yesterdayStr : toStr setNetSalesMTD(salesRes.days.filter(d => d.date <= cutoff).reduce((s, d) => s + d.net_sales, 0)) setNetSalesFull(salesRes.days.reduce((s, d) => s + d.net_sales, 0)) setPySalesMTD(salesRes.days.filter(d => d.date <= cutoff).reduce((s, d) => s + d.py_sales, 0)) setPySalesFull(salesRes.days.reduce((s, d) => s + d.py_sales, 0)) const pyMap: Record = {} for (const d of salesRes.days) pyMap[d.date] = d.py_sales setPySalesByDay(pyMap) // PY MTD: same day number as yesterday in PY (or full month for past months) const yDay = parseInt(yesterdayStr.slice(8)) const pyMtdLimit = isCurrentMonth ? `${year - 1}-${String(month).padStart(2, '0')}-${String(Math.min(yDay, pyDim)).padStart(2, '0')}` : pyToStr const pyDeptList = pyActRes.departments.map(dep => { const mtdCost = Object.entries(dep.days).filter(([d]) => d <= pyMtdLimit).reduce((s, [, v]) => s + v.cost, 0) const fullCost = Object.values(dep.days).reduce((s, v) => s + v.cost, 0) return { id: dep.department_id, name: dep.department_name, cost: mtdCost, costFull: fullCost } }).filter(d => d.cost > 0 || d.costFull > 0).sort((a, b) => b.costFull - a.costFull) setPyDepts(pyDeptList) const pyTotalMTD = pyDeptList.reduce((s, d) => s + d.cost, 0) const pyTotalFull = pyDeptList.reduce((s, d) => s + d.costFull, 0) setPyWages(pyTotalMTD > 0 ? pyTotalMTD : null) setPyWagesFull(pyTotalFull > 0 ? pyTotalFull : null) const bRow = budRes.budgets.find((b: WageBudget) => b.month === fromStr) setBudget(bRow ? bRow.budget_amount : null) // Previous month per-dept totals (full month) for vs Prev column const pmDeptList = pmActRes.departments.map(dep => ({ id: dep.department_id, cost: Object.values(dep.days).reduce((s, v) => s + v.cost, 0), })) setPrevMonthDepts(pmDeptList) } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed to load') } finally { setLoading(false) } }, [fromStr, toStr, actualsFromStr, pyFromStr, pyToStr, pmFromStr, pmToStr, isCurrentMonth, yesterdayStr, year, month, pyDim]) useEffect(() => { load() }, [load]) useEffect(() => { if (!modal) { setModalEmps(null); return } setModalLoad(true) getDeptDetail(modal.deptId, fromStr, isCurrentMonth ? yesterdayStr : toStr) .then(r => setModalEmps(r.employees)) .catch(() => setModalEmps([])) .finally(() => setModalLoad(false)) }, [modal, fromStr, toStr, yesterdayStr, isCurrentMonth]) const prev = () => { if (month === 1) { setYear(y => y - 1); setMonth(12) } else { setMonth(m => m - 1) } } const next = () => { if (month === 12) { setYear(y => y + 1); setMonth(1) } else { setMonth(m => m + 1) } } // Actuals cut off at yesterday; remaining days forecast via prior-week same-day actual const cutoff = isCurrentMonth ? yesterdayStr : toStr const deptSummary = depts.map((dep, idx) => { const actualMTD = Object.entries(dep.days) .filter(([d]) => d >= fromStr && d <= cutoff) .reduce((s, [, v]) => s + v.cost, 0) let forecastRem = 0 if (isCurrentMonth) { // 'rota' method: prefer published rota (or +draft rota if includeUnpublished) for days // that have it, falling back to the repeat-pattern otherwise — see forecastDayCost. // 'repeat' (default): unchanged, no scheduled data passed in so it always repeats. const schedDep = forecastMethod === 'rota' ? scheduled.find(s => s.department_id === dep.department_id) : undefined for (let day = 1; day <= dim; day++) { const dateStr = `${monthStr}-${String(day).padStart(2, '0')}` if (dateStr <= yesterdayStr) continue forecastRem += forecastDayCost(dateStr, dep.days, schedDep?.days, includeUnpublished).cost } } return { department_id: dep.department_id, department_name: dep.department_name, actual_mtd: actualMTD, forecast_eom: actualMTD + forecastRem, color: DEPT_COLORS[idx % DEPT_COLORS.length], } }).sort((a, b) => b.forecast_eom - a.forecast_eom) const totalActual = deptSummary.reduce((s, d) => s + d.actual_mtd, 0) const totalForecast = deptSummary.reduce((s, d) => s + d.forecast_eom, 0) const pyTotalWages = pyDepts.reduce((s, d) => s + d.cost, 0) const pyTotalWagesFull = pyDepts.reduce((s, d) => s + d.costFull, 0) const prevMonthTotal = prevMonthDepts.reduce((s, d) => s + d.cost, 0) // MTD row: budget split across the month by each day's share of PY (DOW-matched) sales, // so a weekend-heavy budget lands more on weekends rather than an even calendar split. // Falls back to flat day-count proration when PY sales data isn't available. const yDay = isCurrentMonth ? parseInt(yesterdayStr.slice(8)) : dim const monthPyTotal = Object.values(pySalesByDay).reduce((s, v) => s + v, 0) const isBudgetWeighted = monthPyTotal > 0 const mtdFrac = isBudgetWeighted ? Object.entries(pySalesByDay).filter(([d]) => d <= cutoff).reduce((s, [, v]) => s + v, 0) / monthPyTotal : yDay / dim const budgetMTD = budget != null ? budget * mtdFrac : null const varianceMTD = budgetMTD != null ? totalActual - budgetMTD : null const pctBudgMTD = budgetMTD != null && budgetMTD > 0 ? (totalActual / budgetMTD) * 100 : null const pctSalesMTD = netSalesMTD > 0 ? (totalActual / netSalesMTD) * 100 : null // Full month row const varianceFull = budget != null ? totalForecast - budget : null const pctBudgFull = budget != null && budget > 0 ? (totalForecast / budget) * 100 : null const pctSalesFull = netSalesFull > 0 ? (totalForecast / netSalesFull) * 100 : null function pyPct(current: number, py: number): string { if (py <= 0 || current <= 0) return '' const p = ((current - py) / py) * 100 return ` (${p >= 0 ? '+' : ''}${p.toFixed(1)}%)` } // Chart: past weeks solid, current/future weeks lighter type WeekEntry = { label: string; isPast: boolean; [dept: string]: number | boolean | string } const weeks: WeekEntry[] = [] for (let w = 0; w * 7 < dim; w++) { const wStart = w * 7 + 1 const wEnd = Math.min(wStart + 6, dim) const wEndStr = `${monthStr}-${String(wEnd).padStart(2, '0')}` const isPast = wEndStr <= yesterdayStr const entry: WeekEntry = { label: `W${w + 1}`, isPast } for (const dep of deptSummary) { const srcDep = depts.find(d => d.department_id === dep.department_id) const schedDep = forecastMethod === 'rota' ? scheduled.find(s => s.department_id === dep.department_id) : undefined let deptCost = 0 let weekTier: 'rota' | 'repeat' = 'rota' for (let day = wStart; day <= wEnd; day++) { const dateStr = `${monthStr}-${String(day).padStart(2, '0')}` if (dateStr <= cutoff) { deptCost += srcDep?.days[dateStr]?.cost ?? 0 } else { const { cost, tier } = forecastDayCost(dateStr, srcDep?.days, schedDep?.days, includeUnpublished) deptCost += cost if (tier === 'actual' || tier === 'none') weekTier = 'repeat' } } entry[dep.department_name] = deptCost entry[`${dep.department_name}__tier`] = weekTier } weeks.push(entry) } const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) return (

Monthly View

{monthLabel}
{/* ── Summary cards ─────────────────────────────────────────── */} {isCurrentMonth ? ( <>
Month to {fmtDisplay(yesterdayStr)}
Actual MTD
{fmtMoney(totalActual)}
{pyWages != null &&
PY MTD {fmtMoney(pyWages)}{pyPct(totalActual, pyWages)}
}
Budget MTD
{budgetMTD != null ? fmtMoney(budgetMTD) : '—'}
{isBudgetWeighted ? 'sales-weighted (PY DOW)' : `pro-rata ${yDay}/${dim} days`}
% Budget MTD
{pctBudgMTD != null ? fmtDelta(pctBudgMTD) : '—'}
{varianceMTD != null && (
0 ? 'variance-over' : 'variance-under'}`}> {varianceMTD > 0 ? `+${fmtMoney(varianceMTD)} over` : `${fmtMoney(Math.abs(varianceMTD))} under`}
)}
% Net Sales MTD
{pctSalesMTD != null ? `${pctSalesMTD.toFixed(1)}%` : '—'}
{pyWages != null && pySalesMTD > 0 && (
PY MTD {((pyWages / pySalesMTD) * 100).toFixed(1)}%
)}
Net Sales MTD
{fmtMoney(netSalesMTD)}
{pySalesMTD > 0 &&
PY {fmtMoney(pySalesMTD)}{pyPct(netSalesMTD, pySalesMTD)}
}
Full month forecast {forecastMethod === 'rota' && ( )}
Forecast EOM
{fmtMoney(totalForecast)}
{pyWagesFull != null &&
PY {fmtMoney(pyWagesFull)}{pyPct(totalForecast, pyWagesFull)}
}
Monthly Budget
{budget != null ? fmtMoney(budget) : '—'}
% Budget (Forecast)
{pctBudgFull != null ? fmtDelta(pctBudgFull) : '—'}
{varianceFull != null && (
0 ? 'variance-over' : 'variance-under'}`}> {varianceFull > 0 ? `+${fmtMoney(varianceFull)} over` : `${fmtMoney(Math.abs(varianceFull))} under`}
)}
% Net Sales (Forecast)
{pctSalesFull != null ? `${pctSalesFull.toFixed(1)}%` : '—'}
{pyWagesFull != null && pySalesFull > 0 && (
PY {((pyWagesFull / pySalesFull) * 100).toFixed(1)}%
)}
Net Sales (Forecast)
{fmtMoney(netSalesFull)}
{pySalesFull > 0 &&
PY {fmtMoney(pySalesFull)}{pyPct(netSalesFull, pySalesFull)}
}
) : (
Actual
{fmtMoney(totalActual)}
{pyWagesFull != null &&
PY {fmtMoney(pyWagesFull)}{pyPct(totalActual, pyWagesFull)}
}
Monthly Budget
{budget != null ? fmtMoney(budget) : '—'}
% Budget
{pctBudgFull != null ? fmtDelta(pctBudgFull) : '—'}
{varianceFull != null && (
0 ? 'variance-over' : 'variance-under'}`}> {varianceFull > 0 ? `+${fmtMoney(varianceFull)} over` : `${fmtMoney(Math.abs(varianceFull))} under`}
)}
% Net Sales
{pctSalesFull != null ? `${pctSalesFull.toFixed(1)}%` : '—'}
{pyWagesFull != null && pySalesFull > 0 && (
PY {((pyWagesFull / pySalesFull) * 100).toFixed(1)}%
)}
Net Sales
{fmtMoney(netSalesFull)}
{pySalesFull > 0 &&
PY {fmtMoney(pySalesFull)}{pyPct(netSalesFull, pySalesFull)}
}
)} {loading &&
Loading…
} {error &&
{error}
} {!loading && !error && ( <>
Weekly Breakdown{isCurrentMonth ? (forecastMethod === 'rota' ? ' (rota-informed forecast shaded, repeat-pattern lighter)' : ' (forecast shaded)') : ''}
`£${Math.round(Number(v) / 1000)}k`} tick={{ fontSize: 11 }} width={55} /> fmtMoney(Number(v))} /> -deptSummary.findIndex(dep => dep.department_name === item.dataKey)} /> {deptSummary.map(dep => ( {weeks.map((w, i) => { const opacity = w.isPast ? 1 : (w[`${dep.department_name}__tier`] === 'rota' ? 0.7 : 0.45) return })} ))}
{isCurrentMonth && } {deptSummary.map(dep => { const displayCost = isCurrentMonth ? dep.forecast_eom : dep.actual_mtd const totalDisplay = isCurrentMonth ? totalForecast : totalActual const depBudg = budget != null && (deptPcts[dep.department_id] ?? 0) > 0 ? budget * (deptPcts[dep.department_id] / 100) : null const dp = depBudg != null && depBudg > 0 ? (displayCost / depBudg) * 100 : null const dv = depBudg != null ? displayCost - depBudg : null const depOfTotal = totalDisplay > 0 ? (displayCost / totalDisplay) * 100 : null // PY: compare actual_mtd vs PY MTD for current month; actual vs PY full for past months const pyDept = pyDepts.find(p => p.id === dep.department_id) const pyCost = pyDept ? (isCurrentMonth ? pyDept.cost : pyDept.costFull) : null const pyDelta = pyCost != null && pyCost > 0 ? ((dep.actual_mtd - pyCost) / pyCost) * 100 : null // Prev month: compare actual_mtd vs prev month full total per dept const pmDept = prevMonthDepts.find(p => p.id === dep.department_id) const pmCost = pmDept && pmDept.cost > 0 ? pmDept.cost : null const pmDelta = pmCost != null ? ((dep.actual_mtd - pmCost) / pmCost) * 100 : null return ( setModal({ deptId: dep.department_id, deptName: dep.department_name })}> {isCurrentMonth && } ) })} {isCurrentMonth && }
Department {isCurrentMonth ? 'Actual MTD' : 'Actual'}Forecast → EOM% of Total vs PY vs Prev Budget % Budget Variance
{dep.department_name} {fmtMoney(dep.actual_mtd)}{fmtMoney(dep.forecast_eom)} {depOfTotal != null ? `${depOfTotal.toFixed(1)}%` : '—'} {pyDelta != null ? 0 ? 'variance-over' : 'variance-under'}>{pyDelta > 0 ? '+' : ''}{pyDelta.toFixed(1)}% : '—'} {pmDelta != null ? 0 ? 'variance-over' : 'variance-under'}>{pmDelta > 0 ? '+' : ''}{pmDelta.toFixed(1)}% : '—'} {depBudg != null ? fmtMoney(depBudg) : '—'} {dp != null ? {fmtDelta(dp)} : '—'} {dv != null && 0 ? 'variance-over' : 'variance-under'}>{dv > 0 ? '+' : ''}{fmtMoney(dv)}}
Total {fmtMoney(totalActual)}{fmtMoney(totalForecast)}100% {(() => { const pyBase = isCurrentMonth ? pyTotalWages : pyTotalWagesFull if (pyBase <= 0) return '—' const d = ((totalActual - pyBase) / pyBase) * 100 return 0 ? 'variance-over' : 'variance-under'}>{d > 0 ? '+' : ''}{d.toFixed(1)}% })()} {(() => { if (prevMonthTotal <= 0) return '—' const d = ((totalActual - prevMonthTotal) / prevMonthTotal) * 100 return 0 ? 'variance-over' : 'variance-under'}>{d > 0 ? '+' : ''}{d.toFixed(1)}% })()} {budget != null ? fmtMoney(budget) : '—'} {pctBudgFull != null ? {fmtDelta(pctBudgFull)} : '—'} {varianceFull != null && 0 ? 'variance-over' : 'variance-under'}>{varianceFull > 0 ? '+' : ''}{fmtMoney(varianceFull)}}
{showOncosts &&

Includes estimated employer on-costs. Final payroll figures are in Sage.

}
{pyDepts.length > 0 && (
{pyTableOpen && ( {pyDepts.map(dep => { const ofTotal = pyTotalWages > 0 ? (dep.cost / pyTotalWages) * 100 : null const ofSales = pySalesMTD > 0 ? (dep.cost / pySalesMTD) * 100 : null return ( ) })}
Department {isCurrentMonth ? 'PY MTD' : 'PY'} Wages % of Total % Net Sales
{dep.name} {fmtMoney(dep.cost)} {ofTotal != null ? `${ofTotal.toFixed(1)}%` : '—'} {ofSales != null ? `${ofSales.toFixed(1)}%` : '—'}
Total {fmtMoney(pyTotalWages)} 100% {pySalesMTD > 0 ? `${((pyTotalWages / pySalesMTD) * 100).toFixed(1)}%` : '—'}
)}
)} )} {modal && ( setModal(null)} /> )}
) }