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
198
frontend/src/pages/Rolling12Months.tsx
Normal file
198
frontend/src/pages/Rolling12Months.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Download } from 'lucide-react'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
|
||||
import type { WageBudget } from '../types'
|
||||
|
||||
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
|
||||
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
|
||||
function pctClass(p: number): string { return p <= 100 ? 'pct-green' : p <= 110 ? 'pct-amber' : 'pct-red' }
|
||||
function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() }
|
||||
|
||||
const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
|
||||
const MONTH_LABELS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
||||
|
||||
export default function Rolling12Months() {
|
||||
const [rows, setRows] = useState<{ label: string; wages: number; budget: number | null; sales: number; pySales: number; partial: boolean }[]>([])
|
||||
const [deptCols, setDeptCols] = useState<{ id: string; name: string; color: string }[]>([])
|
||||
const [chartData, setChartData] = useState<Record<string, number | string>[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const today = new Date()
|
||||
const curY = today.getFullYear()
|
||||
const curM = today.getMonth() + 1 // 1-based
|
||||
|
||||
// 13 months: 12 complete + current partial
|
||||
const months: { year: number; month: number }[] = []
|
||||
for (let i = 12; i >= 0; i--) {
|
||||
let m = curM - i
|
||||
let y = curY
|
||||
while (m <= 0) { m += 12; y-- }
|
||||
months.push({ year: y, month: m })
|
||||
}
|
||||
|
||||
const rangeFrom = `${months[0].year}-${String(months[0].month).padStart(2, '0')}-01`
|
||||
const lastMon = months[months.length - 1]
|
||||
const lastDim = daysInMonth(lastMon.year, lastMon.month)
|
||||
const rangeTo = `${lastMon.year}-${String(lastMon.month).padStart(2, '0')}-${String(lastDim).padStart(2, '0')}`
|
||||
|
||||
const [actRes, salesRes, budRes] = await Promise.all([
|
||||
getActuals(rangeFrom, rangeTo),
|
||||
getNetSales(rangeFrom, rangeTo),
|
||||
getBudgets(),
|
||||
])
|
||||
|
||||
const salesByDate: Record<string, { sales: number; py: number }> = {}
|
||||
for (const d of salesRes.days) salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales }
|
||||
|
||||
const budgetMap: Record<string, number> = {}
|
||||
for (const b of budRes.budgets as WageBudget[]) budgetMap[b.month] = b.budget_amount
|
||||
|
||||
const depts = actRes.departments
|
||||
const cols = depts.map((d, i) => ({ id: d.department_id, name: d.department_name, color: DEPT_COLORS[i % DEPT_COLORS.length] }))
|
||||
setDeptCols(cols)
|
||||
|
||||
const tableRows: typeof rows = []
|
||||
const cData: Record<string, number | string>[] = []
|
||||
const todayStr = fmt(today)
|
||||
|
||||
for (const { year, month } of months) {
|
||||
const dim = daysInMonth(year, month)
|
||||
const monthStr = `${year}-${String(month).padStart(2, '0')}`
|
||||
const monthFrom = `${monthStr}-01`
|
||||
const monthTo = `${monthStr}-${String(dim).padStart(2, '0')}`
|
||||
const isCurrentMonth = year === curY && month === curM
|
||||
const effectiveTo = isCurrentMonth ? todayStr : monthTo
|
||||
|
||||
let wages = 0
|
||||
const deptWages: Record<string, number> = {}
|
||||
for (const dep of depts) {
|
||||
let dCost = 0
|
||||
for (const [date, val] of Object.entries(dep.days)) {
|
||||
if (date >= monthFrom && date <= effectiveTo) dCost += val.cost
|
||||
}
|
||||
wages += dCost
|
||||
deptWages[dep.department_id] = dCost
|
||||
}
|
||||
|
||||
let sales = 0, pySales = 0
|
||||
for (const [date, val] of Object.entries(salesByDate)) {
|
||||
if (date >= monthFrom && date <= effectiveTo) { sales += val.sales; pySales += val.py }
|
||||
}
|
||||
|
||||
const monKey = `${monthFrom}`
|
||||
const budget = budgetMap[monKey] ?? null
|
||||
const label = `${MONTH_LABELS[month - 1]}-${String(year).slice(2)}`
|
||||
|
||||
tableRows.push({ label, wages, budget, sales, pySales, partial: isCurrentMonth })
|
||||
|
||||
const cdRow: Record<string, number | string> = { label }
|
||||
for (const dep of depts) cdRow[dep.department_name] = deptWages[dep.department_id] ?? 0
|
||||
cData.push(cdRow)
|
||||
}
|
||||
|
||||
setRows(tableRows)
|
||||
setChartData(cData)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const today = new Date()
|
||||
const curY = today.getFullYear()
|
||||
const curM = today.getMonth() + 1
|
||||
let fromY = curY, fromM = curM - 12
|
||||
while (fromM <= 0) { fromM += 12; fromY-- }
|
||||
const rangeFrom = `${fromY}-${String(fromM).padStart(2, '0')}-01`
|
||||
const rangeTo = `${curY}-${String(curM).padStart(2, '0')}-${String(daysInMonth(curY, curM)).padStart(2, '0')}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Rolling 12 Months</h1>
|
||||
<button className="btn btn-secondary" onClick={() => downloadExport('rolling-months', rangeFrom, rangeTo)}>
|
||||
<Download size={14} strokeWidth={1.75} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="state-center">Loading…</div>}
|
||||
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-title">Wages by Department (monthly)</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 10 }} />
|
||||
<YAxis tickFormatter={v => `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} />
|
||||
<Tooltip formatter={(v: number) => fmtMoney(v)} />
|
||||
<Legend />
|
||||
{deptCols.map(dep => (
|
||||
<Bar key={dep.id} dataKey={dep.name} stackId="a" fill={dep.color} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th className="right">Total Wages</th>
|
||||
<th className="right">Budget</th>
|
||||
<th className="right">Var vs Budget</th>
|
||||
<th className="right">% Budget</th>
|
||||
<th className="right">Net Sales</th>
|
||||
<th className="right">% Net Sales</th>
|
||||
<th className="right">PY Net Sales</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => {
|
||||
const pctB = r.budget != null && r.budget > 0 ? (r.wages / r.budget) * 100 : null
|
||||
const pctS = r.sales > 0 ? (r.wages / r.sales) * 100 : null
|
||||
const vari = r.budget != null ? r.wages - r.budget : null
|
||||
return (
|
||||
<tr key={i} style={r.partial ? { opacity: 0.7 } : {}}>
|
||||
<td>
|
||||
{r.label}
|
||||
{r.partial && <span className="partial-badge">current</span>}
|
||||
</td>
|
||||
<td className="right">{fmtMoney(r.wages)}</td>
|
||||
<td className="right">{r.budget != null ? fmtMoney(r.budget) : '—'}</td>
|
||||
<td className="right">
|
||||
{vari != null && (
|
||||
<span className={vari > 0 ? 'variance-over' : 'variance-under'}>
|
||||
{vari > 0 ? '+' : ''}{fmtMoney(vari)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="right">
|
||||
{pctB != null ? <span className={`pct-badge ${pctClass(pctB)}`}>{pctB.toFixed(1)}%</span> : '—'}
|
||||
</td>
|
||||
<td className="right">{r.sales > 0 ? fmtMoney(r.sales) : '—'}</td>
|
||||
<td className="right">{pctS != null ? `${pctS.toFixed(1)}%` : '—'}</td>
|
||||
<td className="right">{r.pySales > 0 ? fmtMoney(r.pySales) : '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue