Initial scaffold: wages app
Full wage cost reporting app — weekly/monthly views, rolling 12-week/12-month history, budget management, Workforce API sync with SSE backfill, net sales via forecasting public API, department filter, CSV export. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
2e0592eb90
37 changed files with 3078 additions and 0 deletions
206
frontend/src/pages/Weekly.tsx
Normal file
206
frontend/src/pages/Weekly.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
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 startOfWeek(d: Date): Date {
|
||||
const day = d.getDay()
|
||||
const diff = (day === 0 ? -6 : 1 - day) // Mon = start
|
||||
const r = new Date(d)
|
||||
r.setDate(d.getDate() + diff)
|
||||
r.setHours(0, 0, 0, 0)
|
||||
return r
|
||||
}
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d)
|
||||
r.setDate(r.getDate() + n)
|
||||
return r
|
||||
}
|
||||
|
||||
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
|
||||
function fmtMoney(n: number): string { return `£${n.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}` }
|
||||
function daysInMonth(date: Date): number { return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate() }
|
||||
|
||||
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 [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date()))
|
||||
const [depts, setDepts] = useState<DeptActuals[]>([])
|
||||
const [netSales, setNetSales] = useState(0)
|
||||
const [pySales, setPySales] = useState(0)
|
||||
const [budget, setBudget] = useState<number | null>(null)
|
||||
const [showOncosts, setShowOncosts] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const weekEnd = addDays(weekStart, 6)
|
||||
const fromStr = fmt(weekStart)
|
||||
const toStr = fmt(weekEnd)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const [actRes, salesRes, budgetRes] = await Promise.all([
|
||||
getActuals(fromStr, toStr),
|
||||
getNetSales(fromStr, toStr),
|
||||
getBudgets(),
|
||||
])
|
||||
setDepts(actRes.departments)
|
||||
setShowOncosts(actRes.show_oncosts)
|
||||
|
||||
const totalSales = salesRes.days.reduce((s, d) => s + d.net_sales, 0)
|
||||
const totalPY = salesRes.days.reduce((s, d) => s + d.py_sales, 0)
|
||||
setNetSales(totalSales)
|
||||
setPySales(totalPY)
|
||||
|
||||
// Find budget for the month of weekStart
|
||||
const monthKey = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-01`
|
||||
const bRow = (budgetRes.budgets as WageBudget[]).find(b => b.month === monthKey)
|
||||
if (bRow) {
|
||||
const dim = daysInMonth(weekStart)
|
||||
// Pro-rata: days in the selected week ÷ days in month
|
||||
const today = new Date()
|
||||
let weekDays = 7
|
||||
if (weekStart <= today && today <= weekEnd) {
|
||||
weekDays = Math.ceil((today.getTime() - weekStart.getTime()) / 86_400_000) + 1
|
||||
}
|
||||
setBudget(bRow.budget_amount * (weekDays / dim))
|
||||
} else {
|
||||
setBudget(null)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [fromStr, toStr, weekStart, weekEnd])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const prev = () => setWeekStart(d => addDays(d, -7))
|
||||
const next = () => setWeekStart(d => addDays(d, 7))
|
||||
const isCurrentWeek = fmt(startOfWeek(new Date())) === fmt(weekStart)
|
||||
|
||||
// Totals
|
||||
const deptTotals = depts.map(dep => {
|
||||
const cost = Object.values(dep.days).reduce((s, d) => s + d.cost, 0)
|
||||
return { department_name: dep.department_name, cost }
|
||||
}).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
|
||||
|
||||
const weekLabel = `${weekStart.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} – ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Weekly Wages</h1>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button className="btn btn-secondary" onClick={() => downloadExport('weekly', fromStr, toStr)}>
|
||||
<Download size={14} strokeWidth={1.75} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="summary-grid">
|
||||
<div className="summary-card">
|
||||
<div className="label">Total Wages</div>
|
||||
<div className="value">{fmtMoney(totalWages)}</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">PY {fmtMoney(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">Budget (pro-rata)</th>
|
||||
<th className="right">% Budget</th>
|
||||
<th className="right">Net Sales</th>
|
||||
<th className="right">% Net Sales</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deptTotals.map(dep => {
|
||||
const depPct = budget != null && budget > 0 ? (dep.cost / budget) * 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">—</td>
|
||||
<td className="right">
|
||||
{depPct != null
|
||||
? <span className={`pct-badge ${pctClass(depPct)}`}>{depPct.toFixed(1)}%</span>
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="right">{fmtMoney(netSales)}</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">{budget != null ? fmtMoney(budget) : '—'}</td>
|
||||
<td className="right">
|
||||
{pctBudget != null
|
||||
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="right">{fmtMoney(netSales)}</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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue