Fix Monthly net sales range + chart data; Budgets year nav + CSV upload

This commit is contained in:
jtricerolph 2026-07-23 09:25:51 +00:00
parent 4d6e53a1f8
commit f97772cb72
2 changed files with 212 additions and 140 deletions

View file

@ -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<DeptActuals[]>([])
const [scheduled, setScheduled] = useState<Record<string, Record<string, number>>>({}) // dept_id → date → cost
const [netSales, setNetSales] = useState(0)
const [budget, setBudget] = useState<number | null>(null)
const [year, setYear] = useState(todayYear)
const [month, setMonth] = useState(todayMonth)
const [depts, setDepts] = useState<DeptActuals[]>([])
const [scheduled, setScheduled] = useState<Record<string, Record<string, number>>>({})
const [netSales, setNetSales] = useState(0)
const [budget, setBudget] = useState<number | null>(null)
const [showOncosts, setShowOncosts] = useState(true)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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<string, Record<string, number>> = {}
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() {
<div className="summary-grid">
<div className="summary-card">
<div className="label">Actual MTD</div>
<div className="label">{isCurrentMonth ? 'Actual MTD' : 'Actual'}</div>
<div className="value">{fmtMoney(totalActual)}</div>
</div>
<div className="summary-card">
<div className="label">Forecast EOM</div>
<div className="value">{fmtMoney(totalForecast)}</div>
<div className="sub">rota + prior-week actual</div>
</div>
{isCurrentMonth && (
<div className="summary-card">
<div className="label">Forecast EOM</div>
<div className="value">{fmtMoney(totalForecast)}</div>
<div className="sub">rota + prior-week actual</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="label">% Budget {isCurrentMonth ? '(Forecast)' : ''}</div>
<div className="value">
{pctBudget != null
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
@ -185,7 +195,7 @@ export default function Monthly() {
)}
</div>
<div className="summary-card">
<div className="label">Net Sales MTD</div>
<div className="label">{isCurrentMonth ? 'Net Sales MTD' : 'Net Sales'}</div>
<div className="value">{fmtMoney(netSales)}</div>
</div>
<div className="summary-card">
@ -199,19 +209,18 @@ export default function Monthly() {
{!loading && !error && (
<>
{/* Stacked bar chart */}
<div className="card">
<div className="card-title">Weekly Breakdown</div>
<div className="card-title">Weekly Breakdown{isCurrentMonth ? ' (forecast shaded)' : ''}</div>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={weeks} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis tickFormatter={v => `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} />
<YAxis tickFormatter={v => `£${Math.round(Number(v) / 1000)}k`} tick={{ fontSize: 11 }} width={55} />
<Tooltip formatter={(v: unknown) => fmtMoney(Number(v))} />
<Legend />
{deptSummary.map(dep => (
<Bar key={dep.department_id} dataKey={dep.department_name} stackId="a" fill={dep.color}>
{weeks.map((w, i) => (
<Cell key={i} fill={dep.color} opacity={w.isPast ? 1 : 0.4} />
<Cell key={i} fill={dep.color} opacity={w.isPast ? 1 : 0.45} />
))}
</Bar>
))}
@ -219,14 +228,13 @@ export default function Monthly() {
</ResponsiveContainer>
</div>
{/* Dept breakdown table */}
<div className="card">
<table className="data-table">
<thead>
<tr>
<th>Department</th>
<th className="right">Actual MTD</th>
<th className="right">Forecast EOM</th>
<th className="right">{isCurrentMonth ? 'Actual MTD' : 'Actual'}</th>
{isCurrentMonth && <th className="right">Forecast EOM</th>}
<th className="right">Budget</th>
<th className="right">% Budget</th>
<th className="right">Variance</th>
@ -234,13 +242,14 @@ export default function Monthly() {
</thead>
<tbody>
{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 (
<tr key={dep.department_id}>
<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.forecast_eom)}</td>
{isCurrentMonth && <td className="right">{fmtMoney(dep.forecast_eom)}</td>}
<td className="right"></td>
<td className="right">
{dp != null ? <span className={`pct-badge ${pctClass(dp)}`}>{dp.toFixed(1)}%</span> : '—'}
@ -254,7 +263,7 @@ export default function Monthly() {
<tr className="total-row">
<td>Total</td>
<td className="right">{fmtMoney(totalActual)}</td>
<td className="right">{fmtMoney(totalForecast)}</td>
{isCurrentMonth && <td className="right">{fmtMoney(totalForecast)}</td>}
<td className="right">{budget != null ? fmtMoney(budget) : '—'}</td>
<td className="right">
{pctBudget != null ? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span> : '—'}