diff --git a/frontend/src/pages/Monthly.tsx b/frontend/src/pages/Monthly.tsx index 6afe5fb..3365996 100644 --- a/frontend/src/pages/Monthly.tsx +++ b/frontend/src/pages/Monthly.tsx @@ -51,6 +51,7 @@ export default function Monthly() { const [pyTableOpen, setPyTableOpen] = useState(false) const [prevMonthDepts, setPrevMonthDepts] = useState<{id: string; cost: number}[]>([]) const [budget, setBudget] = useState(null) + const [pySalesByDay, setPySalesByDay] = useState>({}) const [deptPcts, setDeptPcts] = useState>({}) const [showOncosts, setShowOncosts] = 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)) setPySalesFull(salesRes.days.reduce((s, d) => s + d.py_sales, 0)) + const pyMap: Record = {} + 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) const yDay = parseInt(yesterdayStr.slice(8)) const pyMtdLimit = isCurrentMonth @@ -179,9 +184,16 @@ export default function Monthly() { const pyTotalWagesFull = pyDepts.reduce((s, d) => s + d.costFull, 0) const prevMonthTotal = prevMonthDepts.reduce((s, d) => s + d.cost, 0) - // MTD row: pro-rata budget to yesterday - const yDay = isCurrentMonth ? parseInt(yesterdayStr.slice(8)) : dim - const budgetMTD = budget != null ? budget * yDay / dim : null + // 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 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 pctBudgMTD = budgetMTD != null && budgetMTD > 0 ? (totalActual / budgetMTD) * 100 : null const pctSalesMTD = netSalesMTD > 0 ? (totalActual / netSalesMTD) * 100 : null @@ -253,7 +265,7 @@ export default function Monthly() {
Budget MTD
{budgetMTD != null ? fmtMoney(budgetMTD) : '—'}
-
pro-rata {yDay}/{dim} days
+
{isBudgetWeighted ? 'sales-weighted (PY DOW)' : `pro-rata ${yDay}/${dim} days`}
% Budget MTD
diff --git a/frontend/src/pages/Weekly.tsx b/frontend/src/pages/Weekly.tsx index 1fc68fb..3c934da 100644 --- a/frontend/src/pages/Weekly.tsx +++ b/frontend/src/pages/Weekly.tsx @@ -23,6 +23,13 @@ function daysInMonthFor(dateStr: string): number { const d = new Date(dateStr + 'T00:00:00') 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 { 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 [pyWages, setPyWages] = useState(null) const [pyDepts, setPyDepts] = useState<{id: string; name: string; cost: number}[]>([]) - const [monthlyBudg, setMonthlyBudg] = useState(null) + const [budgetsByMonth, setBudgetsByMonth] = useState>({}) // monthKey (YYYY-MM) -> amount + const [pySalesByDay, setPySalesByDay] = useState>({}) // date -> PY (DOW-matched) sales + const [monthPyTotals, setMonthPyTotals] = useState>({}) // monthKey -> total PY sales const [deptPcts, setDeptPcts] = useState>({}) const [showOncosts, setShowOncosts] = useState(true) const [loading, setLoading] = useState(true) @@ -76,17 +85,40 @@ export default function Weekly() { const [modalEmps, setModalEmps] = useState(null) 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 () => { setLoading(true); setError(null) try { 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 getActuals(prevWeekFrom, toStr), getNetSales(fromStr, toStr), getBudgets(), getActuals(pyFromStr, pyToStr), + Promise.all(monthRanges.map(r => getNetSales(r.from, r.to))), ]) + + const pyMap: Record = {} + const monthTotals: Record = {} + 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 = {} + for (const b of (budgetRes.budgets as WageBudget[])) budgMap[b.month.slice(0, 7)] = b.budget_amount + setBudgetsByMonth(budgMap) + setDepts(actRes.departments) setShowOncosts(actRes.show_oncosts) setDeptPcts(actRes.dept_pcts) @@ -107,12 +139,6 @@ export default function Weekly() { setPyDepts(pyDeptList) const pyTotal = pyDeptList.reduce((s, d) => s + d.cost, 0) 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) { setError(e instanceof Error ? e.message : 'Failed to load') } finally { @@ -134,17 +160,32 @@ export default function Weekly() { const prev = () => 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 const wtdCutoff = isCurrentWeek ? yesterdayStr : toStr // Prev week comparison cutoff: same elapsed days as WTD (Mon-N of prev week) 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 = { department_id: string department_name: string @@ -231,7 +272,7 @@ export default function Weekly() {
Budget WTD
{budgetWTD != null ? fmtMoney(budgetWTD) : '—'}
-
proportion of monthly
+
{isBudgetWeighted ? 'sales-weighted (PY DOW)' : 'proportion of monthly'}
% Budget WTD
@@ -263,7 +304,7 @@ export default function Weekly() {
Budget Full Week
{budgetFull != null ? fmtMoney(budgetFull) : '—'}
-
7-day proportion
+
{isBudgetWeighted ? 'sales-weighted (PY DOW)' : '7-day proportion'}
% Budget (Forecast)