Monthly view: rota boundary, EOM PY card, % Budget headline; backfill 25 months
Rota forecasting: only trust Workforce rota for the current Mon–Sun week — beyond that use prior-week actuals to avoid incomplete next-week rotas deflating the EOM forecast. % vs Budget cards (Weekly + Monthly): show as coloured headline number (green/amber/red) instead of small pct-badge chip, matching other cards. Forecast EOM: PY sub-label shows full prior-year month total + % delta, replacing the static "rota + prior-week actual" note once PY data exists. Achieves this by fetching the full PY month rather than MTD-capped range, then deriving both MTD (for Actual MTD card) and full-month (for EOM card). Backfill depth: 13 → 25 months so past months in Rolling 12 have PY wage data available (viewing Dec 2024 needs Dec 2023 = 19 months ago). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a75aceb6e7
commit
d04cd1e82c
4 changed files with 43 additions and 27 deletions
|
|
@ -264,7 +264,7 @@ export async function runBackfill(onProgress, signal) {
|
||||||
endDate.setDate(endDate.getDate() - 1)
|
endDate.setDate(endDate.getDate() - 1)
|
||||||
|
|
||||||
const startDate = new Date(today)
|
const startDate = new Date(today)
|
||||||
startDate.setMonth(startDate.getMonth() - 13)
|
startDate.setMonth(startDate.getMonth() - 25)
|
||||||
|
|
||||||
const totalDays = Math.max(1, Math.ceil((endDate - startDate) / 86_400_000))
|
const totalDays = Math.max(1, Math.ceil((endDate - startDate) / 86_400_000))
|
||||||
let processedDays = 0
|
let processedDays = 0
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,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 [pyWagesFull, setPyWagesFull] = useState<number | null>(null)
|
||||||
|
const [pyDepts, setPyDepts] = useState<{id: string; name: string; cost: number; costFull: number}[]>([])
|
||||||
const [pyTableOpen, setPyTableOpen] = useState(false)
|
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>>({})
|
||||||
|
|
@ -49,12 +50,17 @@ export default function Monthly() {
|
||||||
const isCurrentMonth = year === todayYear && month === todayMonth
|
const isCurrentMonth = year === todayYear && month === todayMonth
|
||||||
const salesTo = isCurrentMonth ? todayStr : toStr
|
const salesTo = isCurrentMonth ? todayStr : toStr
|
||||||
const schedFrom = todayStr < toStr ? todayStr : toStr
|
const schedFrom = todayStr < toStr ? todayStr : toStr
|
||||||
// Prior-year equivalent period
|
// Current Mon–Sun: 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 = isCurrentMonth
|
const pyToStr = `${year - 1}-${String(month).padStart(2, '0')}-${String(pyDim).padStart(2, '0')}`
|
||||||
? `${year - 1}-${String(month).padStart(2, '0')}-${String(Math.min(parseInt(todayStr.slice(8)), pyDim)).padStart(2, '0')}`
|
|
||||||
: `${year - 1}-${String(month).padStart(2, '0')}-${String(pyDim).padStart(2, '0')}`
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true); setError(null)
|
setLoading(true); setError(null)
|
||||||
|
|
@ -82,14 +88,21 @@ 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 pyDeptList = pyActRes.departments.map(dep => ({
|
// PY MTD limit: cap at same day number as today (or month end for past months)
|
||||||
id: dep.department_id,
|
const todayDay = parseInt(todayStr.slice(8))
|
||||||
name: dep.department_name,
|
const pyMtdLimit = isCurrentMonth
|
||||||
cost: Object.values(dep.days).reduce((s, d) => s + d.cost, 0),
|
? `${year - 1}-${String(month).padStart(2, '0')}-${String(Math.min(todayDay, pyDim)).padStart(2, '0')}`
|
||||||
})).filter(d => d.cost > 0).sort((a, b) => b.cost - a.cost)
|
: 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)
|
setPyDepts(pyDeptList)
|
||||||
const pyTotal = pyDeptList.reduce((s, d) => s + d.cost, 0)
|
const pyTotalMTD = pyDeptList.reduce((s, d) => s + d.cost, 0)
|
||||||
setPyWages(pyTotal > 0 ? pyTotal : null)
|
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)
|
const bRow = budRes.budgets.find((b: WageBudget) => b.month === fromStr)
|
||||||
setBudget(bRow ? bRow.budget_amount : null)
|
setBudget(bRow ? bRow.budget_amount : null)
|
||||||
|
|
@ -129,8 +142,11 @@ export default function Monthly() {
|
||||||
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 <= todayStr) continue
|
||||||
|
// Only trust rota for current Mon–Sun week; beyond that use prior-week actuals
|
||||||
|
if (dateStr <= currentWeekEndStr) {
|
||||||
const rota = getSchCost(dep.department_id, dateStr)
|
const rota = getSchCost(dep.department_id, dateStr)
|
||||||
if (rota != null) { forecastRem += rota; continue }
|
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
|
||||||
|
|
@ -178,7 +194,7 @@ export default function Monthly() {
|
||||||
if (dateStr <= todayStr) {
|
if (dateStr <= todayStr) {
|
||||||
deptCost += srcDep?.days[dateStr]?.cost ?? 0
|
deptCost += srcDep?.days[dateStr]?.cost ?? 0
|
||||||
} else {
|
} else {
|
||||||
const rota = getSchCost(dep.department_id, dateStr)
|
const rota = dateStr <= currentWeekEndStr ? getSchCost(dep.department_id, dateStr) : null
|
||||||
if (rota != null) {
|
if (rota != null) {
|
||||||
deptCost += rota
|
deptCost += rota
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -219,7 +235,11 @@ export default function Monthly() {
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
<div className="label">Forecast EOM</div>
|
<div className="label">Forecast EOM</div>
|
||||||
<div className="value">{fmtMoney(totalForecast)}</div>
|
<div className="value">{fmtMoney(totalForecast)}</div>
|
||||||
<div className="sub">rota + prior-week actual</div>
|
<div className="sub">
|
||||||
|
{pyWagesFull != null
|
||||||
|
? `PY ${fmtMoney(pyWagesFull)}${pyPct(totalForecast, pyWagesFull)}`
|
||||||
|
: 'rota + prior-week actual'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
|
|
@ -228,10 +248,8 @@ export default function Monthly() {
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
<div className="label">% Budget {isCurrentMonth ? '(Forecast)' : ''}</div>
|
<div className="label">% Budget {isCurrentMonth ? '(Forecast)' : ''}</div>
|
||||||
<div className="value">
|
<div className="value" style={pctBudget != null ? { color: pctBudget <= 100 ? 'var(--app-primary)' : pctBudget <= 110 ? '#b45309' : '#dc2626' } : {}}>
|
||||||
{pctBudget != null
|
{pctBudget != null ? fmtDelta(pctBudget) : '—'}
|
||||||
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{fmtDelta(pctBudget)}</span>
|
|
||||||
: '—'}
|
|
||||||
</div>
|
</div>
|
||||||
{variance != null && (
|
{variance != null && (
|
||||||
<div className={`sub ${variance > 0 ? 'variance-over' : 'variance-under'}`}>
|
<div className={`sub ${variance > 0 ? 'variance-over' : 'variance-under'}`}>
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ export default function SettingsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBackfill = async () => {
|
const handleBackfill = async () => {
|
||||||
if (!confirm('Start deep backfill? This will fetch ~13 months of Workforce data and may take a few minutes.')) return
|
if (!confirm('Start deep backfill? This will fetch ~25 months of Workforce data and may take several minutes.')) return
|
||||||
setBackfillProg({ processed: 0, total: 1, current: '…' })
|
setBackfillProg({ processed: 0, total: 1, current: '…' })
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
|
|
@ -240,7 +240,7 @@ export default function SettingsPage() {
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={handleBackfill} disabled={syncing || backfillProg != null}>
|
<button className="btn btn-secondary" onClick={handleBackfill} disabled={syncing || backfillProg != null}>
|
||||||
<Download size={14} strokeWidth={1.75} />
|
<Download size={14} strokeWidth={1.75} />
|
||||||
Deep Backfill (13 months)
|
Deep Backfill (25 months)
|
||||||
</button>
|
</button>
|
||||||
{backfillProg && (
|
{backfillProg && (
|
||||||
<button className="btn btn-secondary" onClick={handleCancelBackfill}>
|
<button className="btn btn-secondary" onClick={handleCancelBackfill}>
|
||||||
|
|
|
||||||
|
|
@ -197,10 +197,8 @@ export default function Weekly() {
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
<div className="label">% vs Budget</div>
|
<div className="label">% vs Budget</div>
|
||||||
<div className="value">
|
<div className="value" style={pctBudget != null ? { color: pctBudget <= 100 ? 'var(--app-primary)' : pctBudget <= 110 ? '#b45309' : '#dc2626' } : {}}>
|
||||||
{pctBudget != null
|
{pctBudget != null ? fmtDelta(pctBudget) : '—'}
|
||||||
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{fmtDelta(pctBudget)}</span>
|
|
||||||
: '—'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue