wages/frontend/src/pages/Weekly.tsx
jtricerolph 44a7caa937 Fair PYTD comparison for current week/month
Weekly: cap pyToStr at elapsed days (not full Mon-Sun), and filter
py_sales to past days only — both give WTD vs WTD. Labels show
'PY WTD' for current week, 'PY' for complete past weeks.

Monthly: pyToStr already capped at same day-of-month; salesTo already
capped at today. Labels now show 'PY MTD' for current month, 'PY' for
complete past months.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 10:11:09 +00:00

253 lines
11 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, Download } from 'lucide-react'
import { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
import type { DeptActuals, WageBudget } from '../types'
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'
}
export default function Weekly() {
const [fromStr, setFromStr] = useState<string>(() => mondayOf(new Date()))
const toStr = addDaysStr(fromStr, 6)
const todayStr = localStr(new Date())
const isCurrentWeek = fromStr === mondayOf(new Date())
const pyFromStr = addDaysStr(fromStr, -364)
// For current (partial) week cap PY at equivalent elapsed day — fair WTD comparison
const daysElapsed = isCurrentWeek
? Math.round((new Date(todayStr + '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 [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 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)
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
// For current week only sum PY sales for elapsed days (WTD = apples to apples)
const isCurrentWk = fromStr === mondayOf(new Date())
setPySales(
isCurrentWk
? salesRes.days.filter(d => d.date <= todayStr).reduce((s, d) => s + d.py_sales, 0)
: salesRes.days.reduce((s, d) => s + d.py_sales, 0)
)
const pyTotal = pyActRes.departments.reduce(
(s, dep) => s + Object.values(dep.days).reduce((ds, d) => ds + d.cost, 0), 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 effectiveTo = todayStr < toStr ? todayStr : toStr
const effectiveFrom = fromStr > todayStr ? todayStr : fromStr
const weekDays = effectiveTo >= effectiveFrom
? Math.round((new Date(effectiveTo + 'T00:00:00').getTime() - new Date(effectiveFrom + '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])
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.values(dep.days).reduce((s, d) => s + d.cost, 0),
})).sort((a, b) => b.cost - a.cost)
const totalWages = deptTotals.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">
{pctBudget != null
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
: '—'}
</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>
</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}>
<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)}`}>{depPct.toFixed(1)}%</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)}`}>{pctBudget.toFixed(1)}%</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>
)}
</div>
)
}