From f97772cb72971b1bd3f46ea922244d0978fae78d Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 23 Jul 2026 09:25:51 +0000 Subject: [PATCH] Fix Monthly net sales range + chart data; Budgets year nav + CSV upload --- frontend/src/pages/Budgets.tsx | 199 ++++++++++++++++++++++----------- frontend/src/pages/Monthly.tsx | 153 +++++++++++++------------ 2 files changed, 212 insertions(+), 140 deletions(-) diff --git a/frontend/src/pages/Budgets.tsx b/frontend/src/pages/Budgets.tsx index 0fbc320..e58504b 100644 --- a/frontend/src/pages/Budgets.tsx +++ b/frontend/src/pages/Budgets.tsx @@ -1,41 +1,31 @@ import { useState, useEffect, useRef } from 'react' +import { ChevronLeft, ChevronRight, Download, Upload } from 'lucide-react' import { getBudgets, saveBudget } from '../api' import type { WageBudget } from '../types' function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() } -function getMonthRange(): { year: number; month: number }[] { - const today = new Date() - const months: { year: number; month: number }[] = [] - for (let i = -3; i <= 3; i++) { - let m = today.getMonth() + 1 + i - let y = today.getFullYear() - while (m <= 0) { m += 12; y-- } - while (m > 12) { m -= 12; y++ } - months.push({ year: y, month: m }) - } - return months -} - const MONTH_LABELS = ['January','February','March','April','May','June','July','August','September','October','November','December'] +const MONTH_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] export default function Budgets() { - const [budgets, setBudgets] = useState>({}) - const [editing, setEditing] = useState>({}) - const [saving, setSaving] = useState>({}) - const [error, setError] = useState(null) - const [loading, setLoading] = useState(true) - const inputRefs = useRef>({}) - - const months = getMonthRange() + const thisYear = new Date().getFullYear() + const [viewYear, setViewYear] = useState(thisYear) + const [budgets, setBudgets] = useState>({}) + const [editing, setEditing] = useState>({}) + const [saving, setSaving] = useState>({}) + const [uploadMsg, setUploadMsg] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const inputRefs = useRef>({}) + const fileRef = useRef(null) useEffect(() => { getBudgets() .then(res => { const map: Record = {} for (const b of res.budgets as WageBudget[]) { - const key = b.month.slice(0, 7) // YYYY-MM - map[key] = b.budget_amount + map[b.month.slice(0, 7)] = b.budget_amount } setBudgets(map) }) @@ -43,11 +33,10 @@ export default function Budgets() { .finally(() => setLoading(false)) }, []) - const monthKey = (y: number, m: number) => `${y}-${String(m).padStart(2, '0')}` + const monthKey = (m: number) => `${viewYear}-${String(m).padStart(2, '0')}` const handleFocus = (key: string) => { - const current = budgets[key] - setEditing(e => ({ ...e, [key]: current != null ? String(current) : '' })) + setEditing(e => ({ ...e, [key]: budgets[key] != null ? String(budgets[key]) : '' })) } const handleChange = (key: string, val: string) => { @@ -56,15 +45,9 @@ export default function Budgets() { const handleSave = async (key: string) => { const raw = editing[key]?.trim() - if (raw === '') { - setEditing(e => { const n = { ...e }; delete n[key]; return n }) - return - } + if (raw === '') { setEditing(e => { const n = { ...e }; delete n[key]; return n }); return } const amount = parseFloat(raw) - if (isNaN(amount)) { - setEditing(e => { const n = { ...e }; delete n[key]; return n }) - return - } + if (isNaN(amount)) { setEditing(e => { const n = { ...e }; delete n[key]; return n }); return } setSaving(s => ({ ...s, [key]: true })) try { await saveBudget(key, amount) @@ -77,86 +60,166 @@ export default function Budgets() { } } - const handleKeyDown = (key: string, e: React.KeyboardEvent) => { + const handleKeyDown = (key: string, e: React.KeyboardEvent, monthIdx: number) => { if (e.key === 'Enter') { e.preventDefault() handleSave(key) - // Tab focus to next - const keys = months.map(m => monthKey(m.year, m.month)) - const idx = keys.indexOf(key) - if (idx >= 0 && idx < keys.length - 1) { - setTimeout(() => inputRefs.current[keys[idx + 1]]?.focus(), 50) + if (monthIdx < 11) { + const nextKey = monthKey(monthIdx + 2) + setTimeout(() => inputRefs.current[nextKey]?.focus(), 50) } } - if (e.key === 'Escape') { - setEditing(e2 => { const n = { ...e2 }; delete n[key]; return n }) + if (e.key === 'Escape') setEditing(e2 => { const n = { ...e2 }; delete n[key]; return n }) + } + + // CSV template download + const handleDownloadTemplate = () => { + const rows = ['Month,Budget'] + for (let m = 1; m <= 12; m++) { + const key = monthKey(m) + rows.push(`${MONTH_SHORT[m-1]} ${viewYear},${budgets[key] ?? ''}`) } + const blob = new Blob([rows.join('\n')], { type: 'text/csv' }) + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = `wages-budget-${viewYear}.csv` + a.click() + URL.revokeObjectURL(a.href) + } + + // CSV bulk upload + const handleUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + setUploadMsg(null); setError(null) + const reader = new FileReader() + reader.onload = async (ev) => { + const text = ev.target?.result as string + const lines = text.split(/\r?\n/).filter(l => l.trim() && !l.startsWith('Month')) + let saved = 0, skipped = 0 + for (const line of lines) { + const [monthRaw, amountRaw] = line.split(',').map(s => s.trim()) + if (!monthRaw || !amountRaw) { skipped++; continue } + const amount = parseFloat(amountRaw) + if (isNaN(amount)) { skipped++; continue } + + // Parse "Jan 2025" or "January 2025" or "2025-01" + let key: string | null = null + const isoMatch = monthRaw.match(/^(\d{4})-(\d{2})$/) + if (isoMatch) { + key = `${isoMatch[1]}-${isoMatch[2]}` + } else { + const parts = monthRaw.split(' ') + if (parts.length === 2) { + const yr = parseInt(parts[1]) + const mi = MONTH_SHORT.findIndex(m => m.toLowerCase() === parts[0].toLowerCase().slice(0, 3)) + ?? MONTH_LABELS.findIndex(m => m.toLowerCase() === parts[0].toLowerCase()) + if (!isNaN(yr) && mi >= 0) key = `${yr}-${String(mi + 1).padStart(2, '0')}` + } + } + if (!key) { skipped++; continue } + + try { + await saveBudget(key, amount) + setBudgets(b => ({ ...b, [key!]: amount })) + saved++ + } catch { + skipped++ + } + } + setUploadMsg(`Uploaded: ${saved} saved${skipped ? `, ${skipped} skipped` : ''}`) + if (fileRef.current) fileRef.current.value = '' + } + reader.readAsText(file) } if (loading) return
Loading…
+ const totalYear = Array.from({ length: 12 }, (_, i) => budgets[monthKey(i + 1)] ?? 0).reduce((s, v) => s + v, 0) + return (

Wage Budgets

+
+ + + +
- {error &&
{error}
} + {error &&
{error}
} + {uploadMsg &&
{uploadMsg}
}
+
+
+ + {viewYear} + +
+
+ Year total: + {totalYear > 0 ? `£${Math.round(totalYear).toLocaleString('en-GB')}` : '—'} + +
+
+

- Enter the total monthly wages budget (FD figure). Click a cell to edit, press Enter or Tab to save. + Click a cell to edit, press Enter to save and move to next month. + Or download the CSV template, fill it in, and upload.

+ - + - {months.map(({ year, month }) => { - const key = monthKey(year, month) - const current = budgets[key] - const isEditing = key in editing - const dim = daysInMonth(year, month) - const weekly = current != null ? (current * 7 / dim) : null + {Array.from({ length: 12 }, (_, i) => { + const m = i + 1 + const key = monthKey(m) + const curr = budgets[key] + const isEdit = key in editing + const weekly = curr != null ? (curr * 7 / daysInMonth(viewYear, m)) : null + const isThisMonth = viewYear === thisYear && m === new Date().getMonth() + 1 return ( - - + diff --git a/frontend/src/pages/Monthly.tsx b/frontend/src/pages/Monthly.tsx index 12d0d15..b179376 100644 --- a/frontend/src/pages/Monthly.tsx +++ b/frontend/src/pages/Monthly.tsx @@ -15,38 +15,43 @@ function pctClass(pct: number): string { return pct <= 100 ? 'pct-green' : pct < const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d'] export default function Monthly() { - const today = new Date() - const [year, setYear] = useState(today.getFullYear()) - const [month, setMonth] = useState(today.getMonth() + 1) // 1-based + const todayStr = new Date().toISOString().slice(0, 10) + const todayYear = parseInt(todayStr.slice(0, 4)) + const todayMonth = parseInt(todayStr.slice(5, 7)) - const [depts, setDepts] = useState([]) - const [scheduled, setScheduled] = useState>>({}) // dept_id → date → cost - const [netSales, setNetSales] = useState(0) - const [budget, setBudget] = useState(null) + const [year, setYear] = useState(todayYear) + const [month, setMonth] = useState(todayMonth) + + const [depts, setDepts] = useState([]) + const [scheduled, setScheduled] = useState>>({}) + const [netSales, setNetSales] = useState(0) + const [budget, setBudget] = useState(null) const [showOncosts, setShowOncosts] = useState(true) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) const dim = daysInMonth(year, month) const monthStr = `${year}-${String(month).padStart(2, '0')}` const fromStr = `${monthStr}-01` const toStr = `${monthStr}-${String(dim).padStart(2, '0')}` - const todayStr = fmt(today) - const isCurrentMonth = year === today.getFullYear() && month === today.getMonth() + 1 + const isCurrentMonth = year === todayYear && month === todayMonth + // For net sales: cap at today for current month, use month-end for past months + const salesTo = isCurrentMonth ? todayStr : toStr + // For scheduled: only relevant if month includes future dates + const schedFrom = todayStr < toStr ? todayStr : toStr const load = useCallback(async () => { setLoading(true); setError(null) try { const [actRes, schRes, salesRes, budRes] = await Promise.all([ getActuals(fromStr, toStr), - getScheduled(todayStr, toStr), - getNetSales(fromStr, todayStr), + getScheduled(schedFrom, toStr), + getNetSales(fromStr, salesTo), // salesTo caps at today for current month getBudgets(), ]) setDepts(actRes.departments) setShowOncosts(actRes.show_oncosts) - // Build scheduled map const schMap: Record> = {} for (const dep of schRes.departments) { schMap[dep.department_id] = {} @@ -58,42 +63,41 @@ export default function Monthly() { setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0)) - const bRow = budRes.budgets.find(b => b.month === `${fromStr}`) + const bRow = budRes.budgets.find((b: WageBudget) => b.month === fromStr) setBudget(bRow ? bRow.budget_amount : null) } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed to load') } finally { setLoading(false) } - }, [fromStr, toStr, todayStr]) + }, [fromStr, toStr, schedFrom, salesTo]) useEffect(() => { load() }, [load]) 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) } } + function getSchCost(deptId: string, date: string): number | null { + return scheduled[deptId]?.[date] ?? null + } + // Build dept summary: actual MTD + forecast EOM const deptSummary = depts.map((dep, idx) => { const actualMTD = Object.entries(dep.days) - .filter(([d]) => d <= todayStr) + .filter(([d]) => d <= todayStr && d >= fromStr && d <= toStr) .reduce((s, [, v]) => s + v.cost, 0) - // Forecast remaining days let forecastRem = 0 - for (let day = 1; day <= dim; day++) { - const dateStr = `${monthStr}-${String(day).padStart(2, '0')}` - if (dateStr <= todayStr) continue - - // Priority: rota → prior week same DoW actual - const rotaCost = schMap(dep.department_id, dateStr) - if (rotaCost != null) { - forecastRem += rotaCost - continue + if (isCurrentMonth) { + for (let day = 1; day <= dim; day++) { + const dateStr = `${monthStr}-${String(day).padStart(2, '0')}` + if (dateStr <= todayStr) continue + 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 priorCost = dep.days[priorStr]?.cost + if (priorCost != null) forecastRem += priorCost } - const priorDate = addDays(new Date(dateStr + 'T00:00:00'), -7) - const priorStr = fmt(priorDate) - const priorCost = dep.days[priorStr]?.cost - if (priorCost != null) forecastRem += priorCost } return { @@ -105,39 +109,43 @@ export default function Monthly() { } }).sort((a, b) => b.forecast_eom - a.forecast_eom) - function schMap(deptId: string, date: string): number | null { - return scheduled[deptId]?.[date] ?? null - } - const totalActual = deptSummary.reduce((s, d) => s + d.actual_mtd, 0) const totalForecast = deptSummary.reduce((s, d) => s + d.forecast_eom, 0) const pctBudget = budget != null && budget > 0 ? (totalForecast / budget) * 100 : null const pctSales = netSales > 0 ? (totalActual / netSales) * 100 : null const variance = budget != null ? totalForecast - budget : null - // Build chart data: group by week - const weeks: { label: string; actual: number; forecast: number; isPast: boolean }[] = [] + // Build chart data with per-dept costs per week so Bar dataKey works + type WeekEntry = { label: string; isPast: boolean; [dept: string]: number | boolean | string } + const weeks: WeekEntry[] = [] for (let w = 0; w * 7 < dim; w++) { const wStart = w * 7 + 1 const wEnd = Math.min(wStart + 6, dim) - const wEndDate = new Date(`${monthStr}-${String(wEnd).padStart(2, '0')}T00:00:00`) - const isPast = wEndDate < today + const wEndStr = `${monthStr}-${String(wEnd).padStart(2, '0')}` + const isPast = wEndStr < todayStr - let actual = 0, forecast = 0 - for (let day = wStart; day <= wEnd; day++) { - const dateStr = `${monthStr}-${String(day).padStart(2, '0')}` - const isActual = dateStr <= todayStr - const total = deptSummary.reduce((s, dep) => { - if (isActual) return s + (depts.find(d => d.department_id === dep.department_id)?.days[dateStr]?.cost ?? 0) - const rota = schMap(dep.department_id, dateStr) - if (rota != null) return s + rota - const prior = depts.find(d => d.department_id === dep.department_id)?.days[fmt(addDays(new Date(dateStr + 'T00:00:00'), -7))]?.cost ?? 0 - return s + prior - }, 0) - if (isActual) actual += total; else forecast += total + const entry: WeekEntry = { label: `W${w + 1}`, isPast } + + for (const dep of deptSummary) { + const srcDep = depts.find(d => d.department_id === dep.department_id) + let deptCost = 0 + for (let day = wStart; day <= wEnd; day++) { + const dateStr = `${monthStr}-${String(day).padStart(2, '0')}` + if (dateStr <= todayStr) { + deptCost += srcDep?.days[dateStr]?.cost ?? 0 + } else { + const rota = getSchCost(dep.department_id, dateStr) + if (rota != null) { + 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 } - - weeks.push({ label: `W${w + 1}`, actual, forecast, isPast }) + weeks.push(entry) } const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) @@ -159,20 +167,22 @@ export default function Monthly() {
-
Actual MTD
+
{isCurrentMonth ? 'Actual MTD' : 'Actual'}
{fmtMoney(totalActual)}
-
-
Forecast EOM
-
{fmtMoney(totalForecast)}
-
rota + prior-week actual
-
+ {isCurrentMonth && ( +
+
Forecast EOM
+
{fmtMoney(totalForecast)}
+
rota + prior-week actual
+
+ )}
Monthly Budget
{budget != null ? fmtMoney(budget) : '—'}
-
% Budget (Forecast)
+
% Budget {isCurrentMonth ? '(Forecast)' : ''}
{pctBudget != null ? {pctBudget.toFixed(1)}% @@ -185,7 +195,7 @@ export default function Monthly() { )}
-
Net Sales MTD
+
{isCurrentMonth ? 'Net Sales MTD' : 'Net Sales'}
{fmtMoney(netSales)}
@@ -199,19 +209,18 @@ export default function Monthly() { {!loading && !error && ( <> - {/* Stacked bar chart */}
-
Weekly Breakdown
+
Weekly Breakdown{isCurrentMonth ? ' (forecast shaded)' : ''}
- `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} /> + `£${Math.round(Number(v) / 1000)}k`} tick={{ fontSize: 11 }} width={55} /> fmtMoney(Number(v))} /> {deptSummary.map(dep => ( {weeks.map((w, i) => ( - + ))} ))} @@ -219,14 +228,13 @@ export default function Monthly() {
- {/* Dept breakdown table */}
MonthBudgetMonthly Budget Weekly equiv.
- {MONTH_LABELS[month - 1]} {year} +
+ {MONTH_LABELS[i]} {viewYear} + {isThisMonth && current} - {isEditing ? ( + {isEdit ? ( { inputRefs.current[key] = el }} value={editing[key]} onChange={e => handleChange(key, e.target.value)} onBlur={() => handleSave(key)} - onKeyDown={e => handleKeyDown(key, e)} + onKeyDown={e => handleKeyDown(key, e, i)} style={{ width: 140, textAlign: 'right' }} - autoFocus - min={0} - step={100} + autoFocus min={0} step={100} /> ) : ( handleFocus(key)} style={{ - cursor: 'text', - display: 'inline-block', - minWidth: 100, - padding: '4px 8px', - borderRadius: 4, - border: '1px dashed var(--border)', - textAlign: 'right', - color: current != null ? 'var(--text-primary)' : 'var(--text-muted)', + cursor: 'text', display: 'inline-block', minWidth: 110, + padding: '4px 8px', borderRadius: 4, + border: '1px dashed var(--border)', textAlign: 'right', + color: curr != null ? 'var(--text-primary)' : 'var(--text-muted)', }} > - {saving[key] ? 'Saving…' : current != null ? `£${current.toLocaleString('en-GB')}` : 'Click to set'} + {saving[key] ? 'Saving…' : curr != null ? `£${curr.toLocaleString('en-GB')}` : 'Click to set'} )}
- - + + {isCurrentMonth && } @@ -234,13 +242,14 @@ export default function Monthly() { {deptSummary.map(dep => { - const dp = budget != null && budget > 0 ? (dep.forecast_eom / budget) * 100 : null - const dv = budget != null ? dep.forecast_eom - budget : null + const displayCost = isCurrentMonth ? dep.forecast_eom : dep.actual_mtd + const dp = budget != null && budget > 0 ? (displayCost / budget) * 100 : null + const dv = budget != null ? displayCost - budget : null return ( - + {isCurrentMonth && } - + {isCurrentMonth && }
DepartmentActual MTDForecast → EOM{isCurrentMonth ? 'Actual MTD' : 'Actual'}Forecast → EOMBudget % Budget Variance
{dep.department_name} {fmtMoney(dep.actual_mtd)}{fmtMoney(dep.forecast_eom)}{fmtMoney(dep.forecast_eom)} {dp != null ? {dp.toFixed(1)}% : '—'} @@ -254,7 +263,7 @@ export default function Monthly() {
Total {fmtMoney(totalActual)}{fmtMoney(totalForecast)}{fmtMoney(totalForecast)}{budget != null ? fmtMoney(budget) : '—'} {pctBudget != null ? {pctBudget.toFixed(1)}% : '—'}