wages/frontend/src/pages/Weekly.tsx
jtricerolph 5b243b3ef8 Yesterday cutoff for actuals; two-row monthly summary cards
Both Weekly and Monthly now cut off actuals at yesterday (not today) to avoid
partial clockins and open timesheets skewing the WTD/MTD figures and % net sales.
PY comparison periods also cap at the equivalent yesterday for fair comparison.

Monthly rota forecast removed — all future days use prior-week same-day actuals
from yesterday back, which is more reliable than partially-published future rotas.

Monthly summary cards split into two rows for current month:
  Row 1 (MTD to yesterday): Actual MTD · Budget pro-rata · % Budget MTD · % Net Sales MTD
  Row 2 (Full month):       Forecast EOM · Monthly Budget · % Budget (Forecast) · % Net Sales (OTB)
Net sales fetched for full month so row 2 % net sales uses OTB forecast revenue.
Past months show one row (full month actuals only).

Weekly: budget pro-rata now counts days Monday to yesterday for current week;
dept totals filtered to yesterday for current week.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 12:36:42 +00:00

342 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string>(() => 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<DeptActuals[]>([])
const [netSales, setNetSales] = useState(0)
const [pySales, setPySales] = useState(0)
const [pyWages, setPyWages] = useState<number | null>(null)
const [pyDepts, setPyDepts] = useState<{id: string; name: string; cost: number}[]>([])
const [pyTableOpen, setPyTableOpen] = useState(false)
const [budget, setBudget] = useState<number | null>(null)
const [monthlyBudg, setMonthlyBudg] = useState<number | null>(null)
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
const [showOncosts, setShowOncosts] = useState(true)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [modal, setModal] = useState<{ deptId: string; deptName: string } | null>(null)
const [modalEmps, setModalEmps] = useState<EmployeeDetail[] | null>(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 (
<div>
<div className="page-header">
<h1 className="page-title">Weekly Wages</h1>
<button className="btn btn-secondary" onClick={() => downloadExport('weekly', fromStr, toStr)}>
<Download size={14} strokeWidth={1.75} /> CSV
</button>
</div>
<div className="period-nav" style={{ marginBottom: 20 }}>
<button className="btn btn-secondary" onClick={prev}><ChevronLeft size={16} strokeWidth={1.75} /></button>
<span className="period-label">{weekLabel}</span>
<button className="btn btn-secondary" onClick={next} disabled={isCurrentWeek}><ChevronRight size={16} strokeWidth={1.75} /></button>
</div>
<div className="summary-grid">
<div className="summary-card">
<div className="label">Total Wages</div>
<div className="value">{fmtMoney(totalWages)}</div>
{pyWages != null
? <div className="sub">{isCurrentWeek ? 'PY WTD' : 'PY'} {fmtMoney(pyWages)}{pyPct(totalWages, pyWages)}</div>
: <div className="sub">{showOncosts ? 'incl. on-costs' : 'base cost'}</div>}
</div>
<div className="summary-card">
<div className="label">Pro-rata Budget</div>
<div className="value">{budget != null ? fmtMoney(budget) : '—'}</div>
<div className="sub">proportion of monthly</div>
</div>
<div className="summary-card">
<div className="label">% vs Budget</div>
<div className="value" style={pctBudget != null ? { color: pctBudget <= 100 ? 'var(--app-primary)' : pctBudget <= 110 ? '#b45309' : '#dc2626' } : {}}>
{pctBudget != null ? fmtDelta(pctBudget) : '—'}
</div>
</div>
<div className="summary-card">
<div className="label">Net Sales</div>
<div className="value">{fmtMoney(netSales)}</div>
{pySales > 0 && <div className="sub">{isCurrentWeek ? 'PY WTD' : 'PY'} {fmtMoney(pySales)}{pyPct(netSales, pySales)}</div>}
</div>
<div className="summary-card">
<div className="label">% of Net Sales</div>
<div className="value">{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}</div>
{pyWages != null && pySales > 0 && (
<div className="sub">{isCurrentWeek ? 'PY WTD' : 'PY'} {((pyWages / pySales) * 100).toFixed(1)}%</div>
)}
</div>
</div>
{loading && <div className="state-center">Loading</div>}
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
{!loading && !error && (
<>
<div className="card">
<table className="data-table">
<thead>
<tr>
<th>Department</th>
<th className="right">Wages</th>
<th className="right">% of Total</th>
<th className="right">Budget (pro-rata)</th>
<th className="right">% Budget</th>
<th className="right">% Net Sales</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={dep.department_name} style={{ cursor: 'pointer' }}
onClick={() => setModal({ deptId: dep.department_id, deptName: dep.department_name })}>
<td>{dep.department_name}</td>
<td className="right">{fmtMoney(dep.cost)}</td>
<td className="right" style={{ color: 'var(--text-muted)' }}>
{depOfTotal != null ? `${depOfTotal.toFixed(1)}%` : '—'}
</td>
<td className="right">{depBudg != null ? fmtMoney(depBudg) : '—'}</td>
<td className="right">
{depPct != null
? <span className={`pct-badge ${pctClass(depPct)}`}>{fmtDelta(depPct)}</span>
: '—'}
</td>
<td className="right">{depSPct != null ? `${depSPct.toFixed(1)}%` : '—'}</td>
</tr>
)
})}
<tr className="total-row">
<td>Total</td>
<td className="right">{fmtMoney(totalWages)}</td>
<td className="right">100%</td>
<td className="right">{budget != null ? fmtMoney(budget) : '—'}</td>
<td className="right">
{pctBudget != null
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{fmtDelta(pctBudget)}</span>
: '—'}
</td>
<td className="right">{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}</td>
</tr>
</tbody>
</table>
{showOncosts && (
<p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>
)}
</div>
{pyDepts.length > 0 && (
<div className="card">
<button className="py-collapse-toggle" onClick={() => setPyTableOpen(o => !o)}>
{pyTableOpen
? <ChevronDown size={14} strokeWidth={1.75} />
: <ChevronRight size={14} strokeWidth={1.75} />}
{isCurrentWeek ? 'PY WTD' : 'PY'} Dept Breakdown ({fmtDisplay(pyFromStr)} {fmtDisplay(pyToStr)})
</button>
{pyTableOpen && (
<table className="data-table" style={{ marginTop: 12 }}>
<thead>
<tr>
<th>Department</th>
<th className="right">{isCurrentWeek ? 'PY WTD' : 'PY'} Wages</th>
<th className="right">% of Total</th>
<th className="right">% Net Sales</th>
</tr>
</thead>
<tbody>
{pyDepts.map(dep => {
const ofTotal = pyTotalWages > 0 ? (dep.cost / pyTotalWages) * 100 : null
const ofSales = pySales > 0 ? (dep.cost / pySales) * 100 : null
return (
<tr key={dep.id}>
<td>{dep.name}</td>
<td className="right">{fmtMoney(dep.cost)}</td>
<td className="right" style={{ color: 'var(--text-muted)' }}>{ofTotal != null ? `${ofTotal.toFixed(1)}%` : '—'}</td>
<td className="right">{ofSales != null ? `${ofSales.toFixed(1)}%` : '—'}</td>
</tr>
)
})}
<tr className="total-row">
<td>Total</td>
<td className="right">{fmtMoney(pyTotalWages)}</td>
<td className="right">100%</td>
<td className="right">{pySales > 0 ? `${((pyTotalWages / pySales) * 100).toFixed(1)}%` : '—'}</td>
</tr>
</tbody>
</table>
)}
</div>
)}
</>
)}
{modal && (
<DeptDetailModal
deptName={modal.deptName}
period={`${fmtDisplay(fromStr)} ${fmtDisplay(isCurrentWeek ? todayStr : toStr)}`}
employees={modalEmps}
loading={modalLoad}
onClose={() => setModal(null)}
/>
)}
</div>
)
}