Add collapsed PY dept breakdown table below weekly/monthly tables

Stores full PY department breakdown from already-fetched pyActRes.
A toggle button (collapsed by default) expands an inline table showing
PY wages per dept, % of PY total, and % of PY net sales. Label adapts
to PY WTD / PY MTD for current partial periods.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-23 10:25:04 +00:00
parent a857eb6177
commit f9916cc3fd
3 changed files with 128 additions and 11 deletions

View file

@ -314,6 +314,21 @@ input[type="text"]:focus {
font-style: italic;
}
/* ── PY collapse toggle ─────────────────────────────────────────── */
.py-collapse-toggle {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: none;
padding: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
}
.py-collapse-toggle:hover { color: var(--text-primary); }
/* ── Loading/error states ───────────────────────────────────────── */
.state-center {
display: flex;

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { ChevronLeft, ChevronRight, Download } from 'lucide-react'
import { ChevronLeft, ChevronRight, ChevronDown, Download } from 'lucide-react'
import {
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, Cell,
} from 'recharts'
@ -29,6 +29,8 @@ export default function Monthly() {
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 [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
const [showOncosts, setShowOncosts] = useState(true)
@ -75,9 +77,13 @@ export default function Monthly() {
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
setPySales(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
)
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 bRow = budRes.budgets.find((b: WageBudget) => b.month === fromStr)
@ -128,6 +134,7 @@ export default function Monthly() {
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)
const pctBudget = budget != null && budget > 0 ? (totalForecast / budget) * 100 : null
const pctSales = netSales > 0 ? (totalActual / netSales) * 100 : null
const variance = budget != null ? totalForecast - budget : null
@ -314,6 +321,49 @@ export default function Monthly() {
</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 = 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>
)}
</>
)}
</div>

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { ChevronLeft, ChevronRight, Download } from 'lucide-react'
import { ChevronLeft, ChevronRight, ChevronDown, Download } from 'lucide-react'
import { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
import type { DeptActuals, WageBudget } from '../types'
@ -58,6 +58,8 @@ export default function Weekly() {
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>>({})
@ -86,9 +88,13 @@ export default function Weekly() {
: 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
)
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')
@ -132,6 +138,7 @@ export default function Weekly() {
})).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
@ -197,6 +204,7 @@ export default function Weekly() {
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
{!loading && !error && (
<>
<div className="card">
<table className="data-table">
<thead>
@ -250,6 +258,50 @@ export default function Weekly() {
<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>
)}
</>
)}
</div>
)