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:
jtricerolph 2026-07-24 20:43:18 +00:00
parent 11e5a2b78a
commit 95da5ea237
26 changed files with 8914 additions and 78 deletions

View file

@ -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 && (