Add department budget % allocation

- Budgets page: new dept split section with % inputs, equal-split button, save
- Weekly/Monthly: per-dept budget column using dept pcts from actuals response
- Backend: dept_budget_pcts in ALLOWED_KEYS; actuals returns dept_pcts map;
  budgets route adds GET /dept-config + PUT /dept-pcts endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-23 09:43:39 +00:00
parent f97772cb72
commit 3e1851fa6d
8 changed files with 204 additions and 33 deletions

View file

@ -1,4 +1,4 @@
import type { DeptActuals, DeptScheduled, NetSalesDay, WageBudget, AppSetting } from './types'
import type { DeptActuals, DeptScheduled, NetSalesDay, WageBudget, AppSetting, DeptPct } from './types'
const BASE = '/wages/api'
@ -20,7 +20,7 @@ async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
return res.json()
}
export function getActuals(from: string, to: string): Promise<{ departments: DeptActuals[]; show_oncosts: boolean }> {
export function getActuals(from: string, to: string): Promise<{ departments: DeptActuals[]; show_oncosts: boolean; dept_pcts: Record<string, number> }> {
return request(`/actuals?from=${from}&to=${to}`)
}
@ -67,6 +67,14 @@ export function saveSettings(settings: { key: string; value: string }[]): Promis
return request('/settings', { method: 'PUT', body: JSON.stringify({ settings }) })
}
export function getDeptConfig(): Promise<{ depts: DeptPct[] }> {
return request('/budgets/dept-config')
}
export function saveDeptPcts(pcts: { id: string; pct: number }[]): Promise<{ ok: boolean }> {
return request('/budgets/dept-pcts', { method: 'PUT', body: JSON.stringify({ pcts }) })
}
export function downloadExport(view: string, from: string, to: string): void {
window.open(`${BASE}/export?view=${view}&from=${from}&to=${to}`, '_blank')
}

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import { ChevronLeft, ChevronRight, Download, Upload } from 'lucide-react'
import { getBudgets, saveBudget } from '../api'
import type { WageBudget } from '../types'
import { getBudgets, saveBudget, getDeptConfig, saveDeptPcts } from '../api'
import type { WageBudget, DeptPct } from '../types'
function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() }
@ -20,14 +20,24 @@ export default function Budgets() {
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({})
const fileRef = useRef<HTMLInputElement | null>(null)
// Dept split state
const [depts, setDepts] = useState<DeptPct[]>([])
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
const [deptSaving, setDeptSaving] = useState(false)
const [deptMsg, setDeptMsg] = useState<string | null>(null)
useEffect(() => {
getBudgets()
.then(res => {
Promise.all([getBudgets(), getDeptConfig()])
.then(([budRes, deptRes]) => {
const map: Record<string, number> = {}
for (const b of res.budgets as WageBudget[]) {
for (const b of budRes.budgets as WageBudget[]) {
map[b.month.slice(0, 7)] = b.budget_amount
}
setBudgets(map)
setDepts(deptRes.depts)
const pctMap: Record<string, number> = {}
for (const d of deptRes.depts) pctMap[d.id] = d.pct
setDeptPcts(pctMap)
})
.catch(e => setError(e.message))
.finally(() => setLoading(false))
@ -72,7 +82,6 @@ export default function Budgets() {
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++) {
@ -87,7 +96,6 @@ export default function Budgets() {
URL.revokeObjectURL(a.href)
}
// CSV bulk upload
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
@ -103,7 +111,6 @@ export default function Budgets() {
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) {
@ -133,9 +140,38 @@ export default function Budgets() {
reader.readAsText(file)
}
// Dept split handlers
const pctTotal = depts.reduce((s, d) => s + (deptPcts[d.id] ?? 0), 0)
const handleEqualSplit = () => {
if (depts.length === 0) return
const base = parseFloat((100 / depts.length).toFixed(2))
const map: Record<string, number> = {}
depts.forEach((d, i) => {
map[d.id] = i === depts.length - 1
? parseFloat((100 - base * (depts.length - 1)).toFixed(2))
: base
})
setDeptPcts(map)
setDeptMsg(null)
}
const handleSavePcts = async () => {
setDeptSaving(true); setDeptMsg(null)
try {
await saveDeptPcts(depts.map(d => ({ id: d.id, pct: deptPcts[d.id] ?? 0 })))
setDeptMsg('Saved')
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Save failed')
} finally {
setDeptSaving(false)
}
}
if (loading) return <div className="state-center">Loading</div>
const totalYear = Array.from({ length: 12 }, (_, i) => budgets[monthKey(i + 1)] ?? 0).reduce((s, v) => s + v, 0)
const pctOk = Math.abs(pctTotal - 100) <= 0.1
return (
<div>
@ -232,6 +268,76 @@ export default function Budgets() {
</tbody>
</table>
</div>
{/* Department Budget Split */}
<div className="card" style={{ marginTop: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div className="card-title" style={{ margin: 0 }}>Department Budget Split</div>
{depts.length > 0 && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button className="btn btn-secondary" onClick={handleEqualSplit}>Equal split</button>
<button className="btn btn-primary" onClick={handleSavePcts} disabled={deptSaving}>
{deptSaving ? 'Saving…' : 'Save'}
</button>
</div>
)}
</div>
<p style={{ color: 'var(--text-muted)', fontSize: 13, marginTop: 0 }}>
Allocate what % of each month's total budget belongs to each department.
This unlocks per-department budget columns in the weekly and monthly views.
</p>
{depts.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
No departments configured go to Settings to fetch and enable departments first.
</div>
) : (
<>
<table className="data-table">
<thead>
<tr>
<th>Department</th>
<th className="right">% of Budget</th>
</tr>
</thead>
<tbody>
{depts.map(d => (
<tr key={d.id}>
<td>{d.name}</td>
<td className="right" style={{ width: 180 }}>
<input
type="number"
min={0} max={100} step={0.5}
value={deptPcts[d.id] ?? 0}
onChange={e => {
const v = parseFloat(e.target.value) || 0
setDeptPcts(p => ({ ...p, [d.id]: v }))
setDeptMsg(null)
}}
style={{ width: 90, textAlign: 'right' }}
/>
<span style={{ marginLeft: 6, color: 'var(--text-muted)' }}>%</span>
</td>
</tr>
))}
<tr className="total-row">
<td>Total</td>
<td className="right" style={{ color: pctOk ? 'var(--app-primary)' : '#dc2626', fontWeight: 600 }}>
{pctTotal.toFixed(1)}%
</td>
</tr>
</tbody>
</table>
{!pctOk && (
<p style={{ color: '#dc2626', fontSize: 12, marginTop: 8 }}>
Total must equal 100% currently {pctTotal > 100 ? `${(pctTotal - 100).toFixed(1)}% over` : `${(100 - pctTotal).toFixed(1)}% under`}
</p>
)}
{deptMsg && <p style={{ color: 'var(--app-primary)', fontSize: 12, marginTop: 8 }}>{deptMsg}</p>}
</>
)}
</div>
</div>
)
}

View file

@ -26,6 +26,7 @@ export default function Monthly() {
const [scheduled, setScheduled] = useState<Record<string, Record<string, number>>>({})
const [netSales, setNetSales] = useState(0)
const [budget, setBudget] = useState<number | null>(null)
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
const [showOncosts, setShowOncosts] = useState(true)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -51,6 +52,7 @@ export default function Monthly() {
])
setDepts(actRes.departments)
setShowOncosts(actRes.show_oncosts)
setDeptPcts(actRes.dept_pcts)
const schMap: Record<string, Record<string, number>> = {}
for (const dep of schRes.departments) {
@ -243,14 +245,17 @@ export default function Monthly() {
<tbody>
{deptSummary.map(dep => {
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
const depBudg = budget != null && (deptPcts[dep.department_id] ?? 0) > 0
? budget * (deptPcts[dep.department_id] / 100)
: null
const dp = depBudg != null && depBudg > 0 ? (displayCost / depBudg) * 100 : null
const dv = depBudg != null ? displayCost - depBudg : 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>
{isCurrentMonth && <td className="right">{fmtMoney(dep.forecast_eom)}</td>}
<td className="right"></td>
<td className="right">{depBudg != null ? fmtMoney(depBudg) : '—'}</td>
<td className="right">
{dp != null ? <span className={`pct-badge ${pctClass(dp)}`}>{dp.toFixed(1)}%</span> : '—'}
</td>

View file

@ -47,6 +47,8 @@ export default function Weekly() {
const [netSales, setNetSales] = useState(0)
const [pySales, setPySales] = useState(0)
const [budget, setBudget] = useState<number | null>(null)
const [monthlyBudg, setMonthlyBudg] = useState<number | null>(null)
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
const [showOncosts, setShowOncosts] = useState(true)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -61,6 +63,7 @@ export default function Weekly() {
])
setDepts(actRes.departments)
setShowOncosts(actRes.show_oncosts)
setDeptPcts(actRes.dept_pcts)
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
setPySales(salesRes.days.reduce((s, d) => s + d.py_sales, 0))
@ -69,14 +72,16 @@ export default function Weekly() {
const bRow = (budgetRes.budgets as WageBudget[]).find(b => b.month === monthKey)
if (bRow) {
const dim = daysInMonthFor(fromStr)
// Pro-rata: how many days of this week are <= today
const effectiveTo = todayStr < toStr ? todayStr : toStr
const effectiveFrom = fromStr > todayStr ? todayStr : fromStr
const weekDays = effectiveTo >= effectiveFrom
? Math.round((new Date(effectiveTo + 'T00:00:00').getTime() - new Date(effectiveFrom + 'T00:00:00').getTime()) / 86_400_000) + 1
: 7
setBudget(bRow.budget_amount * (weekDays / dim))
const ratio = weekDays / dim
setMonthlyBudg(bRow.budget_amount)
setBudget(bRow.budget_amount * ratio)
} else {
setMonthlyBudg(null)
setBudget(null)
}
} catch (e: unknown) {
@ -92,7 +97,14 @@ export default function Weekly() {
const next = () => setFromStr(s => addDaysStr(s, 7))
const isCurrentWeek = fromStr === mondayOf(new Date())
// dept_pct × (weekly pro-rata) = dept weekly budget
const deptWeekBudget = (deptId: string) =>
budget != null && monthlyBudg != null && (deptPcts[deptId] ?? 0) > 0
? budget * (deptPcts[deptId] / 100)
: null
const deptTotals = depts.map(dep => ({
department_id: dep.department_id,
department_name: dep.department_name,
cost: Object.values(dep.days).reduce((s, d) => s + d.cost, 0),
})).sort((a, b) => b.cost - a.cost)
@ -166,13 +178,14 @@ export default function Weekly() {
</thead>
<tbody>
{deptTotals.map(dep => {
const depPct = budget != null && budget > 0 ? (dep.cost / budget) * 100 : null
const depBudg = deptWeekBudget(dep.department_id)
const depPct = depBudg != null && depBudg > 0 ? (dep.cost / depBudg) * 100 : null
const depSPct = netSales > 0 ? (dep.cost / netSales) * 100 : null
return (
<tr key={dep.department_name}>
<td>{dep.department_name}</td>
<td className="right">{fmtMoney(dep.cost)}</td>
<td className="right"></td>
<td className="right">{depBudg != null ? fmtMoney(depBudg) : '—'}</td>
<td className="right">
{depPct != null
? <span className={`pct-badge ${pctClass(depPct)}`}>{depPct.toFixed(1)}%</span>

View file

@ -47,3 +47,9 @@ export interface AppSetting {
value: string
updated_at: string
}
export interface DeptPct {
id: string
name: string
pct: number
}