Weight WTD/MTD budget split by PY DOW-matched sales instead of flat day count
Distributes the monthly wage budget across days proportionally to each day's share of last year's same-day-of-week sales, so pacing on Weekly/ Monthly views reflects real demand shape (e.g. weekend-heavy) rather than an even calendar split. Falls back to the old flat day-count split when PY sales data is unavailable. Weekly also now sources each day's budget from its own calendar month, so weeks spanning a month boundary split correctly across both months' budgets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
baba818693
commit
47e899f636
2 changed files with 73 additions and 20 deletions
|
|
@ -51,6 +51,7 @@ export default function Monthly() {
|
||||||
const [pyTableOpen, setPyTableOpen] = useState(false)
|
const [pyTableOpen, setPyTableOpen] = useState(false)
|
||||||
const [prevMonthDepts, setPrevMonthDepts] = useState<{id: string; cost: number}[]>([])
|
const [prevMonthDepts, setPrevMonthDepts] = useState<{id: string; cost: number}[]>([])
|
||||||
const [budget, setBudget] = useState<number | null>(null)
|
const [budget, setBudget] = useState<number | null>(null)
|
||||||
|
const [pySalesByDay, setPySalesByDay] = useState<Record<string, number>>({})
|
||||||
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
|
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
|
||||||
const [showOncosts, setShowOncosts] = useState(true)
|
const [showOncosts, setShowOncosts] = useState(true)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -100,6 +101,10 @@ export default function Monthly() {
|
||||||
setPySalesMTD(salesRes.days.filter(d => d.date <= cutoff).reduce((s, d) => s + d.py_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))
|
setPySalesFull(salesRes.days.reduce((s, d) => s + d.py_sales, 0))
|
||||||
|
|
||||||
|
const pyMap: Record<string, number> = {}
|
||||||
|
for (const d of salesRes.days) pyMap[d.date] = d.py_sales
|
||||||
|
setPySalesByDay(pyMap)
|
||||||
|
|
||||||
// PY MTD: same day number as yesterday in PY (or full month for past months)
|
// PY MTD: same day number as yesterday in PY (or full month for past months)
|
||||||
const yDay = parseInt(yesterdayStr.slice(8))
|
const yDay = parseInt(yesterdayStr.slice(8))
|
||||||
const pyMtdLimit = isCurrentMonth
|
const pyMtdLimit = isCurrentMonth
|
||||||
|
|
@ -179,9 +184,16 @@ export default function Monthly() {
|
||||||
const pyTotalWagesFull = pyDepts.reduce((s, d) => s + d.costFull, 0)
|
const pyTotalWagesFull = pyDepts.reduce((s, d) => s + d.costFull, 0)
|
||||||
const prevMonthTotal = prevMonthDepts.reduce((s, d) => s + d.cost, 0)
|
const prevMonthTotal = prevMonthDepts.reduce((s, d) => s + d.cost, 0)
|
||||||
|
|
||||||
// MTD row: pro-rata budget to yesterday
|
// MTD row: budget split across the month by each day's share of PY (DOW-matched) sales,
|
||||||
|
// so a weekend-heavy budget lands more on weekends rather than an even calendar split.
|
||||||
|
// Falls back to flat day-count proration when PY sales data isn't available.
|
||||||
const yDay = isCurrentMonth ? parseInt(yesterdayStr.slice(8)) : dim
|
const yDay = isCurrentMonth ? parseInt(yesterdayStr.slice(8)) : dim
|
||||||
const budgetMTD = budget != null ? budget * yDay / dim : null
|
const monthPyTotal = Object.values(pySalesByDay).reduce((s, v) => s + v, 0)
|
||||||
|
const isBudgetWeighted = monthPyTotal > 0
|
||||||
|
const mtdFrac = isBudgetWeighted
|
||||||
|
? Object.entries(pySalesByDay).filter(([d]) => d <= cutoff).reduce((s, [, v]) => s + v, 0) / monthPyTotal
|
||||||
|
: yDay / dim
|
||||||
|
const budgetMTD = budget != null ? budget * mtdFrac : null
|
||||||
const varianceMTD = budgetMTD != null ? totalActual - budgetMTD : null
|
const varianceMTD = budgetMTD != null ? totalActual - budgetMTD : null
|
||||||
const pctBudgMTD = budgetMTD != null && budgetMTD > 0 ? (totalActual / budgetMTD) * 100 : null
|
const pctBudgMTD = budgetMTD != null && budgetMTD > 0 ? (totalActual / budgetMTD) * 100 : null
|
||||||
const pctSalesMTD = netSalesMTD > 0 ? (totalActual / netSalesMTD) * 100 : null
|
const pctSalesMTD = netSalesMTD > 0 ? (totalActual / netSalesMTD) * 100 : null
|
||||||
|
|
@ -253,7 +265,7 @@ export default function Monthly() {
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
<div className="label">Budget MTD</div>
|
<div className="label">Budget MTD</div>
|
||||||
<div className="value">{budgetMTD != null ? fmtMoney(budgetMTD) : '—'}</div>
|
<div className="value">{budgetMTD != null ? fmtMoney(budgetMTD) : '—'}</div>
|
||||||
<div className="sub">pro-rata {yDay}/{dim} days</div>
|
<div className="sub">{isBudgetWeighted ? 'sales-weighted (PY DOW)' : `pro-rata ${yDay}/${dim} days`}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
<div className="label">% Budget MTD</div>
|
<div className="label">% Budget MTD</div>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,13 @@ function daysInMonthFor(dateStr: string): number {
|
||||||
const d = new Date(dateStr + 'T00:00:00')
|
const d = new Date(dateStr + 'T00:00:00')
|
||||||
return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
|
return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
|
||||||
}
|
}
|
||||||
|
function monthKeyOf(dateStr: string): string {
|
||||||
|
return dateStr.slice(0, 7)
|
||||||
|
}
|
||||||
|
function monthRangeOf(monthKey: string): { from: string; to: string } {
|
||||||
|
const from = `${monthKey}-01`
|
||||||
|
return { from, to: `${monthKey}-${String(daysInMonthFor(from)).padStart(2, '0')}` }
|
||||||
|
}
|
||||||
function fmtDisplay(dateStr: string): string {
|
function fmtDisplay(dateStr: string): string {
|
||||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
|
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +74,9 @@ export default function Weekly() {
|
||||||
const [pySalesFull, setPySalesFull] = useState(0)
|
const [pySalesFull, setPySalesFull] = 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 [pyDepts, setPyDepts] = useState<{id: string; name: string; cost: number}[]>([])
|
||||||
const [monthlyBudg, setMonthlyBudg] = useState<number | null>(null)
|
const [budgetsByMonth, setBudgetsByMonth] = useState<Record<string, number>>({}) // monthKey (YYYY-MM) -> amount
|
||||||
|
const [pySalesByDay, setPySalesByDay] = useState<Record<string, number>>({}) // date -> PY (DOW-matched) sales
|
||||||
|
const [monthPyTotals, setMonthPyTotals] = useState<Record<string, number>>({}) // monthKey -> total PY sales
|
||||||
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
|
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
|
||||||
const [showOncosts, setShowOncosts] = useState(true)
|
const [showOncosts, setShowOncosts] = useState(true)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -76,17 +85,40 @@ export default function Weekly() {
|
||||||
const [modalEmps, setModalEmps] = useState<EmployeeDetail[] | null>(null)
|
const [modalEmps, setModalEmps] = useState<EmployeeDetail[] | null>(null)
|
||||||
const [modalLoad, setModalLoad] = useState(false)
|
const [modalLoad, setModalLoad] = useState(false)
|
||||||
|
|
||||||
|
// A week can span two calendar months (e.g. Mon 26 – Sun 1) — budget/PY-sales weighting
|
||||||
|
// must be sourced per-day from whichever month that day actually belongs to.
|
||||||
|
const weekMonthKeys = Array.from(new Set([monthKeyOf(fromStr), monthKeyOf(toStr)]))
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true); setError(null)
|
setLoading(true); setError(null)
|
||||||
try {
|
try {
|
||||||
const isCurrentWk = fromStr === mondayOf(new Date())
|
const isCurrentWk = fromStr === mondayOf(new Date())
|
||||||
const [actRes, salesRes, budgetRes, pyActRes] = await Promise.all([
|
const monthKeys = Array.from(new Set([monthKeyOf(fromStr), monthKeyOf(toStr)]))
|
||||||
|
const monthRanges = monthKeys.map(monthRangeOf)
|
||||||
|
|
||||||
|
const [actRes, salesRes, budgetRes, pyActRes, monthSalesResList] = await Promise.all([
|
||||||
// 14-day fetch: prev week + current week so prev-week data is in dep.days for forecast + comparison
|
// 14-day fetch: prev week + current week so prev-week data is in dep.days for forecast + comparison
|
||||||
getActuals(prevWeekFrom, toStr),
|
getActuals(prevWeekFrom, toStr),
|
||||||
getNetSales(fromStr, toStr),
|
getNetSales(fromStr, toStr),
|
||||||
getBudgets(),
|
getBudgets(),
|
||||||
getActuals(pyFromStr, pyToStr),
|
getActuals(pyFromStr, pyToStr),
|
||||||
|
Promise.all(monthRanges.map(r => getNetSales(r.from, r.to))),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const pyMap: Record<string, number> = {}
|
||||||
|
const monthTotals: Record<string, number> = {}
|
||||||
|
monthKeys.forEach((key, i) => {
|
||||||
|
let total = 0
|
||||||
|
for (const d of monthSalesResList[i].days) { pyMap[d.date] = d.py_sales; total += d.py_sales }
|
||||||
|
monthTotals[key] = total
|
||||||
|
})
|
||||||
|
setPySalesByDay(pyMap)
|
||||||
|
setMonthPyTotals(monthTotals)
|
||||||
|
|
||||||
|
const budgMap: Record<string, number> = {}
|
||||||
|
for (const b of (budgetRes.budgets as WageBudget[])) budgMap[b.month.slice(0, 7)] = b.budget_amount
|
||||||
|
setBudgetsByMonth(budgMap)
|
||||||
|
|
||||||
setDepts(actRes.departments)
|
setDepts(actRes.departments)
|
||||||
setShowOncosts(actRes.show_oncosts)
|
setShowOncosts(actRes.show_oncosts)
|
||||||
setDeptPcts(actRes.dept_pcts)
|
setDeptPcts(actRes.dept_pcts)
|
||||||
|
|
@ -107,12 +139,6 @@ export default function Weekly() {
|
||||||
setPyDepts(pyDeptList)
|
setPyDepts(pyDeptList)
|
||||||
const pyTotal = pyDeptList.reduce((s, d) => s + d.cost, 0)
|
const pyTotal = pyDeptList.reduce((s, d) => s + d.cost, 0)
|
||||||
setPyWages(pyTotal > 0 ? pyTotal : null)
|
setPyWages(pyTotal > 0 ? pyTotal : null)
|
||||||
|
|
||||||
// Budget: WTD pro-rata (to yesterday) stored; full-week derived in component body
|
|
||||||
const d0 = new Date(fromStr + 'T00:00:00')
|
|
||||||
const monthKey = `${d0.getFullYear()}-${String(d0.getMonth() + 1).padStart(2, '0')}-01`
|
|
||||||
const bRow = (budgetRes.budgets as WageBudget[]).find(b => b.month === monthKey)
|
|
||||||
setMonthlyBudg(bRow ? bRow.budget_amount : null)
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -134,17 +160,32 @@ export default function Weekly() {
|
||||||
const prev = () => setFromStr(s => addDaysStr(s, -7))
|
const prev = () => setFromStr(s => addDaysStr(s, -7))
|
||||||
const next = () => setFromStr(s => addDaysStr(s, 7))
|
const next = () => setFromStr(s => addDaysStr(s, 7))
|
||||||
|
|
||||||
// Budget derived from monthlyBudg
|
|
||||||
const dim = daysInMonthFor(fromStr)
|
|
||||||
const weekDaysWTD = daysElapsed + 1 // Mon to yesterday inclusive (or 7 for past weeks)
|
|
||||||
const budgetWTD = monthlyBudg != null ? monthlyBudg * weekDaysWTD / dim : null
|
|
||||||
const budgetFull = monthlyBudg != null ? monthlyBudg * 7 / dim : null
|
|
||||||
|
|
||||||
// Per-dept rows computed from 14-day actuals
|
// Per-dept rows computed from 14-day actuals
|
||||||
const wtdCutoff = isCurrentWeek ? yesterdayStr : toStr
|
const wtdCutoff = isCurrentWeek ? yesterdayStr : toStr
|
||||||
// Prev week comparison cutoff: same elapsed days as WTD (Mon-N of prev week)
|
// Prev week comparison cutoff: same elapsed days as WTD (Mon-N of prev week)
|
||||||
const prevCutoff = isCurrentWeek ? addDaysStr(yesterdayStr, -7) : prevWeekTo
|
const prevCutoff = isCurrentWeek ? addDaysStr(yesterdayStr, -7) : prevWeekTo
|
||||||
|
|
||||||
|
// Budget for a single day: sourced from THAT day's own calendar month (a week can span two
|
||||||
|
// months), weighted by its share of that month's PY (DOW-matched) sales. Falls back to a flat
|
||||||
|
// day-count split within that month when PY sales data isn't available.
|
||||||
|
const dayBudget = (dateStr: string): number => {
|
||||||
|
const mKey = monthKeyOf(dateStr)
|
||||||
|
const monthBudget = budgetsByMonth[mKey]
|
||||||
|
if (monthBudget == null) return 0
|
||||||
|
const monthTotal = monthPyTotals[mKey] ?? 0
|
||||||
|
if (monthTotal > 0) return monthBudget * ((pySalesByDay[dateStr] ?? 0) / monthTotal)
|
||||||
|
return monthBudget / daysInMonthFor(`${mKey}-01`)
|
||||||
|
}
|
||||||
|
const sumBudget = (fromD: string, toD: string): number => {
|
||||||
|
let s = 0
|
||||||
|
for (let d = fromD; d <= toD; d = addDaysStr(d, 1)) s += dayBudget(d)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
const hasBudget = weekMonthKeys.some(k => budgetsByMonth[k] != null)
|
||||||
|
const isBudgetWeighted = weekMonthKeys.every(k => (monthPyTotals[k] ?? 0) > 0)
|
||||||
|
const budgetWTD = hasBudget ? sumBudget(fromStr, wtdCutoff) : null
|
||||||
|
const budgetFull = hasBudget ? sumBudget(fromStr, toStr) : null
|
||||||
|
|
||||||
type DeptRow = {
|
type DeptRow = {
|
||||||
department_id: string
|
department_id: string
|
||||||
department_name: string
|
department_name: string
|
||||||
|
|
@ -231,7 +272,7 @@ export default function Weekly() {
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
<div className="label">Budget WTD</div>
|
<div className="label">Budget WTD</div>
|
||||||
<div className="value">{budgetWTD != null ? fmtMoney(budgetWTD) : '—'}</div>
|
<div className="value">{budgetWTD != null ? fmtMoney(budgetWTD) : '—'}</div>
|
||||||
<div className="sub">proportion of monthly</div>
|
<div className="sub">{isBudgetWeighted ? 'sales-weighted (PY DOW)' : 'proportion of monthly'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
<div className="label">% Budget WTD</div>
|
<div className="label">% Budget WTD</div>
|
||||||
|
|
@ -263,7 +304,7 @@ export default function Weekly() {
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
<div className="label">Budget Full Week</div>
|
<div className="label">Budget Full Week</div>
|
||||||
<div className="value">{budgetFull != null ? fmtMoney(budgetFull) : '—'}</div>
|
<div className="value">{budgetFull != null ? fmtMoney(budgetFull) : '—'}</div>
|
||||||
<div className="sub">7-day proportion</div>
|
<div className="sub">{isBudgetWeighted ? 'sales-weighted (PY DOW)' : '7-day proportion'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
<div className="label">% Budget (Forecast)</div>
|
<div className="label">% Budget (Forecast)</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue