Fix Monthly net sales range + chart data; Budgets year nav + CSV upload
This commit is contained in:
parent
4d6e53a1f8
commit
f97772cb72
2 changed files with 212 additions and 140 deletions
|
|
@ -1,41 +1,31 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { ChevronLeft, ChevronRight, Download, Upload } from 'lucide-react'
|
||||||
import { getBudgets, saveBudget } from '../api'
|
import { getBudgets, saveBudget } from '../api'
|
||||||
import type { WageBudget } from '../types'
|
import type { WageBudget } 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() }
|
||||||
|
|
||||||
function getMonthRange(): { year: number; month: number }[] {
|
|
||||||
const today = new Date()
|
|
||||||
const months: { year: number; month: number }[] = []
|
|
||||||
for (let i = -3; i <= 3; i++) {
|
|
||||||
let m = today.getMonth() + 1 + i
|
|
||||||
let y = today.getFullYear()
|
|
||||||
while (m <= 0) { m += 12; y-- }
|
|
||||||
while (m > 12) { m -= 12; y++ }
|
|
||||||
months.push({ year: y, month: m })
|
|
||||||
}
|
|
||||||
return months
|
|
||||||
}
|
|
||||||
|
|
||||||
const MONTH_LABELS = ['January','February','March','April','May','June','July','August','September','October','November','December']
|
const MONTH_LABELS = ['January','February','March','April','May','June','July','August','September','October','November','December']
|
||||||
|
const MONTH_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
||||||
|
|
||||||
export default function Budgets() {
|
export default function Budgets() {
|
||||||
const [budgets, setBudgets] = useState<Record<string, number>>({})
|
const thisYear = new Date().getFullYear()
|
||||||
const [editing, setEditing] = useState<Record<string, string>>({})
|
const [viewYear, setViewYear] = useState(thisYear)
|
||||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
const [budgets, setBudgets] = useState<Record<string, number>>({})
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [editing, setEditing] = useState<Record<string, string>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||||
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
const months = getMonthRange()
|
const [loading, setLoading] = useState(true)
|
||||||
|
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||||
|
const fileRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getBudgets()
|
getBudgets()
|
||||||
.then(res => {
|
.then(res => {
|
||||||
const map: Record<string, number> = {}
|
const map: Record<string, number> = {}
|
||||||
for (const b of res.budgets as WageBudget[]) {
|
for (const b of res.budgets as WageBudget[]) {
|
||||||
const key = b.month.slice(0, 7) // YYYY-MM
|
map[b.month.slice(0, 7)] = b.budget_amount
|
||||||
map[key] = b.budget_amount
|
|
||||||
}
|
}
|
||||||
setBudgets(map)
|
setBudgets(map)
|
||||||
})
|
})
|
||||||
|
|
@ -43,11 +33,10 @@ export default function Budgets() {
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const monthKey = (y: number, m: number) => `${y}-${String(m).padStart(2, '0')}`
|
const monthKey = (m: number) => `${viewYear}-${String(m).padStart(2, '0')}`
|
||||||
|
|
||||||
const handleFocus = (key: string) => {
|
const handleFocus = (key: string) => {
|
||||||
const current = budgets[key]
|
setEditing(e => ({ ...e, [key]: budgets[key] != null ? String(budgets[key]) : '' }))
|
||||||
setEditing(e => ({ ...e, [key]: current != null ? String(current) : '' }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = (key: string, val: string) => {
|
const handleChange = (key: string, val: string) => {
|
||||||
|
|
@ -56,15 +45,9 @@ export default function Budgets() {
|
||||||
|
|
||||||
const handleSave = async (key: string) => {
|
const handleSave = async (key: string) => {
|
||||||
const raw = editing[key]?.trim()
|
const raw = editing[key]?.trim()
|
||||||
if (raw === '') {
|
if (raw === '') { setEditing(e => { const n = { ...e }; delete n[key]; return n }); return }
|
||||||
setEditing(e => { const n = { ...e }; delete n[key]; return n })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const amount = parseFloat(raw)
|
const amount = parseFloat(raw)
|
||||||
if (isNaN(amount)) {
|
if (isNaN(amount)) { setEditing(e => { const n = { ...e }; delete n[key]; return n }); return }
|
||||||
setEditing(e => { const n = { ...e }; delete n[key]; return n })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSaving(s => ({ ...s, [key]: true }))
|
setSaving(s => ({ ...s, [key]: true }))
|
||||||
try {
|
try {
|
||||||
await saveBudget(key, amount)
|
await saveBudget(key, amount)
|
||||||
|
|
@ -77,86 +60,166 @@ export default function Budgets() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (key: string, e: React.KeyboardEvent) => {
|
const handleKeyDown = (key: string, e: React.KeyboardEvent, monthIdx: number) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSave(key)
|
handleSave(key)
|
||||||
// Tab focus to next
|
if (monthIdx < 11) {
|
||||||
const keys = months.map(m => monthKey(m.year, m.month))
|
const nextKey = monthKey(monthIdx + 2)
|
||||||
const idx = keys.indexOf(key)
|
setTimeout(() => inputRefs.current[nextKey]?.focus(), 50)
|
||||||
if (idx >= 0 && idx < keys.length - 1) {
|
|
||||||
setTimeout(() => inputRefs.current[keys[idx + 1]]?.focus(), 50)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') setEditing(e2 => { const n = { ...e2 }; delete n[key]; return n })
|
||||||
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++) {
|
||||||
|
const key = monthKey(m)
|
||||||
|
rows.push(`${MONTH_SHORT[m-1]} ${viewYear},${budgets[key] ?? ''}`)
|
||||||
}
|
}
|
||||||
|
const blob = new Blob([rows.join('\n')], { type: 'text/csv' })
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = URL.createObjectURL(blob)
|
||||||
|
a.download = `wages-budget-${viewYear}.csv`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(a.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV bulk upload
|
||||||
|
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setUploadMsg(null); setError(null)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = async (ev) => {
|
||||||
|
const text = ev.target?.result as string
|
||||||
|
const lines = text.split(/\r?\n/).filter(l => l.trim() && !l.startsWith('Month'))
|
||||||
|
let saved = 0, skipped = 0
|
||||||
|
for (const line of lines) {
|
||||||
|
const [monthRaw, amountRaw] = line.split(',').map(s => s.trim())
|
||||||
|
if (!monthRaw || !amountRaw) { skipped++; continue }
|
||||||
|
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) {
|
||||||
|
key = `${isoMatch[1]}-${isoMatch[2]}`
|
||||||
|
} else {
|
||||||
|
const parts = monthRaw.split(' ')
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const yr = parseInt(parts[1])
|
||||||
|
const mi = MONTH_SHORT.findIndex(m => m.toLowerCase() === parts[0].toLowerCase().slice(0, 3))
|
||||||
|
?? MONTH_LABELS.findIndex(m => m.toLowerCase() === parts[0].toLowerCase())
|
||||||
|
if (!isNaN(yr) && mi >= 0) key = `${yr}-${String(mi + 1).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!key) { skipped++; continue }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveBudget(key, amount)
|
||||||
|
setBudgets(b => ({ ...b, [key!]: amount }))
|
||||||
|
saved++
|
||||||
|
} catch {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUploadMsg(`Uploaded: ${saved} saved${skipped ? `, ${skipped} skipped` : ''}`)
|
||||||
|
if (fileRef.current) fileRef.current.value = ''
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1 className="page-title">Wage Budgets</h1>
|
<h1 className="page-title">Wage Budgets</h1>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button className="btn btn-secondary" onClick={handleDownloadTemplate}>
|
||||||
|
<Download size={14} strokeWidth={1.75} /> Template
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => fileRef.current?.click()}>
|
||||||
|
<Upload size={14} strokeWidth={1.75} /> Upload CSV
|
||||||
|
</button>
|
||||||
|
<input ref={fileRef} type="file" accept=".csv" style={{ display: 'none' }} onChange={handleUpload} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div style={{ color: '#dc2626', marginBottom: 16 }}>{error}</div>}
|
{error && <div style={{ color: '#dc2626', marginBottom: 12, padding: '8px 12px', background: '#fee2e2', borderRadius: 6 }}>{error}</div>}
|
||||||
|
{uploadMsg && <div style={{ color: '#065f46', marginBottom: 12, padding: '8px 12px', background: '#d1fae5', borderRadius: 6 }}>{uploadMsg}</div>}
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setViewYear(y => y - 1)}><ChevronLeft size={16} strokeWidth={1.75} /></button>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 16 }}>{viewYear}</span>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setViewYear(y => y + 1)}><ChevronRight size={16} strokeWidth={1.75} /></button>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||||
|
Year total: <strong style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{totalYear > 0 ? `£${Math.round(totalYear).toLocaleString('en-GB')}` : '—'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p style={{ color: 'var(--text-muted)', marginTop: 0, fontSize: 13 }}>
|
<p style={{ color: 'var(--text-muted)', marginTop: 0, fontSize: 13 }}>
|
||||||
Enter the total monthly wages budget (FD figure). Click a cell to edit, press Enter or Tab to save.
|
Click a cell to edit, press Enter to save and move to next month.
|
||||||
|
Or download the CSV template, fill it in, and upload.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Month</th>
|
<th>Month</th>
|
||||||
<th className="right">Budget</th>
|
<th className="right">Monthly Budget</th>
|
||||||
<th className="right" style={{ color: 'var(--text-muted)', fontWeight: 400 }}>Weekly equiv.</th>
|
<th className="right" style={{ color: 'var(--text-muted)', fontWeight: 400 }}>Weekly equiv.</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{months.map(({ year, month }) => {
|
{Array.from({ length: 12 }, (_, i) => {
|
||||||
const key = monthKey(year, month)
|
const m = i + 1
|
||||||
const current = budgets[key]
|
const key = monthKey(m)
|
||||||
const isEditing = key in editing
|
const curr = budgets[key]
|
||||||
const dim = daysInMonth(year, month)
|
const isEdit = key in editing
|
||||||
const weekly = current != null ? (current * 7 / dim) : null
|
const weekly = curr != null ? (curr * 7 / daysInMonth(viewYear, m)) : null
|
||||||
|
const isThisMonth = viewYear === thisYear && m === new Date().getMonth() + 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={key}>
|
<tr key={key} style={isThisMonth ? { background: 'var(--body-bg)' } : {}}>
|
||||||
<td style={{ fontWeight: 500 }}>
|
<td style={{ fontWeight: isThisMonth ? 600 : 400 }}>
|
||||||
{MONTH_LABELS[month - 1]} {year}
|
{MONTH_LABELS[i]} {viewYear}
|
||||||
|
{isThisMonth && <span style={{ marginLeft: 8, fontSize: 11, color: 'var(--app-primary)', fontWeight: 400 }}>current</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="right" style={{ width: 160 }}>
|
<td className="right" style={{ width: 160 }}>
|
||||||
{isEditing ? (
|
{isEdit ? (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
ref={el => { inputRefs.current[key] = el }}
|
ref={el => { inputRefs.current[key] = el }}
|
||||||
value={editing[key]}
|
value={editing[key]}
|
||||||
onChange={e => handleChange(key, e.target.value)}
|
onChange={e => handleChange(key, e.target.value)}
|
||||||
onBlur={() => handleSave(key)}
|
onBlur={() => handleSave(key)}
|
||||||
onKeyDown={e => handleKeyDown(key, e)}
|
onKeyDown={e => handleKeyDown(key, e, i)}
|
||||||
style={{ width: 140, textAlign: 'right' }}
|
style={{ width: 140, textAlign: 'right' }}
|
||||||
autoFocus
|
autoFocus min={0} step={100}
|
||||||
min={0}
|
|
||||||
step={100}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
onClick={() => handleFocus(key)}
|
onClick={() => handleFocus(key)}
|
||||||
style={{
|
style={{
|
||||||
cursor: 'text',
|
cursor: 'text', display: 'inline-block', minWidth: 110,
|
||||||
display: 'inline-block',
|
padding: '4px 8px', borderRadius: 4,
|
||||||
minWidth: 100,
|
border: '1px dashed var(--border)', textAlign: 'right',
|
||||||
padding: '4px 8px',
|
color: curr != null ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px dashed var(--border)',
|
|
||||||
textAlign: 'right',
|
|
||||||
color: current != null ? 'var(--text-primary)' : 'var(--text-muted)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{saving[key] ? 'Saving…' : current != null ? `£${current.toLocaleString('en-GB')}` : 'Click to set'}
|
{saving[key] ? 'Saving…' : curr != null ? `£${curr.toLocaleString('en-GB')}` : 'Click to set'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -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']
|
const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
|
||||||
|
|
||||||
export default function Monthly() {
|
export default function Monthly() {
|
||||||
const today = new Date()
|
const todayStr = new Date().toISOString().slice(0, 10)
|
||||||
const [year, setYear] = useState(today.getFullYear())
|
const todayYear = parseInt(todayStr.slice(0, 4))
|
||||||
const [month, setMonth] = useState(today.getMonth() + 1) // 1-based
|
const todayMonth = parseInt(todayStr.slice(5, 7))
|
||||||
|
|
||||||
const [depts, setDepts] = useState<DeptActuals[]>([])
|
const [year, setYear] = useState(todayYear)
|
||||||
const [scheduled, setScheduled] = useState<Record<string, Record<string, number>>>({}) // dept_id → date → cost
|
const [month, setMonth] = useState(todayMonth)
|
||||||
const [netSales, setNetSales] = useState(0)
|
|
||||||
const [budget, setBudget] = useState<number | null>(null)
|
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 [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)
|
||||||
|
|
||||||
const dim = daysInMonth(year, month)
|
const dim = daysInMonth(year, month)
|
||||||
const monthStr = `${year}-${String(month).padStart(2, '0')}`
|
const monthStr = `${year}-${String(month).padStart(2, '0')}`
|
||||||
const fromStr = `${monthStr}-01`
|
const fromStr = `${monthStr}-01`
|
||||||
const toStr = `${monthStr}-${String(dim).padStart(2, '0')}`
|
const toStr = `${monthStr}-${String(dim).padStart(2, '0')}`
|
||||||
const todayStr = fmt(today)
|
const isCurrentMonth = year === todayYear && month === todayMonth
|
||||||
const isCurrentMonth = year === today.getFullYear() && month === today.getMonth() + 1
|
// 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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true); setError(null)
|
setLoading(true); setError(null)
|
||||||
try {
|
try {
|
||||||
const [actRes, schRes, salesRes, budRes] = await Promise.all([
|
const [actRes, schRes, salesRes, budRes] = await Promise.all([
|
||||||
getActuals(fromStr, toStr),
|
getActuals(fromStr, toStr),
|
||||||
getScheduled(todayStr, toStr),
|
getScheduled(schedFrom, toStr),
|
||||||
getNetSales(fromStr, todayStr),
|
getNetSales(fromStr, salesTo), // salesTo caps at today for current month
|
||||||
getBudgets(),
|
getBudgets(),
|
||||||
])
|
])
|
||||||
setDepts(actRes.departments)
|
setDepts(actRes.departments)
|
||||||
setShowOncosts(actRes.show_oncosts)
|
setShowOncosts(actRes.show_oncosts)
|
||||||
|
|
||||||
// Build scheduled map
|
|
||||||
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) {
|
||||||
schMap[dep.department_id] = {}
|
schMap[dep.department_id] = {}
|
||||||
|
|
@ -58,42 +63,41 @@ export default function Monthly() {
|
||||||
|
|
||||||
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
|
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)
|
setBudget(bRow ? bRow.budget_amount : null)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [fromStr, toStr, todayStr])
|
}, [fromStr, toStr, schedFrom, salesTo])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
const prev = () => { if (month === 1) { setYear(y => y - 1); setMonth(12) } else { setMonth(m => m - 1) } }
|
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) } }
|
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
|
// Build dept summary: actual MTD + forecast EOM
|
||||||
const deptSummary = depts.map((dep, idx) => {
|
const deptSummary = depts.map((dep, idx) => {
|
||||||
const actualMTD = Object.entries(dep.days)
|
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)
|
.reduce((s, [, v]) => s + v.cost, 0)
|
||||||
|
|
||||||
// Forecast remaining days
|
|
||||||
let forecastRem = 0
|
let forecastRem = 0
|
||||||
for (let day = 1; day <= dim; day++) {
|
if (isCurrentMonth) {
|
||||||
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
|
for (let day = 1; day <= dim; day++) {
|
||||||
if (dateStr <= todayStr) continue
|
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
|
||||||
|
if (dateStr <= todayStr) continue
|
||||||
// Priority: rota → prior week same DoW actual
|
const rota = getSchCost(dep.department_id, dateStr)
|
||||||
const rotaCost = schMap(dep.department_id, dateStr)
|
if (rota != null) { forecastRem += rota; continue }
|
||||||
if (rotaCost != null) {
|
const priorStr = fmt(addDays(new Date(dateStr + 'T00:00:00'), -7))
|
||||||
forecastRem += rotaCost
|
const priorCost = dep.days[priorStr]?.cost
|
||||||
continue
|
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 {
|
return {
|
||||||
|
|
@ -105,39 +109,43 @@ export default function Monthly() {
|
||||||
}
|
}
|
||||||
}).sort((a, b) => b.forecast_eom - a.forecast_eom)
|
}).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 totalActual = deptSummary.reduce((s, d) => s + d.actual_mtd, 0)
|
||||||
const totalForecast = deptSummary.reduce((s, d) => s + d.forecast_eom, 0)
|
const totalForecast = deptSummary.reduce((s, d) => s + d.forecast_eom, 0)
|
||||||
const pctBudget = budget != null && budget > 0 ? (totalForecast / budget) * 100 : null
|
const pctBudget = budget != null && budget > 0 ? (totalForecast / budget) * 100 : null
|
||||||
const pctSales = netSales > 0 ? (totalActual / netSales) * 100 : null
|
const pctSales = netSales > 0 ? (totalActual / netSales) * 100 : null
|
||||||
const variance = budget != null ? totalForecast - budget : null
|
const variance = budget != null ? totalForecast - budget : null
|
||||||
|
|
||||||
// Build chart data: group by week
|
// Build chart data with per-dept costs per week so Bar dataKey works
|
||||||
const weeks: { label: string; actual: number; forecast: number; isPast: boolean }[] = []
|
type WeekEntry = { label: string; isPast: boolean; [dept: string]: number | boolean | string }
|
||||||
|
const weeks: WeekEntry[] = []
|
||||||
for (let w = 0; w * 7 < dim; w++) {
|
for (let w = 0; w * 7 < dim; w++) {
|
||||||
const wStart = w * 7 + 1
|
const wStart = w * 7 + 1
|
||||||
const wEnd = Math.min(wStart + 6, dim)
|
const wEnd = Math.min(wStart + 6, dim)
|
||||||
const wEndDate = new Date(`${monthStr}-${String(wEnd).padStart(2, '0')}T00:00:00`)
|
const wEndStr = `${monthStr}-${String(wEnd).padStart(2, '0')}`
|
||||||
const isPast = wEndDate < today
|
const isPast = wEndStr < todayStr
|
||||||
|
|
||||||
let actual = 0, forecast = 0
|
const entry: WeekEntry = { label: `W${w + 1}`, isPast }
|
||||||
for (let day = wStart; day <= wEnd; day++) {
|
|
||||||
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
|
for (const dep of deptSummary) {
|
||||||
const isActual = dateStr <= todayStr
|
const srcDep = depts.find(d => d.department_id === dep.department_id)
|
||||||
const total = deptSummary.reduce((s, dep) => {
|
let deptCost = 0
|
||||||
if (isActual) return s + (depts.find(d => d.department_id === dep.department_id)?.days[dateStr]?.cost ?? 0)
|
for (let day = wStart; day <= wEnd; day++) {
|
||||||
const rota = schMap(dep.department_id, dateStr)
|
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
|
||||||
if (rota != null) return s + rota
|
if (dateStr <= todayStr) {
|
||||||
const prior = depts.find(d => d.department_id === dep.department_id)?.days[fmt(addDays(new Date(dateStr + 'T00:00:00'), -7))]?.cost ?? 0
|
deptCost += srcDep?.days[dateStr]?.cost ?? 0
|
||||||
return s + prior
|
} else {
|
||||||
}, 0)
|
const rota = getSchCost(dep.department_id, dateStr)
|
||||||
if (isActual) actual += total; else forecast += total
|
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(entry)
|
||||||
weeks.push({ label: `W${w + 1}`, actual, forecast, isPast })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
|
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-grid">
|
||||||
<div className="summary-card">
|
<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 className="value">{fmtMoney(totalActual)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-card">
|
{isCurrentMonth && (
|
||||||
<div className="label">Forecast EOM</div>
|
<div className="summary-card">
|
||||||
<div className="value">{fmtMoney(totalForecast)}</div>
|
<div className="label">Forecast EOM</div>
|
||||||
<div className="sub">rota + prior-week actual</div>
|
<div className="value">{fmtMoney(totalForecast)}</div>
|
||||||
</div>
|
<div className="sub">rota + prior-week actual</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
<div className="label">Monthly Budget</div>
|
<div className="label">Monthly Budget</div>
|
||||||
<div className="value">{budget != null ? fmtMoney(budget) : '—'}</div>
|
<div className="value">{budget != null ? fmtMoney(budget) : '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
<div className="label">% Budget (Forecast)</div>
|
<div className="label">% Budget {isCurrentMonth ? '(Forecast)' : ''}</div>
|
||||||
<div className="value">
|
<div className="value">
|
||||||
{pctBudget != null
|
{pctBudget != null
|
||||||
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
|
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
|
||||||
|
|
@ -185,7 +195,7 @@ export default function Monthly() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-card">
|
<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 className="value">{fmtMoney(netSales)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-card">
|
<div className="summary-card">
|
||||||
|
|
@ -199,19 +209,18 @@ export default function Monthly() {
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
{/* Stacked bar chart */}
|
|
||||||
<div className="card">
|
<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}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<BarChart data={weeks} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
|
<BarChart data={weeks} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
|
||||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
<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))} />
|
<Tooltip formatter={(v: unknown) => fmtMoney(Number(v))} />
|
||||||
<Legend />
|
<Legend />
|
||||||
{deptSummary.map(dep => (
|
{deptSummary.map(dep => (
|
||||||
<Bar key={dep.department_id} dataKey={dep.department_name} stackId="a" fill={dep.color}>
|
<Bar key={dep.department_id} dataKey={dep.department_name} stackId="a" fill={dep.color}>
|
||||||
{weeks.map((w, i) => (
|
{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>
|
</Bar>
|
||||||
))}
|
))}
|
||||||
|
|
@ -219,14 +228,13 @@ export default function Monthly() {
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dept breakdown table */}
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Department</th>
|
<th>Department</th>
|
||||||
<th className="right">Actual MTD</th>
|
<th className="right">{isCurrentMonth ? 'Actual MTD' : 'Actual'}</th>
|
||||||
<th className="right">Forecast → EOM</th>
|
{isCurrentMonth && <th className="right">Forecast → EOM</th>}
|
||||||
<th className="right">Budget</th>
|
<th className="right">Budget</th>
|
||||||
<th className="right">% Budget</th>
|
<th className="right">% Budget</th>
|
||||||
<th className="right">Variance</th>
|
<th className="right">Variance</th>
|
||||||
|
|
@ -234,13 +242,14 @@ export default function Monthly() {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{deptSummary.map(dep => {
|
{deptSummary.map(dep => {
|
||||||
const dp = budget != null && budget > 0 ? (dep.forecast_eom / budget) * 100 : null
|
const displayCost = isCurrentMonth ? dep.forecast_eom : dep.actual_mtd
|
||||||
const dv = budget != null ? dep.forecast_eom - budget : null
|
const dp = budget != null && budget > 0 ? (displayCost / budget) * 100 : null
|
||||||
|
const dv = budget != null ? displayCost - budget : 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>
|
||||||
<td className="right">{fmtMoney(dep.forecast_eom)}</td>
|
{isCurrentMonth && <td className="right">{fmtMoney(dep.forecast_eom)}</td>}
|
||||||
<td className="right">—</td>
|
<td className="right">—</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> : '—'}
|
||||||
|
|
@ -254,7 +263,7 @@ export default function Monthly() {
|
||||||
<tr className="total-row">
|
<tr className="total-row">
|
||||||
<td>Total</td>
|
<td>Total</td>
|
||||||
<td className="right">{fmtMoney(totalActual)}</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">{budget != null ? fmtMoney(budget) : '—'}</td>
|
||||||
<td className="right">
|
<td className="right">
|
||||||
{pctBudget != null ? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span> : '—'}
|
{pctBudget != null ? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span> : '—'}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue