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:
parent
f97772cb72
commit
3e1851fa6d
8 changed files with 204 additions and 33 deletions
|
|
@ -8,21 +8,23 @@ export async function actualsRoutes(fastify) {
|
|||
const { from, to } = request.query
|
||||
if (!from || !to) return reply.status(400).send({ error: 'from and to required' })
|
||||
|
||||
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
|
||||
const costCol = showOncosts ? 'total_cost' : 'base_cost'
|
||||
const [showOncostsRaw, pctsRaw, dbRes] = await Promise.all([
|
||||
getConfig('show_oncosts'),
|
||||
getConfig('dept_budget_pcts'),
|
||||
pool.query(
|
||||
`SELECT date, department_id, department_name,
|
||||
base_cost, total_cost, shift_count
|
||||
FROM wage_actuals
|
||||
WHERE date >= $1 AND date <= $2
|
||||
ORDER BY date, department_name`,
|
||||
[from, to]
|
||||
),
|
||||
])
|
||||
|
||||
const res = await pool.query(
|
||||
`SELECT date, department_id, department_name,
|
||||
base_cost, total_cost, shift_count
|
||||
FROM wage_actuals
|
||||
WHERE date >= $1 AND date <= $2
|
||||
ORDER BY date, department_name`,
|
||||
[from, to]
|
||||
)
|
||||
const showOncosts = showOncostsRaw !== 'false'
|
||||
|
||||
// Group by department, emit { dept_id, dept_name, days: { 'YYYY-MM-DD': cost } }
|
||||
const deptMap = {}
|
||||
for (const row of res.rows) {
|
||||
for (const row of dbRes.rows) {
|
||||
const d = row.date.toISOString().slice(0, 10)
|
||||
if (!deptMap[row.department_id]) {
|
||||
deptMap[row.department_id] = {
|
||||
|
|
@ -39,6 +41,11 @@ export async function actualsRoutes(fastify) {
|
|||
}
|
||||
}
|
||||
|
||||
return { departments: Object.values(deptMap), show_oncosts: showOncosts }
|
||||
const dept_pcts = {}
|
||||
if (pctsRaw) {
|
||||
try { for (const item of JSON.parse(pctsRaw)) dept_pcts[item.id] = item.pct } catch {}
|
||||
}
|
||||
|
||||
return { departments: Object.values(deptMap), show_oncosts: showOncosts, dept_pcts }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
import { pool, getConfig, setConfig } from '../db.js'
|
||||
|
||||
export async function budgetsRoutes(fastify) {
|
||||
fastify.addHook('preHandler', requireAuth)
|
||||
|
|
@ -25,7 +25,6 @@ export async function budgetsRoutes(fastify) {
|
|||
return reply.status(400).send({ error: 'budget_amount must be a non-negative number' })
|
||||
}
|
||||
|
||||
// Store as first day of month
|
||||
const monthDate = `${month}-01`
|
||||
await pool.query(
|
||||
`INSERT INTO wage_budgets (month, budget_amount, updated_at) VALUES ($1, $2, NOW())
|
||||
|
|
@ -34,4 +33,31 @@ export async function budgetsRoutes(fastify) {
|
|||
)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// Dept list + saved pcts — used by Budgets page (view cap)
|
||||
fastify.get('/api/budgets/dept-config', { preHandler: requireCap('view') }, async () => {
|
||||
const [deptsRaw, pctsRaw] = await Promise.all([
|
||||
getConfig('departments'),
|
||||
getConfig('dept_budget_pcts'),
|
||||
])
|
||||
|
||||
let depts = []
|
||||
if (deptsRaw) {
|
||||
try { depts = JSON.parse(deptsRaw).filter(d => d.enabled !== false) } catch {}
|
||||
}
|
||||
|
||||
const pcts = {}
|
||||
if (pctsRaw) {
|
||||
try { for (const item of JSON.parse(pctsRaw)) pcts[item.id] = item.pct } catch {}
|
||||
}
|
||||
|
||||
return { depts: depts.map(d => ({ id: d.id, name: d.name, pct: pcts[d.id] ?? 0 })) }
|
||||
})
|
||||
|
||||
fastify.put('/api/budgets/dept-pcts', { preHandler: requireCap('budget') }, async (request, reply) => {
|
||||
const { pcts } = request.body || {}
|
||||
if (!Array.isArray(pcts)) return reply.status(400).send({ error: 'pcts must be an array' })
|
||||
await setConfig('dept_budget_pcts', JSON.stringify(pcts))
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { requireAuth, requireCap } from '../auth.js'
|
|||
import { pool, getConfig, setConfig } from '../db.js'
|
||||
|
||||
const ALLOWED_KEYS = new Set([
|
||||
'forecasting_url', 'forecasting_api_key', 'show_oncosts', 'departments',
|
||||
'forecasting_url', 'forecasting_api_key', 'show_oncosts', 'departments', 'dept_budget_pcts',
|
||||
])
|
||||
|
||||
export async function settingsRoutes(fastify) {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -47,3 +47,9 @@ export interface AppSetting {
|
|||
value: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DeptPct {
|
||||
id: string
|
||||
name: string
|
||||
pct: number
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue