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>
463 lines
23 KiB
TypeScript
463 lines
23 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
|
import { ChevronLeft, ChevronRight, ChevronDown, Download } from 'lucide-react'
|
|
import {
|
|
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, Cell,
|
|
} from 'recharts'
|
|
import { getActuals, getNetSales, getBudgets, getDeptDetail, downloadExport } from '../api'
|
|
import type { DeptActuals, WageBudget, EmployeeDetail } from '../types'
|
|
import { DeptDetailModal } from '../components/DeptDetailModal'
|
|
|
|
function fmt(d: Date): string {
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
}
|
|
function addDays(d: Date, n: number): Date { const r = new Date(d); r.setDate(r.getDate() + n); return r }
|
|
function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() }
|
|
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
|
|
function pctClass(pct: number): string { return pct <= 100 ? 'pct-green' : pct <= 110 ? 'pct-amber' : 'pct-red' }
|
|
function fmtDelta(pct: number): string { const d = pct - 100; return `${d >= 0 ? '+' : ''}${d.toFixed(1)}%` }
|
|
function fmtDisplay(dateStr: string): string {
|
|
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
|
|
}
|
|
function budgetColour(pct: number): string {
|
|
return pct <= 100 ? 'var(--app-primary)' : pct <= 110 ? '#b45309' : '#dc2626'
|
|
}
|
|
|
|
const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
|
|
|
|
export default function Monthly() {
|
|
const today = new Date()
|
|
const todayStr = fmt(today)
|
|
const yesterdayStr = fmt(addDays(today, -1))
|
|
const todayYear = parseInt(todayStr.slice(0, 4))
|
|
const todayMonth = parseInt(todayStr.slice(5, 7))
|
|
|
|
const [year, setYear] = useState(todayYear)
|
|
const [month, setMonth] = useState(todayMonth)
|
|
|
|
const [depts, setDepts] = useState<DeptActuals[]>([])
|
|
const [netSalesMTD, setNetSalesMTD] = useState(0)
|
|
const [netSalesFull, setNetSalesFull] = useState(0)
|
|
const [pySalesMTD, setPySalesMTD] = useState(0)
|
|
const [pySalesFull, setPySalesFull] = useState(0)
|
|
const [pyWages, setPyWages] = useState<number | null>(null)
|
|
const [pyWagesFull, setPyWagesFull] = useState<number | null>(null)
|
|
const [pyDepts, setPyDepts] = useState<{id: string; name: string; cost: number; costFull: number}[]>([])
|
|
const [pyTableOpen, setPyTableOpen] = useState(false)
|
|
const [budget, setBudget] = 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 dim = daysInMonth(year, month)
|
|
const monthStr = `${year}-${String(month).padStart(2, '0')}`
|
|
const fromStr = `${monthStr}-01`
|
|
const toStr = `${monthStr}-${String(dim).padStart(2, '0')}`
|
|
const isCurrentMonth = year === todayYear && month === todayMonth
|
|
|
|
// Prior year: always fetch full month (derive both MTD and full totals from one call)
|
|
const pyDim = daysInMonth(year - 1, month)
|
|
const pyFromStr = `${year - 1}-${String(month).padStart(2, '0')}-01`
|
|
const pyToStr = `${year - 1}-${String(month).padStart(2, '0')}-${String(pyDim).padStart(2, '0')}`
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true); setError(null)
|
|
try {
|
|
// Net sales: full month — OTB/forecast for future dates, actuals for past dates
|
|
const [actRes, salesRes, budRes, 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)
|
|
|
|
// Cut-off for MTD = yesterday (avoid partial clockins today)
|
|
const cutoff = isCurrentMonth ? yesterdayStr : toStr
|
|
|
|
setNetSalesMTD(salesRes.days.filter(d => d.date <= cutoff).reduce((s, d) => s + d.net_sales, 0))
|
|
setNetSalesFull(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
|
|
setPySalesMTD(salesRes.days.filter(d => d.date <= cutoff).reduce((s, d) => s + d.py_sales, 0))
|
|
setPySalesFull(salesRes.days.reduce((s, d) => s + d.py_sales, 0))
|
|
|
|
// PY MTD: same day number as yesterday in PY (or full month for past months)
|
|
const yDay = parseInt(yesterdayStr.slice(8))
|
|
const pyMtdLimit = isCurrentMonth
|
|
? `${year - 1}-${String(month).padStart(2, '0')}-${String(Math.min(yDay, pyDim)).padStart(2, '0')}`
|
|
: pyToStr
|
|
|
|
const pyDeptList = pyActRes.departments.map(dep => {
|
|
const mtdCost = Object.entries(dep.days).filter(([d]) => d <= pyMtdLimit).reduce((s, [, v]) => s + v.cost, 0)
|
|
const fullCost = Object.values(dep.days).reduce((s, v) => s + v.cost, 0)
|
|
return { id: dep.department_id, name: dep.department_name, cost: mtdCost, costFull: fullCost }
|
|
}).filter(d => d.cost > 0 || d.costFull > 0).sort((a, b) => b.costFull - a.costFull)
|
|
setPyDepts(pyDeptList)
|
|
const pyTotalMTD = pyDeptList.reduce((s, d) => s + d.cost, 0)
|
|
const pyTotalFull = pyDeptList.reduce((s, d) => s + d.costFull, 0)
|
|
setPyWages(pyTotalMTD > 0 ? pyTotalMTD : null)
|
|
setPyWagesFull(pyTotalFull > 0 ? pyTotalFull : null)
|
|
|
|
const bRow = budRes.budgets.find((b: WageBudget) => b.month === fromStr)
|
|
setBudget(bRow ? bRow.budget_amount : null)
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : 'Failed to load')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [fromStr, toStr, pyFromStr, pyToStr, isCurrentMonth, yesterdayStr, year, month, pyDim])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
useEffect(() => {
|
|
if (!modal) { setModalEmps(null); return }
|
|
setModalLoad(true)
|
|
getDeptDetail(modal.deptId, fromStr, isCurrentMonth ? yesterdayStr : toStr)
|
|
.then(r => setModalEmps(r.employees))
|
|
.catch(() => setModalEmps([]))
|
|
.finally(() => setModalLoad(false))
|
|
}, [modal, fromStr, toStr, yesterdayStr, isCurrentMonth])
|
|
|
|
const prev = () => { if (month === 1) { setYear(y => y - 1); setMonth(12) } else { setMonth(m => m - 1) } }
|
|
const next = () => { if (month === 12) { setYear(y => y + 1); setMonth(1) } else { setMonth(m => m + 1) } }
|
|
|
|
// Actuals cut off at yesterday; remaining days forecast via prior-week same-day actual
|
|
const cutoff = isCurrentMonth ? yesterdayStr : toStr
|
|
|
|
const deptSummary = depts.map((dep, idx) => {
|
|
const actualMTD = Object.entries(dep.days)
|
|
.filter(([d]) => d >= fromStr && d <= cutoff)
|
|
.reduce((s, [, v]) => s + v.cost, 0)
|
|
|
|
let forecastRem = 0
|
|
if (isCurrentMonth) {
|
|
for (let day = 1; day <= dim; day++) {
|
|
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
|
|
if (dateStr <= yesterdayStr) continue
|
|
const priorStr = fmt(addDays(new Date(dateStr + 'T00:00:00'), -7))
|
|
const priorCost = dep.days[priorStr]?.cost
|
|
if (priorCost != null) forecastRem += priorCost
|
|
}
|
|
}
|
|
|
|
return {
|
|
department_id: dep.department_id,
|
|
department_name: dep.department_name,
|
|
actual_mtd: actualMTD,
|
|
forecast_eom: actualMTD + forecastRem,
|
|
color: DEPT_COLORS[idx % DEPT_COLORS.length],
|
|
}
|
|
}).sort((a, b) => b.forecast_eom - a.forecast_eom)
|
|
|
|
const totalActual = deptSummary.reduce((s, d) => s + d.actual_mtd, 0)
|
|
const totalForecast = deptSummary.reduce((s, d) => s + d.forecast_eom, 0)
|
|
const pyTotalWages = pyDepts.reduce((s, d) => s + d.cost, 0)
|
|
|
|
// MTD row: pro-rata budget to yesterday
|
|
const yDay = isCurrentMonth ? parseInt(yesterdayStr.slice(8)) : dim
|
|
const budgetMTD = budget != null ? budget * yDay / dim : null
|
|
const varianceMTD = budgetMTD != null ? totalActual - budgetMTD : null
|
|
const pctBudgMTD = budgetMTD != null && budgetMTD > 0 ? (totalActual / budgetMTD) * 100 : null
|
|
const pctSalesMTD = netSalesMTD > 0 ? (totalActual / netSalesMTD) * 100 : null
|
|
|
|
// Full month row
|
|
const varianceFull = budget != null ? totalForecast - budget : null
|
|
const pctBudgFull = budget != null && budget > 0 ? (totalForecast / budget) * 100 : null
|
|
const pctSalesFull = netSalesFull > 0 ? (totalForecast / netSalesFull) * 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)}%)`
|
|
}
|
|
|
|
// Chart: past weeks solid, current/future weeks lighter
|
|
type WeekEntry = { label: string; isPast: boolean; [dept: string]: number | boolean | string }
|
|
const weeks: WeekEntry[] = []
|
|
for (let w = 0; w * 7 < dim; w++) {
|
|
const wStart = w * 7 + 1
|
|
const wEnd = Math.min(wStart + 6, dim)
|
|
const wEndStr = `${monthStr}-${String(wEnd).padStart(2, '0')}`
|
|
const isPast = wEndStr <= yesterdayStr
|
|
|
|
const entry: WeekEntry = { label: `W${w + 1}`, isPast }
|
|
for (const dep of deptSummary) {
|
|
const srcDep = depts.find(d => d.department_id === dep.department_id)
|
|
let deptCost = 0
|
|
for (let day = wStart; day <= wEnd; day++) {
|
|
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
|
|
if (dateStr <= cutoff) {
|
|
deptCost += srcDep?.days[dateStr]?.cost ?? 0
|
|
} else {
|
|
const priorStr = fmt(addDays(new Date(dateStr + 'T00:00:00'), -7))
|
|
deptCost += srcDep?.days[priorStr]?.cost ?? 0
|
|
}
|
|
}
|
|
entry[dep.department_name] = deptCost
|
|
}
|
|
weeks.push(entry)
|
|
}
|
|
|
|
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
|
|
|
|
return (
|
|
<div>
|
|
<div className="page-header">
|
|
<h1 className="page-title">Monthly View</h1>
|
|
<button className="btn btn-secondary" onClick={() => downloadExport('monthly', 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">{monthLabel}</span>
|
|
<button className="btn btn-secondary" onClick={next} disabled={isCurrentMonth}><ChevronRight size={16} strokeWidth={1.75} /></button>
|
|
</div>
|
|
|
|
{/* ── Summary cards ─────────────────────────────────────────── */}
|
|
{isCurrentMonth ? (
|
|
<>
|
|
<div className="section-row-label">Month to {fmtDisplay(yesterdayStr)}</div>
|
|
<div className="summary-grid" style={{ marginBottom: 8 }}>
|
|
<div className="summary-card">
|
|
<div className="label">Actual MTD</div>
|
|
<div className="value">{fmtMoney(totalActual)}</div>
|
|
{pyWages != null && <div className="sub">PY MTD {fmtMoney(pyWages)}{pyPct(totalActual, pyWages)}</div>}
|
|
</div>
|
|
<div className="summary-card">
|
|
<div className="label">Budget MTD</div>
|
|
<div className="value">{budgetMTD != null ? fmtMoney(budgetMTD) : '—'}</div>
|
|
<div className="sub">pro-rata {yDay}/{dim} days</div>
|
|
</div>
|
|
<div className="summary-card">
|
|
<div className="label">% Budget MTD</div>
|
|
<div className="value" style={pctBudgMTD != null ? { color: budgetColour(pctBudgMTD) } : {}}>
|
|
{pctBudgMTD != null ? fmtDelta(pctBudgMTD) : '—'}
|
|
</div>
|
|
{varianceMTD != null && (
|
|
<div className={`sub ${varianceMTD > 0 ? 'variance-over' : 'variance-under'}`}>
|
|
{varianceMTD > 0 ? `+${fmtMoney(varianceMTD)} over` : `${fmtMoney(Math.abs(varianceMTD))} under`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="summary-card">
|
|
<div className="label">% Net Sales MTD</div>
|
|
<div className="value">{pctSalesMTD != null ? `${pctSalesMTD.toFixed(1)}%` : '—'}</div>
|
|
{pyWages != null && pySalesMTD > 0 && (
|
|
<div className="sub">PY MTD {((pyWages / pySalesMTD) * 100).toFixed(1)}%</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="section-row-label">Full month forecast</div>
|
|
<div className="summary-grid" style={{ marginBottom: 20 }}>
|
|
<div className="summary-card">
|
|
<div className="label">Forecast EOM</div>
|
|
<div className="value">{fmtMoney(totalForecast)}</div>
|
|
{pyWagesFull != null && <div className="sub">PY {fmtMoney(pyWagesFull)}{pyPct(totalForecast, pyWagesFull)}</div>}
|
|
</div>
|
|
<div className="summary-card">
|
|
<div className="label">Monthly Budget</div>
|
|
<div className="value">{budget != null ? fmtMoney(budget) : '—'}</div>
|
|
</div>
|
|
<div className="summary-card">
|
|
<div className="label">% Budget (Forecast)</div>
|
|
<div className="value" style={pctBudgFull != null ? { color: budgetColour(pctBudgFull) } : {}}>
|
|
{pctBudgFull != null ? fmtDelta(pctBudgFull) : '—'}
|
|
</div>
|
|
{varianceFull != null && (
|
|
<div className={`sub ${varianceFull > 0 ? 'variance-over' : 'variance-under'}`}>
|
|
{varianceFull > 0 ? `+${fmtMoney(varianceFull)} over` : `${fmtMoney(Math.abs(varianceFull))} under`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="summary-card">
|
|
<div className="label">% Net Sales (OTB)</div>
|
|
<div className="value">{pctSalesFull != null ? `${pctSalesFull.toFixed(1)}%` : '—'}</div>
|
|
{pyWagesFull != null && pySalesFull > 0 && (
|
|
<div className="sub">PY {((pyWagesFull / pySalesFull) * 100).toFixed(1)}%</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="summary-grid" style={{ marginBottom: 20 }}>
|
|
<div className="summary-card">
|
|
<div className="label">Actual</div>
|
|
<div className="value">{fmtMoney(totalActual)}</div>
|
|
{pyWagesFull != null && <div className="sub">PY {fmtMoney(pyWagesFull)}{pyPct(totalActual, pyWagesFull)}</div>}
|
|
</div>
|
|
<div className="summary-card">
|
|
<div className="label">Monthly Budget</div>
|
|
<div className="value">{budget != null ? fmtMoney(budget) : '—'}</div>
|
|
</div>
|
|
<div className="summary-card">
|
|
<div className="label">% Budget</div>
|
|
<div className="value" style={pctBudgFull != null ? { color: budgetColour(pctBudgFull) } : {}}>
|
|
{pctBudgFull != null ? fmtDelta(pctBudgFull) : '—'}
|
|
</div>
|
|
{varianceFull != null && (
|
|
<div className={`sub ${varianceFull > 0 ? 'variance-over' : 'variance-under'}`}>
|
|
{varianceFull > 0 ? `+${fmtMoney(varianceFull)} over` : `${fmtMoney(Math.abs(varianceFull))} under`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="summary-card">
|
|
<div className="label">% Net Sales</div>
|
|
<div className="value">{pctSalesFull != null ? `${pctSalesFull.toFixed(1)}%` : '—'}</div>
|
|
{pyWagesFull != null && pySalesFull > 0 && (
|
|
<div className="sub">PY {((pyWagesFull / pySalesFull) * 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">
|
|
<div className="card-title">Weekly Breakdown{isCurrentMonth ? ' (forecast shaded)' : ''}</div>
|
|
<ResponsiveContainer width="100%" height={260}>
|
|
<BarChart data={weeks} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
|
|
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
|
<YAxis tickFormatter={v => `£${Math.round(Number(v) / 1000)}k`} tick={{ fontSize: 11 }} width={55} />
|
|
<Tooltip formatter={(v: unknown) => fmtMoney(Number(v))} />
|
|
<Legend />
|
|
{deptSummary.map(dep => (
|
|
<Bar key={dep.department_id} dataKey={dep.department_name} stackId="a" fill={dep.color}>
|
|
{weeks.map((w, i) => (
|
|
<Cell key={i} fill={dep.color} opacity={w.isPast ? 1 : 0.45} />
|
|
))}
|
|
</Bar>
|
|
))}
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<table className="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Department</th>
|
|
<th className="right">{isCurrentMonth ? 'Actual MTD' : 'Actual'}</th>
|
|
{isCurrentMonth && <th className="right">Forecast → EOM</th>}
|
|
<th className="right">% of Total</th>
|
|
<th className="right">Budget</th>
|
|
<th className="right">% Budget</th>
|
|
<th className="right">Variance</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{deptSummary.map(dep => {
|
|
const displayCost = isCurrentMonth ? dep.forecast_eom : dep.actual_mtd
|
|
const totalDisplay = isCurrentMonth ? totalForecast : totalActual
|
|
const depBudg = budget != null && (deptPcts[dep.department_id] ?? 0) > 0
|
|
? budget * (deptPcts[dep.department_id] / 100)
|
|
: null
|
|
const dp = depBudg != null && depBudg > 0 ? (displayCost / depBudg) * 100 : null
|
|
const dv = depBudg != null ? displayCost - depBudg : null
|
|
const depOfTotal = totalDisplay > 0 ? (displayCost / totalDisplay) * 100 : null
|
|
return (
|
|
<tr key={dep.department_id} style={{ cursor: 'pointer' }}
|
|
onClick={() => setModal({ deptId: dep.department_id, deptName: dep.department_name })}>
|
|
<td>
|
|
<span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: dep.color, marginRight: 8 }} />
|
|
{dep.department_name}
|
|
</td>
|
|
<td className="right">{fmtMoney(dep.actual_mtd)}</td>
|
|
{isCurrentMonth && <td className="right">{fmtMoney(dep.forecast_eom)}</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">
|
|
{dp != null ? <span className={`pct-badge ${pctClass(dp)}`}>{fmtDelta(dp)}</span> : '—'}
|
|
</td>
|
|
<td className="right">
|
|
{dv != null && <span className={dv > 0 ? 'variance-over' : 'variance-under'}>{dv > 0 ? '+' : ''}{fmtMoney(dv)}</span>}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
<tr className="total-row">
|
|
<td>Total</td>
|
|
<td className="right">{fmtMoney(totalActual)}</td>
|
|
{isCurrentMonth && <td className="right">{fmtMoney(totalForecast)}</td>}
|
|
<td className="right">100%</td>
|
|
<td className="right">{budget != null ? fmtMoney(budget) : '—'}</td>
|
|
<td className="right">
|
|
{pctBudgFull != null ? <span className={`pct-badge ${pctClass(pctBudgFull)}`}>{fmtDelta(pctBudgFull)}</span> : '—'}
|
|
</td>
|
|
<td className="right">
|
|
{varianceFull != null && <span className={varianceFull > 0 ? 'variance-over' : 'variance-under'}>{varianceFull > 0 ? '+' : ''}{fmtMoney(varianceFull)}</span>}
|
|
</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} />}
|
|
{isCurrentMonth ? 'PY MTD' : 'PY'} Dept Breakdown ({pyFromStr.slice(0, 7)})
|
|
</button>
|
|
{pyTableOpen && (
|
|
<table className="data-table" style={{ marginTop: 12 }}>
|
|
<thead>
|
|
<tr>
|
|
<th>Department</th>
|
|
<th className="right">{isCurrentMonth ? 'PY MTD' : '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 = pySalesMTD > 0 ? (dep.cost / pySalesMTD) * 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">{pySalesMTD > 0 ? `${((pyTotalWages / pySalesMTD) * 100).toFixed(1)}%` : '—'}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{modal && (
|
|
<DeptDetailModal
|
|
deptName={modal.deptName}
|
|
period={isCurrentMonth ? `MTD to ${fmtDisplay(yesterdayStr)}` : monthLabel}
|
|
employees={modalEmps}
|
|
loading={modalLoad}
|
|
onClose={() => setModal(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|