Yesterday cutoff for actuals; two-row monthly summary cards

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>
This commit is contained in:
jtricerolph 2026-07-23 12:36:42 +00:00
parent d04cd1e82c
commit 5b243b3ef8
3 changed files with 204 additions and 136 deletions

View file

@ -366,6 +366,17 @@ input[type="text"]:focus {
font-size: 14px; font-size: 14px;
} }
/* ── Section row label (Monthly two-row cards) ──────────────────── */
.section-row-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 8px;
margin-top: 4px;
}
/* ── PY collapse toggle ─────────────────────────────────────────── */ /* ── PY collapse toggle ─────────────────────────────────────────── */
.py-collapse-toggle { .py-collapse-toggle {
display: flex; display: flex;

View file

@ -3,7 +3,7 @@ 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'
import { getActuals, getScheduled, getNetSales, getBudgets, getDeptDetail, downloadExport } from '../api' import { getActuals, getNetSales, getBudgets, getDeptDetail, downloadExport } from '../api'
import type { DeptActuals, WageBudget, EmployeeDetail } from '../types' import type { DeptActuals, WageBudget, EmployeeDetail } from '../types'
import { DeptDetailModal } from '../components/DeptDetailModal' import { DeptDetailModal } from '../components/DeptDetailModal'
@ -15,49 +15,50 @@ function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).ge
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` } 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 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 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'] const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
export default function Monthly() { export default function Monthly() {
const todayStr = fmt(new Date()) const today = new Date()
const todayYear = parseInt(todayStr.slice(0, 4)) const todayStr = fmt(today)
const todayMonth = parseInt(todayStr.slice(5, 7)) 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 [year, setYear] = useState(todayYear)
const [month, setMonth] = useState(todayMonth) const [month, setMonth] = useState(todayMonth)
const [depts, setDepts] = useState<DeptActuals[]>([]) const [depts, setDepts] = useState<DeptActuals[]>([])
const [scheduled, setScheduled] = useState<Record<string, Record<string, number>>>({}) const [netSalesMTD, setNetSalesMTD] = useState(0)
const [netSales, setNetSales] = useState(0) const [netSalesFull, setNetSalesFull] = useState(0)
const [pySales, setPySales] = useState(0) const [pySalesMTD, setPySalesMTD] = useState(0)
const [pyWages, setPyWages] = useState<number | null>(null) const [pySalesFull, setPySalesFull] = useState(0)
const [pyWagesFull, setPyWagesFull] = useState<number | null>(null) const [pyWages, setPyWages] = useState<number | null>(null)
const [pyDepts, setPyDepts] = useState<{id: string; name: string; cost: number; costFull: number}[]>([]) const [pyWagesFull, setPyWagesFull] = useState<number | null>(null)
const [pyTableOpen, setPyTableOpen] = useState(false) const [pyDepts, setPyDepts] = useState<{id: string; name: string; cost: number; costFull: number}[]>([])
const [budget, setBudget] = useState<number | null>(null) const [pyTableOpen, setPyTableOpen] = useState(false)
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({}) const [budget, setBudget] = useState<number | null>(null)
const [showOncosts, setShowOncosts] = useState(true) const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(true) const [showOncosts, setShowOncosts] = useState(true)
const [error, setError] = useState<string | null>(null) const [loading, setLoading] = useState(true)
const [modal, setModal] = useState<{ deptId: string; deptName: string } | null>(null) const [error, setError] = useState<string | null>(null)
const [modalEmps, setModalEmps] = useState<EmployeeDetail[] | null>(null) const [modal, setModal] = useState<{ deptId: string; deptName: string } | null>(null)
const [modalLoad, setModalLoad] = useState(false) const [modalEmps, setModalEmps] = useState<EmployeeDetail[] | null>(null)
const [modalLoad, setModalLoad] = useState(false)
const dim = daysInMonth(year, month) const dim = daysInMonth(year, month)
const monthStr = `${year}-${String(month).padStart(2, '0')}` const monthStr = `${year}-${String(month).padStart(2, '0')}`
const fromStr = `${monthStr}-01` const fromStr = `${monthStr}-01`
const toStr = `${monthStr}-${String(dim).padStart(2, '0')}` const toStr = `${monthStr}-${String(dim).padStart(2, '0')}`
const isCurrentMonth = year === todayYear && month === todayMonth const isCurrentMonth = year === todayYear && month === todayMonth
const salesTo = isCurrentMonth ? todayStr : toStr
const schedFrom = todayStr < toStr ? todayStr : toStr // Prior year: always fetch full month (derive both MTD and full totals from one call)
// Current MonSun: only use rota within this window; beyond it use prior-week actuals
const currentWeekEndStr = (() => {
const d = new Date(todayStr + 'T00:00:00')
const day = d.getDay()
d.setDate(d.getDate() + (day === 0 ? 0 : 7 - day))
return fmt(d)
})()
// Prior-year: always fetch full month so we can show PY EOM alongside PY MTD
const pyDim = daysInMonth(year - 1, month) const pyDim = daysInMonth(year - 1, month)
const pyFromStr = `${year - 1}-${String(month).padStart(2, '0')}-01` 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 pyToStr = `${year - 1}-${String(month).padStart(2, '0')}-${String(pyDim).padStart(2, '0')}`
@ -65,10 +66,10 @@ export default function Monthly() {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setError(null) setLoading(true); setError(null)
try { try {
const [actRes, schRes, salesRes, budRes, pyActRes] = await Promise.all([ // Net sales: full month — OTB/forecast for future dates, actuals for past dates
const [actRes, salesRes, budRes, pyActRes] = await Promise.all([
getActuals(fromStr, toStr), getActuals(fromStr, toStr),
getScheduled(schedFrom, toStr), getNetSales(fromStr, toStr),
getNetSales(fromStr, salesTo),
getBudgets(), getBudgets(),
getActuals(pyFromStr, pyToStr), getActuals(pyFromStr, pyToStr),
]) ])
@ -76,23 +77,20 @@ export default function Monthly() {
setShowOncosts(actRes.show_oncosts) setShowOncosts(actRes.show_oncosts)
setDeptPcts(actRes.dept_pcts) setDeptPcts(actRes.dept_pcts)
const schMap: Record<string, Record<string, number>> = {} // Cut-off for MTD = yesterday (avoid partial clockins today)
for (const dep of schRes.departments) { const cutoff = isCurrentMonth ? yesterdayStr : toStr
schMap[dep.department_id] = {}
for (const [date, val] of Object.entries(dep.days)) {
schMap[dep.department_id][date] = val.cost
}
}
setScheduled(schMap)
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0)) setNetSalesMTD(salesRes.days.filter(d => d.date <= cutoff).reduce((s, d) => s + d.net_sales, 0))
setPySales(salesRes.days.reduce((s, d) => s + d.py_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 limit: cap at same day number as today (or month end for past months) // PY MTD: same day number as yesterday in PY (or full month for past months)
const todayDay = parseInt(todayStr.slice(8)) const yDay = parseInt(yesterdayStr.slice(8))
const pyMtdLimit = isCurrentMonth const pyMtdLimit = isCurrentMonth
? `${year - 1}-${String(month).padStart(2, '0')}-${String(Math.min(todayDay, pyDim)).padStart(2, '0')}` ? `${year - 1}-${String(month).padStart(2, '0')}-${String(Math.min(yDay, pyDim)).padStart(2, '0')}`
: pyToStr : pyToStr
const pyDeptList = pyActRes.departments.map(dep => { const pyDeptList = pyActRes.departments.map(dep => {
const mtdCost = Object.entries(dep.days).filter(([d]) => d <= pyMtdLimit).reduce((s, [, v]) => s + v.cost, 0) 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) const fullCost = Object.values(dep.days).reduce((s, v) => s + v.cost, 0)
@ -111,42 +109,35 @@ export default function Monthly() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [fromStr, toStr, schedFrom, salesTo, pyFromStr, pyToStr]) }, [fromStr, toStr, pyFromStr, pyToStr, isCurrentMonth, yesterdayStr, year, month, pyDim])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
useEffect(() => { useEffect(() => {
if (!modal) { setModalEmps(null); return } if (!modal) { setModalEmps(null); return }
setModalLoad(true) setModalLoad(true)
getDeptDetail(modal.deptId, fromStr, isCurrentMonth ? todayStr : toStr) getDeptDetail(modal.deptId, fromStr, isCurrentMonth ? yesterdayStr : toStr)
.then(r => setModalEmps(r.employees)) .then(r => setModalEmps(r.employees))
.catch(() => setModalEmps([])) .catch(() => setModalEmps([]))
.finally(() => setModalLoad(false)) .finally(() => setModalLoad(false))
}, [modal, fromStr, toStr, todayStr, isCurrentMonth]) }, [modal, fromStr, toStr, yesterdayStr, isCurrentMonth])
const prev = () => { if (month === 1) { setYear(y => y - 1); setMonth(12) } else { setMonth(m => m - 1) } } 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) } } const next = () => { if (month === 12) { setYear(y => y + 1); setMonth(1) } else { setMonth(m => m + 1) } }
function getSchCost(deptId: string, date: string): number | null { // Actuals cut off at yesterday; remaining days forecast via prior-week same-day actual
return scheduled[deptId]?.[date] ?? null const cutoff = isCurrentMonth ? yesterdayStr : toStr
}
// Build dept summary: actual MTD + forecast EOM
const deptSummary = depts.map((dep, idx) => { const deptSummary = depts.map((dep, idx) => {
const actualMTD = Object.entries(dep.days) const actualMTD = Object.entries(dep.days)
.filter(([d]) => d <= todayStr && d >= fromStr && d <= toStr) .filter(([d]) => d >= fromStr && d <= cutoff)
.reduce((s, [, v]) => s + v.cost, 0) .reduce((s, [, v]) => s + v.cost, 0)
let forecastRem = 0 let forecastRem = 0
if (isCurrentMonth) { if (isCurrentMonth) {
for (let day = 1; day <= dim; day++) { for (let day = 1; day <= dim; day++) {
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}` const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
if (dateStr <= todayStr) continue if (dateStr <= yesterdayStr) continue
// Only trust rota for current MonSun week; beyond that use prior-week actuals
if (dateStr <= currentWeekEndStr) {
const rota = getSchCost(dep.department_id, dateStr)
if (rota != null) { forecastRem += rota; continue }
}
const priorStr = fmt(addDays(new Date(dateStr + 'T00:00:00'), -7)) const priorStr = fmt(addDays(new Date(dateStr + 'T00:00:00'), -7))
const priorCost = dep.days[priorStr]?.cost const priorCost = dep.days[priorStr]?.cost
if (priorCost != null) forecastRem += priorCost if (priorCost != null) forecastRem += priorCost
@ -165,9 +156,18 @@ 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 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 // MTD row: pro-rata budget to yesterday
const variance = budget != null ? totalForecast - budget : null 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 { function pyPct(current: number, py: number): string {
if (py <= 0 || current <= 0) return '' if (py <= 0 || current <= 0) return ''
@ -175,32 +175,26 @@ export default function Monthly() {
return ` (${p >= 0 ? '+' : ''}${p.toFixed(1)}%)` return ` (${p >= 0 ? '+' : ''}${p.toFixed(1)}%)`
} }
// Build chart data with per-dept costs per week so Bar dataKey works // Chart: past weeks solid, current/future weeks lighter
type WeekEntry = { label: string; isPast: boolean; [dept: string]: number | boolean | string } type WeekEntry = { label: string; isPast: boolean; [dept: string]: number | boolean | string }
const weeks: WeekEntry[] = [] const weeks: WeekEntry[] = []
for (let w = 0; w * 7 < dim; w++) { for (let w = 0; w * 7 < dim; w++) {
const wStart = w * 7 + 1 const wStart = w * 7 + 1
const wEnd = Math.min(wStart + 6, dim) const wEnd = Math.min(wStart + 6, dim)
const wEndStr = `${monthStr}-${String(wEnd).padStart(2, '0')}` const wEndStr = `${monthStr}-${String(wEnd).padStart(2, '0')}`
const isPast = wEndStr < todayStr const isPast = wEndStr <= yesterdayStr
const entry: WeekEntry = { label: `W${w + 1}`, isPast } const entry: WeekEntry = { label: `W${w + 1}`, isPast }
for (const dep of deptSummary) { for (const dep of deptSummary) {
const srcDep = depts.find(d => d.department_id === dep.department_id) const srcDep = depts.find(d => d.department_id === dep.department_id)
let deptCost = 0 let deptCost = 0
for (let day = wStart; day <= wEnd; day++) { for (let day = wStart; day <= wEnd; day++) {
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}` const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
if (dateStr <= todayStr) { if (dateStr <= cutoff) {
deptCost += srcDep?.days[dateStr]?.cost ?? 0 deptCost += srcDep?.days[dateStr]?.cost ?? 0
} else { } else {
const rota = dateStr <= currentWeekEndStr ? getSchCost(dep.department_id, dateStr) : null const priorStr = fmt(addDays(new Date(dateStr + 'T00:00:00'), -7))
if (rota != null) { deptCost += srcDep?.days[priorStr]?.cost ?? 0
deptCost += rota
} else {
const priorStr = fmt(addDays(new Date(dateStr + 'T00:00:00'), -7))
deptCost += srcDep?.days[priorStr]?.cost ?? 0
}
} }
} }
entry[dep.department_name] = deptCost entry[dep.department_name] = deptCost
@ -225,51 +219,103 @@ export default function Monthly() {
<button className="btn btn-secondary" onClick={next} disabled={isCurrentMonth}><ChevronRight size={16} strokeWidth={1.75} /></button> <button className="btn btn-secondary" onClick={next} disabled={isCurrentMonth}><ChevronRight size={16} strokeWidth={1.75} /></button>
</div> </div>
<div className="summary-grid"> {/* ── Summary cards ─────────────────────────────────────────── */}
<div className="summary-card"> {isCurrentMonth ? (
<div className="label">{isCurrentMonth ? 'Actual MTD' : 'Actual'}</div> <>
<div className="value">{fmtMoney(totalActual)}</div> <div className="section-row-label">Month to {fmtDisplay(yesterdayStr)}</div>
{pyWages != null && <div className="sub">{isCurrentMonth ? 'PY MTD' : 'PY'} {fmtMoney(pyWages)}{pyPct(totalActual, pyWages)}</div>} <div className="summary-grid" style={{ marginBottom: 8 }}>
</div> <div className="summary-card">
{isCurrentMonth && ( <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="summary-card">
<div className="label">Forecast EOM</div> <div className="label">Actual</div>
<div className="value">{fmtMoney(totalForecast)}</div> <div className="value">{fmtMoney(totalActual)}</div>
<div className="sub"> {pyWagesFull != null && <div className="sub">PY {fmtMoney(pyWagesFull)}{pyPct(totalActual, pyWagesFull)}</div>}
{pyWagesFull != null
? `PY ${fmtMoney(pyWagesFull)}${pyPct(totalForecast, pyWagesFull)}`
: 'rota + prior-week actual'}
</div>
</div> </div>
)} <div className="summary-card">
<div className="summary-card"> <div className="label">Monthly Budget</div>
<div className="label">Monthly Budget</div> <div className="value">{budget != null ? fmtMoney(budget) : '—'}</div>
<div className="value">{budget != null ? fmtMoney(budget) : '—'}</div>
</div>
<div className="summary-card">
<div className="label">% Budget {isCurrentMonth ? '(Forecast)' : ''}</div>
<div className="value" style={pctBudget != null ? { color: pctBudget <= 100 ? 'var(--app-primary)' : pctBudget <= 110 ? '#b45309' : '#dc2626' } : {}}>
{pctBudget != null ? fmtDelta(pctBudget) : '—'}
</div> </div>
{variance != null && ( <div className="summary-card">
<div className={`sub ${variance > 0 ? 'variance-over' : 'variance-under'}`}> <div className="label">% Budget</div>
{variance > 0 ? `+${fmtMoney(variance)} over` : `${fmtMoney(Math.abs(variance))} under`} <div className="value" style={pctBudgFull != null ? { color: budgetColour(pctBudgFull) } : {}}>
{pctBudgFull != null ? fmtDelta(pctBudgFull) : '—'}
</div> </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> </div>
<div className="summary-card"> )}
<div className="label">{isCurrentMonth ? 'Net Sales MTD' : 'Net Sales'}</div>
<div className="value">{fmtMoney(netSales)}</div>
{pySales > 0 && <div className="sub">{isCurrentMonth ? 'PY MTD' : 'PY'} {fmtMoney(pySales)}{pyPct(netSales, pySales)}</div>}
</div>
<div className="summary-card">
<div className="label">% Net Sales</div>
<div className="value">{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}</div>
{pyWages != null && pySales > 0 && (
<div className="sub">{isCurrentMonth ? 'PY MTD' : 'PY'} {((pyWages / pySales) * 100).toFixed(1)}%</div>
)}
</div>
</div>
{loading && <div className="state-center">Loading</div>} {loading && <div className="state-center">Loading</div>}
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>} {error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
@ -321,7 +367,10 @@ export default function Monthly() {
return ( return (
<tr key={dep.department_id} style={{ cursor: 'pointer' }} <tr key={dep.department_id} style={{ cursor: 'pointer' }}
onClick={() => setModal({ deptId: dep.department_id, deptName: dep.department_name })}> 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>
<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> <td className="right">{fmtMoney(dep.actual_mtd)}</td>
{isCurrentMonth && <td className="right">{fmtMoney(dep.forecast_eom)}</td>} {isCurrentMonth && <td className="right">{fmtMoney(dep.forecast_eom)}</td>}
<td className="right" style={{ color: 'var(--text-muted)' }}> <td className="right" style={{ color: 'var(--text-muted)' }}>
@ -344,10 +393,10 @@ export default function Monthly() {
<td className="right">100%</td> <td className="right">100%</td>
<td className="right">{budget != null ? fmtMoney(budget) : '—'}</td> <td className="right">{budget != null ? fmtMoney(budget) : '—'}</td>
<td className="right"> <td className="right">
{pctBudget != null ? <span className={`pct-badge ${pctClass(pctBudget)}`}>{fmtDelta(pctBudget)}</span> : '—'} {pctBudgFull != null ? <span className={`pct-badge ${pctClass(pctBudgFull)}`}>{fmtDelta(pctBudgFull)}</span> : '—'}
</td> </td>
<td className="right"> <td className="right">
{variance != null && <span className={variance > 0 ? 'variance-over' : 'variance-under'}>{variance > 0 ? '+' : ''}{fmtMoney(variance)}</span>} {varianceFull != null && <span className={varianceFull > 0 ? 'variance-over' : 'variance-under'}>{varianceFull > 0 ? '+' : ''}{fmtMoney(varianceFull)}</span>}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -376,7 +425,7 @@ export default function Monthly() {
<tbody> <tbody>
{pyDepts.map(dep => { {pyDepts.map(dep => {
const ofTotal = pyTotalWages > 0 ? (dep.cost / pyTotalWages) * 100 : null const ofTotal = pyTotalWages > 0 ? (dep.cost / pyTotalWages) * 100 : null
const ofSales = pySales > 0 ? (dep.cost / pySales) * 100 : null const ofSales = pySalesMTD > 0 ? (dep.cost / pySalesMTD) * 100 : null
return ( return (
<tr key={dep.id}> <tr key={dep.id}>
<td>{dep.name}</td> <td>{dep.name}</td>
@ -390,7 +439,7 @@ export default function Monthly() {
<td>Total</td> <td>Total</td>
<td className="right">{fmtMoney(pyTotalWages)}</td> <td className="right">{fmtMoney(pyTotalWages)}</td>
<td className="right">100%</td> <td className="right">100%</td>
<td className="right">{pySales > 0 ? `${((pyTotalWages / pySales) * 100).toFixed(1)}%` : '—'}</td> <td className="right">{pySalesMTD > 0 ? `${((pyTotalWages / pySalesMTD) * 100).toFixed(1)}%` : '—'}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -403,7 +452,7 @@ export default function Monthly() {
{modal && ( {modal && (
<DeptDetailModal <DeptDetailModal
deptName={modal.deptName} deptName={modal.deptName}
period={monthLabel} period={isCurrentMonth ? `MTD to ${fmtDisplay(yesterdayStr)}` : monthLabel}
employees={modalEmps} employees={modalEmps}
loading={modalLoad} loading={modalLoad}
onClose={() => setModal(null)} onClose={() => setModal(null)}

View file

@ -51,11 +51,13 @@ export default function Weekly() {
const toStr = addDaysStr(fromStr, 6) const toStr = addDaysStr(fromStr, 6)
const todayStr = localStr(new Date()) const todayStr = localStr(new Date())
const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1)
const yesterdayStr = localStr(yesterday)
const isCurrentWeek = fromStr === mondayOf(new Date()) const isCurrentWeek = fromStr === mondayOf(new Date())
const pyFromStr = addDaysStr(fromStr, -364) const pyFromStr = addDaysStr(fromStr, -364)
// For current (partial) week cap PY at equivalent elapsed day — fair WTD comparison // Cut-off at yesterday: avoids partial clockins/open timesheets skewing today's figures
const daysElapsed = isCurrentWeek const daysElapsed = isCurrentWeek
? Math.round((new Date(todayStr + 'T00:00:00').getTime() - new Date(fromStr + 'T00:00:00').getTime()) / 86_400_000) ? Math.round((new Date(yesterdayStr + 'T00:00:00').getTime() - new Date(fromStr + 'T00:00:00').getTime()) / 86_400_000)
: 6 : 6
const pyToStr = addDaysStr(fromStr, -364 + daysElapsed) const pyToStr = addDaysStr(fromStr, -364 + daysElapsed)
@ -87,12 +89,16 @@ export default function Weekly() {
setDepts(actRes.departments) setDepts(actRes.departments)
setShowOncosts(actRes.show_oncosts) setShowOncosts(actRes.show_oncosts)
setDeptPcts(actRes.dept_pcts) setDeptPcts(actRes.dept_pcts)
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0)) // Current week cut-off at yesterday to avoid partial clockins
// For current week only sum PY sales for elapsed days (WTD = apples to apples)
const isCurrentWk = fromStr === mondayOf(new Date()) const isCurrentWk = fromStr === mondayOf(new Date())
setNetSales(
isCurrentWk
? salesRes.days.filter(d => d.date <= yesterdayStr).reduce((s, d) => s + d.net_sales, 0)
: salesRes.days.reduce((s, d) => s + d.net_sales, 0)
)
setPySales( setPySales(
isCurrentWk isCurrentWk
? salesRes.days.filter(d => d.date <= todayStr).reduce((s, d) => s + d.py_sales, 0) ? salesRes.days.filter(d => d.date <= yesterdayStr).reduce((s, d) => s + d.py_sales, 0)
: salesRes.days.reduce((s, d) => s + d.py_sales, 0) : salesRes.days.reduce((s, d) => s + d.py_sales, 0)
) )
@ -110,10 +116,10 @@ export default function Weekly() {
const bRow = (budgetRes.budgets as WageBudget[]).find(b => b.month === monthKey) const bRow = (budgetRes.budgets as WageBudget[]).find(b => b.month === monthKey)
if (bRow) { if (bRow) {
const dim = daysInMonthFor(fromStr) const dim = daysInMonthFor(fromStr)
const effectiveTo = todayStr < toStr ? todayStr : toStr const cutoff = isCurrentWk ? yesterdayStr : toStr
const effectiveFrom = fromStr > todayStr ? todayStr : fromStr const effectiveTo = cutoff < toStr ? cutoff : toStr
const weekDays = effectiveTo >= effectiveFrom const weekDays = effectiveTo >= fromStr
? Math.round((new Date(effectiveTo + 'T00:00:00').getTime() - new Date(effectiveFrom + 'T00:00:00').getTime()) / 86_400_000) + 1 ? Math.round((new Date(effectiveTo + 'T00:00:00').getTime() - new Date(fromStr + 'T00:00:00').getTime()) / 86_400_000) + 1
: 7 : 7
const ratio = weekDays / dim const ratio = weekDays / dim
setMonthlyBudg(bRow.budget_amount) setMonthlyBudg(bRow.budget_amount)
@ -151,7 +157,9 @@ export default function Weekly() {
const deptTotals = depts.map(dep => ({ const deptTotals = depts.map(dep => ({
department_id: dep.department_id, department_id: dep.department_id,
department_name: dep.department_name, department_name: dep.department_name,
cost: Object.values(dep.days).reduce((s, d) => s + d.cost, 0), cost: Object.entries(dep.days)
.filter(([d]) => !isCurrentWeek || d <= yesterdayStr)
.reduce((s, [, v]) => s + v.cost, 0),
})).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)