import { useState, useEffect, useCallback } from 'react' import { ChevronLeft, ChevronRight, ChevronDown, Download } from 'lucide-react' import { getActuals, getNetSales, getBudgets, getDeptDetail, downloadExport } from '../api' import type { DeptActuals, WageBudget, EmployeeDetail } from '../types' import { DeptDetailModal } from '../components/DeptDetailModal' 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 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 pctClass(pct: number | null): string { if (pct == null) return '' if (pct <= 100) return 'pct-green' if (pct <= 110) return 'pct-amber' return 'pct-red' } function fmtDelta(pct: number): string { const d = pct - 100 return `${d >= 0 ? '+' : ''}${d.toFixed(1)}%` } export default function Weekly() { const [fromStr, setFromStr] = useState(() => mondayOf(new Date())) const toStr = addDaysStr(fromStr, 6) const todayStr = localStr(new Date()) const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1) const yesterdayStr = localStr(yesterday) const isCurrentWeek = fromStr === mondayOf(new Date()) const pyFromStr = addDaysStr(fromStr, -364) // Cut-off at yesterday: avoids partial clockins/open timesheets skewing today's figures const daysElapsed = isCurrentWeek ? Math.round((new Date(yesterdayStr + 'T00:00:00').getTime() - new Date(fromStr + 'T00:00:00').getTime()) / 86_400_000) : 6 const pyToStr = addDaysStr(fromStr, -364 + daysElapsed) const [depts, setDepts] = useState([]) const [netSales, setNetSales] = useState(0) const [pySales, setPySales] = useState(0) const [pyWages, setPyWages] = useState(null) const [pyDepts, setPyDepts] = useState<{id: string; name: string; cost: number}[]>([]) const [pyTableOpen, setPyTableOpen] = useState(false) const [budget, setBudget] = useState(null) const [monthlyBudg, setMonthlyBudg] = 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 load = useCallback(async () => { setLoading(true); setError(null) try { const [actRes, salesRes, budgetRes, 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) // Current week cut-off at yesterday to avoid partial clockins const isCurrentWk = fromStr === mondayOf(new Date()) setNetSales( isCurrentWk ? salesRes.days.filter(d => d.date <= yesterdayStr).reduce((s, d) => s + d.net_sales, 0) : salesRes.days.reduce((s, d) => s + d.net_sales, 0) ) setPySales( isCurrentWk ? salesRes.days.filter(d => d.date <= yesterdayStr).reduce((s, d) => s + d.py_sales, 0) : salesRes.days.reduce((s, d) => s + d.py_sales, 0) ) const pyDeptList = pyActRes.departments.map(dep => ({ id: dep.department_id, name: dep.department_name, cost: Object.values(dep.days).reduce((s, d) => s + d.cost, 0), })).filter(d => d.cost > 0).sort((a, b) => b.cost - a.cost) setPyDepts(pyDeptList) const pyTotal = pyDeptList.reduce((s, d) => s + d.cost, 0) setPyWages(pyTotal > 0 ? pyTotal : null) const d0 = new Date(fromStr + 'T00:00:00') const monthKey = `${d0.getFullYear()}-${String(d0.getMonth() + 1).padStart(2, '0')}-01` const bRow = (budgetRes.budgets as WageBudget[]).find(b => b.month === monthKey) if (bRow) { const dim = daysInMonthFor(fromStr) const cutoff = isCurrentWk ? yesterdayStr : toStr const effectiveTo = cutoff < toStr ? cutoff : toStr const weekDays = effectiveTo >= fromStr ? Math.round((new Date(effectiveTo + 'T00:00:00').getTime() - new Date(fromStr + 'T00:00:00').getTime()) / 86_400_000) + 1 : 7 const ratio = weekDays / dim setMonthlyBudg(bRow.budget_amount) setBudget(bRow.budget_amount * ratio) } else { setMonthlyBudg(null) setBudget(null) } } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed to load') } finally { setLoading(false) } }, [fromStr, toStr, todayStr, pyFromStr, pyToStr]) useEffect(() => { load() }, [load]) useEffect(() => { if (!modal) { setModalEmps(null); return } setModalLoad(true) getDeptDetail(modal.deptId, fromStr, isCurrentWeek ? todayStr : toStr) .then(r => setModalEmps(r.employees)) .catch(() => setModalEmps([])) .finally(() => setModalLoad(false)) }, [modal, fromStr, toStr, todayStr, isCurrentWeek]) const prev = () => setFromStr(s => addDaysStr(s, -7)) const next = () => setFromStr(s => addDaysStr(s, 7)) const deptWeekBudget = (deptId: string) => budget != null && monthlyBudg != null && (deptPcts[deptId] ?? 0) > 0 ? budget * (deptPcts[deptId] / 100) : null const deptTotals = depts.map(dep => ({ department_id: dep.department_id, department_name: dep.department_name, cost: Object.entries(dep.days) .filter(([d]) => !isCurrentWeek || d <= yesterdayStr) .reduce((s, [, v]) => s + v.cost, 0), })).sort((a, b) => b.cost - a.cost) const totalWages = deptTotals.reduce((s, d) => s + d.cost, 0) const pyTotalWages = pyDepts.reduce((s, d) => s + d.cost, 0) const pctBudget = budget != null && budget > 0 ? (totalWages / budget) * 100 : null const pctSales = netSales > 0 ? (totalWages / netSales) * 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)}%)` } const weekLabel = `${fmtDisplay(fromStr)} – ${fmtDisplay(toStr)} ${new Date(toStr + 'T00:00:00').getFullYear()}` return (

Weekly Wages

{weekLabel}
Total Wages
{fmtMoney(totalWages)}
{pyWages != null ?
{isCurrentWeek ? 'PY WTD' : 'PY'} {fmtMoney(pyWages)}{pyPct(totalWages, pyWages)}
:
{showOncosts ? 'incl. on-costs' : 'base cost'}
}
Pro-rata Budget
{budget != null ? fmtMoney(budget) : '—'}
proportion of monthly
% vs Budget
{pctBudget != null ? fmtDelta(pctBudget) : '—'}
Net Sales
{fmtMoney(netSales)}
{pySales > 0 &&
{isCurrentWeek ? 'PY WTD' : 'PY'} {fmtMoney(pySales)}{pyPct(netSales, pySales)}
}
% of Net Sales
{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}
{pyWages != null && pySales > 0 && (
{isCurrentWeek ? 'PY WTD' : 'PY'} {((pyWages / pySales) * 100).toFixed(1)}%
)}
{loading &&
Loading…
} {error &&
{error}
} {!loading && !error && ( <>
{deptTotals.map(dep => { const depBudg = deptWeekBudget(dep.department_id) const depPct = depBudg != null && depBudg > 0 ? (dep.cost / depBudg) * 100 : null const depOfTotal = totalWages > 0 ? (dep.cost / totalWages) * 100 : null const depSPct = netSales > 0 ? (dep.cost / netSales) * 100 : null return ( setModal({ deptId: dep.department_id, deptName: dep.department_name })}> ) })}
Department Wages % of Total Budget (pro-rata) % Budget % Net Sales
{dep.department_name} {fmtMoney(dep.cost)} {depOfTotal != null ? `${depOfTotal.toFixed(1)}%` : '—'} {depBudg != null ? fmtMoney(depBudg) : '—'} {depPct != null ? {fmtDelta(depPct)} : '—'} {depSPct != null ? `${depSPct.toFixed(1)}%` : '—'}
Total {fmtMoney(totalWages)} 100% {budget != null ? fmtMoney(budget) : '—'} {pctBudget != null ? {fmtDelta(pctBudget)} : '—'} {pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}
{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 = pySales > 0 ? (dep.cost / pySales) * 100 : null return ( ) })}
Department {isCurrentWeek ? 'PY WTD' : '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% {pySales > 0 ? `${((pyTotalWages / pySales) * 100).toFixed(1)}%` : '—'}
)}
)} )} {modal && ( setModal(null)} /> )}
) }