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, getNetSales, getBudgets, getDeptDetail, downloadExport } from '../api' import type { DeptActuals, WageBudget, EmployeeDetail } from '../types' import { DeptDetailModal } from '../components/DeptDetailModal' 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 [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 [budget, setBudget] = useState(null) 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 // 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')}` const load = useCallback(async () => { setLoading(true); setError(null) try { // Net sales: full month — OTB/forecast for future dates, actuals for past dates const [actRes, salesRes, budRes, pyActRes] = await Promise.all([ getActuals(fromStr, toStr), getNetSales(fromStr, toStr), getBudgets(), getActuals(pyFromStr, pyToStr), ]) setDepts(actRes.departments) setShowOncosts(actRes.show_oncosts) setDeptPcts(actRes.dept_pcts) // 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)) // 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) } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed to load') } finally { setLoading(false) } }, [fromStr, toStr, pyFromStr, pyToStr, 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) { for (let day = 1; day <= dim; day++) { const dateStr = `${monthStr}-${String(day).padStart(2, '0')}` if (dateStr <= yesterdayStr) continue const priorStr = fmt(addDays(new Date(dateStr + 'T00:00:00'), -7)) const priorCost = dep.days[priorStr]?.cost if (priorCost != null) forecastRem += priorCost } } 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) // MTD row: pro-rata budget to yesterday const yDay = isCurrentMonth ? parseInt(yesterdayStr.slice(8)) : dim const budgetMTD = budget != null ? budget * yDay / dim : 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) let deptCost = 0 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 priorStr = fmt(addDays(new Date(dateStr + 'T00:00:00'), -7)) deptCost += srcDep?.days[priorStr]?.cost ?? 0 } } entry[dep.department_name] = deptCost } 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) : '—'}
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)}%
)}
Full month forecast
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 (OTB)
{pctSalesFull != null ? `${pctSalesFull.toFixed(1)}%` : '—'}
{pyWagesFull != null && pySalesFull > 0 && (
PY {((pyWagesFull / pySalesFull) * 100).toFixed(1)}%
)}
) : (
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)}%
)}
)} {loading &&
Loading…
} {error &&
{error}
} {!loading && !error && ( <>
Weekly Breakdown{isCurrentMonth ? ' (forecast shaded)' : ''}
`£${Math.round(Number(v) / 1000)}k`} tick={{ fontSize: 11 }} width={55} /> fmtMoney(Number(v))} /> {deptSummary.map(dep => ( {weeks.map((w, i) => ( ))} ))}
{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 return ( setModal({ deptId: dep.department_id, deptName: dep.department_name })}> {isCurrentMonth && } ) })} {isCurrentMonth && }
Department {isCurrentMonth ? 'Actual MTD' : 'Actual'}Forecast → EOM% of Total Budget % Budget Variance
{dep.department_name} {fmtMoney(dep.actual_mtd)}{fmtMoney(dep.forecast_eom)} {depOfTotal != null ? `${depOfTotal.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% {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)} /> )}
) }