Add AI cost insights dashboard; rota-informed forecast method
AI Insights: a daily Claude-generated wage cost briefing covering month-to-date pace vs budget, prior-month/prior-year comparison, rota-vs-actual variance by department, a rota-informed forecast to month-end, employee-level anomalies, and wage cost as a % of revenue. Runs on a configurable daily schedule or on demand, gated by a manual 5-minute rate limit and a daily token budget. Uses the Anthropic key configured centrally in Portal → Settings → Integrations. Also includes the rota-vs-repeat-pattern forecast method (published/ draft rota tiers with same-weekday fallback) already built into the Weekly/Monthly views, and adds a .gitignore for node_modules/dist. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
11e5a2b78a
commit
95da5ea237
26 changed files with 8914 additions and 78 deletions
|
|
@ -3,9 +3,10 @@ import { ChevronLeft, ChevronRight, ChevronDown, Download } from 'lucide-react'
|
|||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, Cell,
|
||||
} from 'recharts'
|
||||
import { getActuals, getNetSales, getBudgets, getDeptDetail, downloadExport } from '../api'
|
||||
import type { DeptActuals, WageBudget, EmployeeDetail } from '../types'
|
||||
import { getActuals, getScheduled, getNetSales, getBudgets, getDeptDetail, downloadExport } from '../api'
|
||||
import type { DeptActuals, DeptScheduled, ForecastMethod, WageBudget, EmployeeDetail } from '../types'
|
||||
import { DeptDetailModal } from '../components/DeptDetailModal'
|
||||
import { forecastDayCost } from '../lib/forecast'
|
||||
|
||||
function fmt(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
|
|
@ -21,13 +22,6 @@ function fmtDisplay(dateStr: string): string {
|
|||
function budgetColour(pct: number): string {
|
||||
return pct <= 100 ? 'var(--app-primary)' : pct <= 110 ? '#b45309' : '#dc2626'
|
||||
}
|
||||
// Baseline forecast: repeat the last full actual week's pattern forward to end of month
|
||||
function repeatingPriorCost(days: Record<string, { cost: number }>, dateStr: string, cutoffStr: string): number {
|
||||
let probe = dateStr
|
||||
while (probe > cutoffStr) probe = fmt(addDays(new Date(probe + 'T00:00:00'), -7))
|
||||
return days[probe]?.cost ?? 0
|
||||
}
|
||||
|
||||
const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
|
||||
|
||||
export default function Monthly() {
|
||||
|
|
@ -41,6 +35,9 @@ export default function Monthly() {
|
|||
const [month, setMonth] = useState(todayMonth)
|
||||
|
||||
const [depts, setDepts] = useState<DeptActuals[]>([])
|
||||
const [scheduled, setScheduled] = useState<DeptScheduled[]>([])
|
||||
const [forecastMethod, setForecastMethod] = useState<ForecastMethod>('repeat')
|
||||
const [includeUnpublished, setIncludeUnpublished] = useState(false)
|
||||
const [netSalesMTD, setNetSalesMTD] = useState(0)
|
||||
const [netSalesFull, setNetSalesFull] = useState(0)
|
||||
const [pySalesMTD, setPySalesMTD] = useState(0)
|
||||
|
|
@ -82,16 +79,19 @@ export default function Monthly() {
|
|||
setLoading(true); setError(null)
|
||||
try {
|
||||
// Net sales: full month — OTB/forecast for future dates, actuals for past dates
|
||||
const [actRes, salesRes, budRes, pyActRes, pmActRes] = await Promise.all([
|
||||
const [actRes, schedRes, salesRes, budRes, pyActRes, pmActRes] = await Promise.all([
|
||||
getActuals(fromStr, toStr),
|
||||
getScheduled(fromStr, toStr),
|
||||
getNetSales(fromStr, toStr),
|
||||
getBudgets(),
|
||||
getActuals(pyFromStr, pyToStr),
|
||||
getActuals(pmFromStr, pmToStr),
|
||||
])
|
||||
setDepts(actRes.departments)
|
||||
setScheduled(schedRes.departments)
|
||||
setShowOncosts(actRes.show_oncosts)
|
||||
setDeptPcts(actRes.dept_pcts)
|
||||
setForecastMethod(actRes.forecast_method)
|
||||
|
||||
// Cut-off for MTD = yesterday (avoid partial clockins today)
|
||||
const cutoff = isCurrentMonth ? yesterdayStr : toStr
|
||||
|
|
@ -162,10 +162,14 @@ export default function Monthly() {
|
|||
|
||||
let forecastRem = 0
|
||||
if (isCurrentMonth) {
|
||||
// 'rota' method: prefer published rota (or +draft rota if includeUnpublished) for days
|
||||
// that have it, falling back to the repeat-pattern otherwise — see forecastDayCost.
|
||||
// 'repeat' (default): unchanged, no scheduled data passed in so it always repeats.
|
||||
const schedDep = forecastMethod === 'rota' ? scheduled.find(s => s.department_id === dep.department_id) : undefined
|
||||
for (let day = 1; day <= dim; day++) {
|
||||
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
|
||||
if (dateStr <= yesterdayStr) continue
|
||||
forecastRem += repeatingPriorCost(dep.days, dateStr, yesterdayStr)
|
||||
forecastRem += forecastDayCost(dateStr, dep.days, schedDep?.days, includeUnpublished).cost
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,17 +224,22 @@ export default function Monthly() {
|
|||
|
||||
const entry: WeekEntry = { label: `W${w + 1}`, isPast }
|
||||
for (const dep of deptSummary) {
|
||||
const srcDep = depts.find(d => d.department_id === dep.department_id)
|
||||
const srcDep = depts.find(d => d.department_id === dep.department_id)
|
||||
const schedDep = forecastMethod === 'rota' ? scheduled.find(s => s.department_id === dep.department_id) : undefined
|
||||
let deptCost = 0
|
||||
let weekTier: 'rota' | 'repeat' = 'rota'
|
||||
for (let day = wStart; day <= wEnd; day++) {
|
||||
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
|
||||
if (dateStr <= cutoff) {
|
||||
deptCost += srcDep?.days[dateStr]?.cost ?? 0
|
||||
} else {
|
||||
deptCost += repeatingPriorCost(srcDep?.days ?? {}, dateStr, cutoff)
|
||||
const { cost, tier } = forecastDayCost(dateStr, srcDep?.days, schedDep?.days, includeUnpublished)
|
||||
deptCost += cost
|
||||
if (tier === 'actual' || tier === 'none') weekTier = 'repeat'
|
||||
}
|
||||
}
|
||||
entry[dep.department_name] = deptCost
|
||||
entry[`${dep.department_name}__tier`] = weekTier
|
||||
}
|
||||
weeks.push(entry)
|
||||
}
|
||||
|
|
@ -292,7 +301,18 @@ export default function Monthly() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-row-label">Full month forecast</div>
|
||||
<div className="section-row-label" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Full month forecast</span>
|
||||
{forecastMethod === 'rota' && (
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12, fontWeight: 400, color: 'var(--text-muted)' }}
|
||||
title="Only affects days with zero published shifts so far — those days use draft rota cost instead of falling back to a repeated estimate. A day with at least one published shift already uses that day's own rota cost (plus draft cost too, if this is checked) — it never blends with, or falls back to, a repeated day."
|
||||
>
|
||||
<input type="checkbox" checked={includeUnpublished} onChange={e => setIncludeUnpublished(e.target.checked)} />
|
||||
Include unpublished shifts in forecast
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="summary-grid" style={{ marginBottom: 20 }}>
|
||||
<div className="summary-card">
|
||||
<div className="label">Forecast EOM</div>
|
||||
|
|
@ -371,7 +391,9 @@ export default function Monthly() {
|
|||
{!loading && !error && (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-title">Weekly Breakdown{isCurrentMonth ? ' (forecast shaded)' : ''}</div>
|
||||
<div className="card-title">
|
||||
Weekly Breakdown{isCurrentMonth ? (forecastMethod === 'rota' ? ' (rota-informed forecast shaded, repeat-pattern lighter)' : ' (forecast shaded)') : ''}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={weeks} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
||||
|
|
@ -380,9 +402,10 @@ export default function Monthly() {
|
|||
<Legend itemSorter={item => -deptSummary.findIndex(dep => dep.department_name === item.dataKey)} />
|
||||
{deptSummary.map(dep => (
|
||||
<Bar key={dep.department_id} dataKey={dep.department_name} stackId="a" fill={dep.color}>
|
||||
{weeks.map((w, i) => (
|
||||
<Cell key={i} fill={dep.color} opacity={w.isPast ? 1 : 0.45} />
|
||||
))}
|
||||
{weeks.map((w, i) => {
|
||||
const opacity = w.isPast ? 1 : (w[`${dep.department_name}__tier`] === 'rota' ? 0.7 : 0.45)
|
||||
return <Cell key={i} fill={dep.color} opacity={opacity} />
|
||||
})}
|
||||
</Bar>
|
||||
))}
|
||||
</BarChart>
|
||||
|
|
@ -485,6 +508,9 @@ export default function Monthly() {
|
|||
</tbody>
|
||||
</table>
|
||||
{showOncosts && <p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>}
|
||||
{isCurrentMonth && forecastMethod === 'rota' && (
|
||||
<p className="footnote">Rota-based forecast figures exclude employer National Insurance — Workforce's schedules API doesn't provide it, only the timesheets/actuals API does.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pyDepts.length > 0 && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue