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>
342 lines
15 KiB
TypeScript
342 lines
15 KiB
TypeScript
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>
|
||
)
|
||
}
|