diff --git a/backend/src/routes/actuals.js b/backend/src/routes/actuals.js index 1f096d4..0a37547 100644 --- a/backend/src/routes/actuals.js +++ b/backend/src/routes/actuals.js @@ -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 } }) } diff --git a/backend/src/routes/budgets.js b/backend/src/routes/budgets.js index 21e94d5..3634f28 100644 --- a/backend/src/routes/budgets.js +++ b/backend/src/routes/budgets.js @@ -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 } + }) } diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index e21a381..06b645b 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -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) { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e8bb7a4..93fb78e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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(path: string, opts: RequestInit = {}): Promise { 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 }> { 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') } diff --git a/frontend/src/pages/Budgets.tsx b/frontend/src/pages/Budgets.tsx index e58504b..ae2f7b0 100644 --- a/frontend/src/pages/Budgets.tsx +++ b/frontend/src/pages/Budgets.tsx @@ -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>({}) const fileRef = useRef(null) + // Dept split state + const [depts, setDepts] = useState([]) + const [deptPcts, setDeptPcts] = useState>({}) + const [deptSaving, setDeptSaving] = useState(false) + const [deptMsg, setDeptMsg] = useState(null) + useEffect(() => { - getBudgets() - .then(res => { + Promise.all([getBudgets(), getDeptConfig()]) + .then(([budRes, deptRes]) => { const map: Record = {} - 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 = {} + 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) => { 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 = {} + 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
Loading…
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 (
@@ -232,6 +268,76 @@ export default function Budgets() {
+ + {/* Department Budget Split */} +
+
+
Department Budget Split
+ {depts.length > 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. +

+ + {depts.length === 0 ? ( +
+ No departments configured — go to Settings to fetch and enable departments first. +
+ ) : ( + <> + + + + + + + + + {depts.map(d => ( + + + + + ))} + + + + + +
Department% of Budget
{d.name} + { + const v = parseFloat(e.target.value) || 0 + setDeptPcts(p => ({ ...p, [d.id]: v })) + setDeptMsg(null) + }} + style={{ width: 90, textAlign: 'right' }} + /> + % +
Total + {pctTotal.toFixed(1)}% +
+ {!pctOk && ( +

+ Total must equal 100% — currently {pctTotal > 100 ? `${(pctTotal - 100).toFixed(1)}% over` : `${(100 - pctTotal).toFixed(1)}% under`} +

+ )} + {deptMsg &&

{deptMsg}

} + + )} +
) } diff --git a/frontend/src/pages/Monthly.tsx b/frontend/src/pages/Monthly.tsx index b179376..030aab8 100644 --- a/frontend/src/pages/Monthly.tsx +++ b/frontend/src/pages/Monthly.tsx @@ -26,6 +26,7 @@ export default function Monthly() { const [scheduled, setScheduled] = useState>>({}) const [netSales, setNetSales] = useState(0) const [budget, setBudget] = useState(null) + const [deptPcts, setDeptPcts] = useState>({}) const [showOncosts, setShowOncosts] = useState(true) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -51,6 +52,7 @@ export default function Monthly() { ]) setDepts(actRes.departments) setShowOncosts(actRes.show_oncosts) + setDeptPcts(actRes.dept_pcts) const schMap: Record> = {} for (const dep of schRes.departments) { @@ -243,14 +245,17 @@ export default function Monthly() { {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 ( {dep.department_name} {fmtMoney(dep.actual_mtd)} {isCurrentMonth && {fmtMoney(dep.forecast_eom)}} - — + {depBudg != null ? fmtMoney(depBudg) : '—'} {dp != null ? {dp.toFixed(1)}% : '—'} diff --git a/frontend/src/pages/Weekly.tsx b/frontend/src/pages/Weekly.tsx index ee47d10..3f56d4d 100644 --- a/frontend/src/pages/Weekly.tsx +++ b/frontend/src/pages/Weekly.tsx @@ -47,6 +47,8 @@ export default function Weekly() { const [netSales, setNetSales] = useState(0) const [pySales, setPySales] = useState(0) const [budget, setBudget] = useState(null) + const [monthlyBudg, setMonthlyBudg] = useState(null) + const [deptPcts, setDeptPcts] = useState>({}) const [showOncosts, setShowOncosts] = useState(true) const [loading, setLoading] = useState(true) const [error, setError] = useState(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() { {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 ( {dep.department_name} {fmtMoney(dep.cost)} - — + {depBudg != null ? fmtMoney(depBudg) : '—'} {depPct != null ? {depPct.toFixed(1)}% diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3840162..224b6a5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -47,3 +47,9 @@ export interface AppSetting { value: string updated_at: string } + +export interface DeptPct { + id: string + name: string + pct: number +}