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
|
const { from, to } = request.query
|
||||||
if (!from || !to) return reply.status(400).send({ error: 'from and to required' })
|
if (!from || !to) return reply.status(400).send({ error: 'from and to required' })
|
||||||
|
|
||||||
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
|
const [showOncostsRaw, pctsRaw, dbRes] = await Promise.all([
|
||||||
const costCol = showOncosts ? 'total_cost' : 'base_cost'
|
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(
|
const showOncosts = showOncostsRaw !== 'false'
|
||||||
`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]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Group by department, emit { dept_id, dept_name, days: { 'YYYY-MM-DD': cost } }
|
|
||||||
const deptMap = {}
|
const deptMap = {}
|
||||||
for (const row of res.rows) {
|
for (const row of dbRes.rows) {
|
||||||
const d = row.date.toISOString().slice(0, 10)
|
const d = row.date.toISOString().slice(0, 10)
|
||||||
if (!deptMap[row.department_id]) {
|
if (!deptMap[row.department_id]) {
|
||||||
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 { requireAuth, requireCap } from '../auth.js'
|
||||||
import { pool } from '../db.js'
|
import { pool, getConfig, setConfig } from '../db.js'
|
||||||
|
|
||||||
export async function budgetsRoutes(fastify) {
|
export async function budgetsRoutes(fastify) {
|
||||||
fastify.addHook('preHandler', requireAuth)
|
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' })
|
return reply.status(400).send({ error: 'budget_amount must be a non-negative number' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store as first day of month
|
|
||||||
const monthDate = `${month}-01`
|
const monthDate = `${month}-01`
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO wage_budgets (month, budget_amount, updated_at) VALUES ($1, $2, NOW())
|
`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 }
|
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'
|
import { pool, getConfig, setConfig } from '../db.js'
|
||||||
|
|
||||||
const ALLOWED_KEYS = new Set([
|
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) {
|
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'
|
const BASE = '/wages/api'
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||||
return res.json()
|
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}`)
|
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 }) })
|
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 {
|
export function downloadExport(view: string, from: string, to: string): void {
|
||||||
window.open(`${BASE}/export?view=${view}&from=${from}&to=${to}`, '_blank')
|
window.open(`${BASE}/export?view=${view}&from=${from}&to=${to}`, '_blank')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { ChevronLeft, ChevronRight, Download, Upload } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, Download, Upload } from 'lucide-react'
|
||||||
import { getBudgets, saveBudget } from '../api'
|
import { getBudgets, saveBudget, getDeptConfig, saveDeptPcts } from '../api'
|
||||||
import type { WageBudget } from '../types'
|
import type { WageBudget, DeptPct } from '../types'
|
||||||
|
|
||||||
function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() }
|
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 inputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||||
const fileRef = useRef<HTMLInputElement | null>(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(() => {
|
useEffect(() => {
|
||||||
getBudgets()
|
Promise.all([getBudgets(), getDeptConfig()])
|
||||||
.then(res => {
|
.then(([budRes, deptRes]) => {
|
||||||
const map: Record<string, number> = {}
|
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
|
map[b.month.slice(0, 7)] = b.budget_amount
|
||||||
}
|
}
|
||||||
setBudgets(map)
|
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))
|
.catch(e => setError(e.message))
|
||||||
.finally(() => setLoading(false))
|
.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 })
|
if (e.key === 'Escape') setEditing(e2 => { const n = { ...e2 }; delete n[key]; return n })
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSV template download
|
|
||||||
const handleDownloadTemplate = () => {
|
const handleDownloadTemplate = () => {
|
||||||
const rows = ['Month,Budget']
|
const rows = ['Month,Budget']
|
||||||
for (let m = 1; m <= 12; m++) {
|
for (let m = 1; m <= 12; m++) {
|
||||||
|
|
@ -87,7 +96,6 @@ export default function Budgets() {
|
||||||
URL.revokeObjectURL(a.href)
|
URL.revokeObjectURL(a.href)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSV bulk upload
|
|
||||||
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
@ -103,7 +111,6 @@ export default function Budgets() {
|
||||||
const amount = parseFloat(amountRaw)
|
const amount = parseFloat(amountRaw)
|
||||||
if (isNaN(amount)) { skipped++; continue }
|
if (isNaN(amount)) { skipped++; continue }
|
||||||
|
|
||||||
// Parse "Jan 2025" or "January 2025" or "2025-01"
|
|
||||||
let key: string | null = null
|
let key: string | null = null
|
||||||
const isoMatch = monthRaw.match(/^(\d{4})-(\d{2})$/)
|
const isoMatch = monthRaw.match(/^(\d{4})-(\d{2})$/)
|
||||||
if (isoMatch) {
|
if (isoMatch) {
|
||||||
|
|
@ -133,9 +140,38 @@ export default function Budgets() {
|
||||||
reader.readAsText(file)
|
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>
|
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 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -232,6 +268,76 @@ export default function Budgets() {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export default function Monthly() {
|
||||||
const [scheduled, setScheduled] = useState<Record<string, Record<string, number>>>({})
|
const [scheduled, setScheduled] = useState<Record<string, Record<string, number>>>({})
|
||||||
const [netSales, setNetSales] = useState(0)
|
const [netSales, setNetSales] = useState(0)
|
||||||
const [budget, setBudget] = useState<number | null>(null)
|
const [budget, setBudget] = useState<number | null>(null)
|
||||||
|
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
|
||||||
const [showOncosts, setShowOncosts] = useState(true)
|
const [showOncosts, setShowOncosts] = useState(true)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
@ -51,6 +52,7 @@ export default function Monthly() {
|
||||||
])
|
])
|
||||||
setDepts(actRes.departments)
|
setDepts(actRes.departments)
|
||||||
setShowOncosts(actRes.show_oncosts)
|
setShowOncosts(actRes.show_oncosts)
|
||||||
|
setDeptPcts(actRes.dept_pcts)
|
||||||
|
|
||||||
const schMap: Record<string, Record<string, number>> = {}
|
const schMap: Record<string, Record<string, number>> = {}
|
||||||
for (const dep of schRes.departments) {
|
for (const dep of schRes.departments) {
|
||||||
|
|
@ -243,14 +245,17 @@ export default function Monthly() {
|
||||||
<tbody>
|
<tbody>
|
||||||
{deptSummary.map(dep => {
|
{deptSummary.map(dep => {
|
||||||
const displayCost = isCurrentMonth ? dep.forecast_eom : dep.actual_mtd
|
const displayCost = isCurrentMonth ? dep.forecast_eom : dep.actual_mtd
|
||||||
const dp = budget != null && budget > 0 ? (displayCost / budget) * 100 : null
|
const depBudg = budget != null && (deptPcts[dep.department_id] ?? 0) > 0
|
||||||
const dv = budget != null ? displayCost - budget : null
|
? 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 (
|
return (
|
||||||
<tr key={dep.department_id}>
|
<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><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.actual_mtd)}</td>
|
||||||
{isCurrentMonth && <td className="right">{fmtMoney(dep.forecast_eom)}</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">
|
<td className="right">
|
||||||
{dp != null ? <span className={`pct-badge ${pctClass(dp)}`}>{dp.toFixed(1)}%</span> : '—'}
|
{dp != null ? <span className={`pct-badge ${pctClass(dp)}`}>{dp.toFixed(1)}%</span> : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ export default function Weekly() {
|
||||||
const [netSales, setNetSales] = useState(0)
|
const [netSales, setNetSales] = useState(0)
|
||||||
const [pySales, setPySales] = useState(0)
|
const [pySales, setPySales] = useState(0)
|
||||||
const [budget, setBudget] = useState<number | null>(null)
|
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 [showOncosts, setShowOncosts] = useState(true)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
@ -61,6 +63,7 @@ export default function Weekly() {
|
||||||
])
|
])
|
||||||
setDepts(actRes.departments)
|
setDepts(actRes.departments)
|
||||||
setShowOncosts(actRes.show_oncosts)
|
setShowOncosts(actRes.show_oncosts)
|
||||||
|
setDeptPcts(actRes.dept_pcts)
|
||||||
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
|
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
|
||||||
setPySales(salesRes.days.reduce((s, d) => s + d.py_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)
|
const bRow = (budgetRes.budgets as WageBudget[]).find(b => b.month === monthKey)
|
||||||
if (bRow) {
|
if (bRow) {
|
||||||
const dim = daysInMonthFor(fromStr)
|
const dim = daysInMonthFor(fromStr)
|
||||||
// Pro-rata: how many days of this week are <= today
|
|
||||||
const effectiveTo = todayStr < toStr ? todayStr : toStr
|
const effectiveTo = todayStr < toStr ? todayStr : toStr
|
||||||
const effectiveFrom = fromStr > todayStr ? todayStr : fromStr
|
const effectiveFrom = fromStr > todayStr ? todayStr : fromStr
|
||||||
const weekDays = effectiveTo >= effectiveFrom
|
const weekDays = effectiveTo >= effectiveFrom
|
||||||
? Math.round((new Date(effectiveTo + 'T00:00:00').getTime() - new Date(effectiveFrom + 'T00:00:00').getTime()) / 86_400_000) + 1
|
? Math.round((new Date(effectiveTo + 'T00:00:00').getTime() - new Date(effectiveFrom + 'T00:00:00').getTime()) / 86_400_000) + 1
|
||||||
: 7
|
: 7
|
||||||
setBudget(bRow.budget_amount * (weekDays / dim))
|
const ratio = weekDays / dim
|
||||||
|
setMonthlyBudg(bRow.budget_amount)
|
||||||
|
setBudget(bRow.budget_amount * ratio)
|
||||||
} else {
|
} else {
|
||||||
|
setMonthlyBudg(null)
|
||||||
setBudget(null)
|
setBudget(null)
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|
@ -92,7 +97,14 @@ export default function Weekly() {
|
||||||
const next = () => setFromStr(s => addDaysStr(s, 7))
|
const next = () => setFromStr(s => addDaysStr(s, 7))
|
||||||
const isCurrentWeek = fromStr === mondayOf(new Date())
|
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 => ({
|
const deptTotals = depts.map(dep => ({
|
||||||
|
department_id: dep.department_id,
|
||||||
department_name: dep.department_name,
|
department_name: dep.department_name,
|
||||||
cost: Object.values(dep.days).reduce((s, d) => s + d.cost, 0),
|
cost: Object.values(dep.days).reduce((s, d) => s + d.cost, 0),
|
||||||
})).sort((a, b) => b.cost - a.cost)
|
})).sort((a, b) => b.cost - a.cost)
|
||||||
|
|
@ -166,13 +178,14 @@ export default function Weekly() {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{deptTotals.map(dep => {
|
{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
|
const depSPct = netSales > 0 ? (dep.cost / netSales) * 100 : null
|
||||||
return (
|
return (
|
||||||
<tr key={dep.department_name}>
|
<tr key={dep.department_name}>
|
||||||
<td>{dep.department_name}</td>
|
<td>{dep.department_name}</td>
|
||||||
<td className="right">{fmtMoney(dep.cost)}</td>
|
<td className="right">{fmtMoney(dep.cost)}</td>
|
||||||
<td className="right">—</td>
|
<td className="right">{depBudg != null ? fmtMoney(depBudg) : '—'}</td>
|
||||||
<td className="right">
|
<td className="right">
|
||||||
{depPct != null
|
{depPct != null
|
||||||
? <span className={`pct-badge ${pctClass(depPct)}`}>{depPct.toFixed(1)}%</span>
|
? <span className={`pct-badge ${pctClass(depPct)}`}>{depPct.toFixed(1)}%</span>
|
||||||
|
|
|
||||||
|
|
@ -47,3 +47,9 @@ export interface AppSetting {
|
||||||
value: string
|
value: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeptPct {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
pct: number
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue