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:
parent
a857eb6177
commit
f9916cc3fd
3 changed files with 128 additions and 11 deletions
|
|
@ -314,6 +314,21 @@ input[type="text"]:focus {
|
||||||
font-style: italic;
|
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 ───────────────────────────────────────── */
|
/* ── Loading/error states ───────────────────────────────────────── */
|
||||||
.state-center {
|
.state-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { ChevronLeft, ChevronRight, Download } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, ChevronDown, Download } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, Cell,
|
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, Cell,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
|
|
@ -29,6 +29,8 @@ export default function Monthly() {
|
||||||
const [netSales, setNetSales] = useState(0)
|
const [netSales, setNetSales] = useState(0)
|
||||||
const [pySales, setPySales] = useState(0)
|
const [pySales, setPySales] = useState(0)
|
||||||
const [pyWages, setPyWages] = useState<number | null>(null)
|
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 [budget, setBudget] = useState<number | null>(null)
|
||||||
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
|
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
|
||||||
const [showOncosts, setShowOncosts] = useState(true)
|
const [showOncosts, setShowOncosts] = useState(true)
|
||||||
|
|
@ -75,9 +77,13 @@ export default function Monthly() {
|
||||||
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
|
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
|
||||||
setPySales(salesRes.days.reduce((s, d) => s + d.py_sales, 0))
|
setPySales(salesRes.days.reduce((s, d) => s + d.py_sales, 0))
|
||||||
|
|
||||||
const pyTotal = pyActRes.departments.reduce(
|
const pyDeptList = pyActRes.departments.map(dep => ({
|
||||||
(s, dep) => s + Object.values(dep.days).reduce((ds, d) => ds + d.cost, 0), 0
|
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)
|
setPyWages(pyTotal > 0 ? pyTotal : null)
|
||||||
|
|
||||||
const bRow = budRes.budgets.find((b: WageBudget) => b.month === fromStr)
|
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 totalActual = deptSummary.reduce((s, d) => s + d.actual_mtd, 0)
|
||||||
const totalForecast = deptSummary.reduce((s, d) => s + d.forecast_eom, 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 pctBudget = budget != null && budget > 0 ? (totalForecast / budget) * 100 : null
|
||||||
const pctSales = netSales > 0 ? (totalActual / netSales) * 100 : null
|
const pctSales = netSales > 0 ? (totalActual / netSales) * 100 : null
|
||||||
const variance = budget != null ? totalForecast - budget : null
|
const variance = budget != null ? totalForecast - budget : null
|
||||||
|
|
@ -314,6 +321,49 @@ export default function Monthly() {
|
||||||
</table>
|
</table>
|
||||||
{showOncosts && <p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>}
|
{showOncosts && <p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
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 { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
|
||||||
import type { DeptActuals, WageBudget } from '../types'
|
import type { DeptActuals, WageBudget } from '../types'
|
||||||
|
|
||||||
|
|
@ -58,6 +58,8 @@ export default function Weekly() {
|
||||||
const [netSales, setNetSales] = useState(0)
|
const [netSales, setNetSales] = useState(0)
|
||||||
const [pySales, setPySales] = useState(0)
|
const [pySales, setPySales] = useState(0)
|
||||||
const [pyWages, setPyWages] = useState<number | null>(null)
|
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 [budget, setBudget] = useState<number | null>(null)
|
||||||
const [monthlyBudg, setMonthlyBudg] = useState<number | null>(null)
|
const [monthlyBudg, setMonthlyBudg] = useState<number | null>(null)
|
||||||
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
|
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)
|
: salesRes.days.reduce((s, d) => s + d.py_sales, 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
const pyTotal = pyActRes.departments.reduce(
|
const pyDeptList = pyActRes.departments.map(dep => ({
|
||||||
(s, dep) => s + Object.values(dep.days).reduce((ds, d) => ds + d.cost, 0), 0
|
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)
|
setPyWages(pyTotal > 0 ? pyTotal : null)
|
||||||
|
|
||||||
const d0 = new Date(fromStr + 'T00:00:00')
|
const d0 = new Date(fromStr + 'T00:00:00')
|
||||||
|
|
@ -132,6 +138,7 @@ export default function Weekly() {
|
||||||
})).sort((a, b) => b.cost - a.cost)
|
})).sort((a, b) => b.cost - a.cost)
|
||||||
|
|
||||||
const totalWages = deptTotals.reduce((s, d) => s + d.cost, 0)
|
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 pctBudget = budget != null && budget > 0 ? (totalWages / budget) * 100 : null
|
||||||
const pctSales = netSales > 0 ? (totalWages / netSales) * 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>}
|
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -250,6 +258,50 @@ export default function Weekly() {
|
||||||
<p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>
|
<p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue