import { useState, useEffect, useCallback } from 'react' import { ChevronLeft, ChevronRight, Download } from 'lucide-react' import { BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, Cell, } from 'recharts' import { getActuals, getScheduled, getNetSales, getBudgets, downloadExport } from '../api' import type { DeptActuals, WageBudget } from '../types' function fmt(d: Date): string { return d.toISOString().slice(0, 10) } 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' } const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d'] export default function Monthly() { const today = new Date() const [year, setYear] = useState(today.getFullYear()) const [month, setMonth] = useState(today.getMonth() + 1) // 1-based const [depts, setDepts] = useState([]) const [scheduled, setScheduled] = useState>>({}) // dept_id → date → cost const [netSales, setNetSales] = useState(0) const [budget, setBudget] = useState(null) const [showOncosts, setShowOncosts] = useState(true) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) 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 todayStr = fmt(today) const isCurrentMonth = year === today.getFullYear() && month === today.getMonth() + 1 const load = useCallback(async () => { setLoading(true); setError(null) try { const [actRes, schRes, salesRes, budRes] = await Promise.all([ getActuals(fromStr, toStr), getScheduled(todayStr, toStr), getNetSales(fromStr, todayStr), getBudgets(), ]) setDepts(actRes.departments) setShowOncosts(actRes.show_oncosts) // Build scheduled map const schMap: Record> = {} for (const dep of schRes.departments) { schMap[dep.department_id] = {} for (const [date, val] of Object.entries(dep.days)) { schMap[dep.department_id][date] = val.cost } } setScheduled(schMap) setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0)) const bRow = budRes.budgets.find(b => 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, todayStr]) useEffect(() => { load() }, [load]) 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) } } // Build dept summary: actual MTD + forecast EOM const deptSummary = depts.map((dep, idx) => { const actualMTD = Object.entries(dep.days) .filter(([d]) => d <= todayStr) .reduce((s, [, v]) => s + v.cost, 0) // Forecast remaining days let forecastRem = 0 for (let day = 1; day <= dim; day++) { const dateStr = `${monthStr}-${String(day).padStart(2, '0')}` if (dateStr <= todayStr) continue // Priority: rota → prior week same DoW actual const rotaCost = schMap(dep.department_id, dateStr) if (rotaCost != null) { forecastRem += rotaCost continue } const priorDate = addDays(new Date(dateStr + 'T00:00:00'), -7) const priorStr = fmt(priorDate) 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) function schMap(deptId: string, date: string): number | null { return scheduled[deptId]?.[date] ?? null } const totalActual = deptSummary.reduce((s, d) => s + d.actual_mtd, 0) const totalForecast = deptSummary.reduce((s, d) => s + d.forecast_eom, 0) const pctBudget = budget != null && budget > 0 ? (totalForecast / budget) * 100 : null const pctSales = netSales > 0 ? (totalActual / netSales) * 100 : null const variance = budget != null ? totalForecast - budget : null // Build chart data: group by week const weeks: { label: string; actual: number; forecast: number; isPast: boolean }[] = [] for (let w = 0; w * 7 < dim; w++) { const wStart = w * 7 + 1 const wEnd = Math.min(wStart + 6, dim) const wEndDate = new Date(`${monthStr}-${String(wEnd).padStart(2, '0')}T00:00:00`) const isPast = wEndDate < today let actual = 0, forecast = 0 for (let day = wStart; day <= wEnd; day++) { const dateStr = `${monthStr}-${String(day).padStart(2, '0')}` const isActual = dateStr <= todayStr const total = deptSummary.reduce((s, dep) => { if (isActual) return s + (depts.find(d => d.department_id === dep.department_id)?.days[dateStr]?.cost ?? 0) const rota = schMap(dep.department_id, dateStr) if (rota != null) return s + rota const prior = depts.find(d => d.department_id === dep.department_id)?.days[fmt(addDays(new Date(dateStr + 'T00:00:00'), -7))]?.cost ?? 0 return s + prior }, 0) if (isActual) actual += total; else forecast += total } weeks.push({ label: `W${w + 1}`, actual, forecast, isPast }) } const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) return (

Monthly View

{monthLabel}
Actual MTD
{fmtMoney(totalActual)}
Forecast EOM
{fmtMoney(totalForecast)}
rota + prior-week actual
Monthly Budget
{budget != null ? fmtMoney(budget) : '—'}
% Budget (Forecast)
{pctBudget != null ? {pctBudget.toFixed(1)}% : '—'}
{variance != null && (
0 ? 'variance-over' : 'variance-under'}`}> {variance > 0 ? `+${fmtMoney(variance)} over` : `${fmtMoney(Math.abs(variance))} under`}
)}
Net Sales MTD
{fmtMoney(netSales)}
% Net Sales
{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}
{loading &&
Loading…
} {error &&
{error}
} {!loading && !error && ( <> {/* Stacked bar chart */}
Weekly Breakdown
`£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} /> fmtMoney(v)} /> {deptSummary.map(dep => ( {weeks.map((w, i) => ( ))} ))}
{/* Dept breakdown table */}
{deptSummary.map(dep => { const dp = budget != null && budget > 0 ? (dep.forecast_eom / budget) * 100 : null const dv = budget != null ? dep.forecast_eom - budget : null return ( ) })}
Department Actual MTD Forecast → EOM Budget % Budget Variance
{dep.department_name} {fmtMoney(dep.actual_mtd)} {fmtMoney(dep.forecast_eom)} {dp != null ? {dp.toFixed(1)}% : '—'} {dv != null && 0 ? 'variance-over' : 'variance-under'}>{dv > 0 ? '+' : ''}{fmtMoney(dv)}}
Total {fmtMoney(totalActual)} {fmtMoney(totalForecast)} {budget != null ? fmtMoney(budget) : '—'} {pctBudget != null ? {pctBudget.toFixed(1)}% : '—'} {variance != null && 0 ? 'variance-over' : 'variance-under'}>{variance > 0 ? '+' : ''}{fmtMoney(variance)}}
{showOncosts &&

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

}
)}
) }