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

@ -0,0 +1,37 @@
export function formatAge(iso: string): string {
const ms = Date.now() - new Date(iso).getTime()
const mins = Math.floor(ms / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
}
function escHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
export function renderContent(text: string) {
return text.split('\n').map((line, i) => {
const safe = escHtml(line)
const processed = safe.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
if (line.startsWith('- ') || line.startsWith('* ')) {
return (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
<span style={{ color: 'var(--gold)', flexShrink: 0 }}></span>
<span dangerouslySetInnerHTML={{ __html: processed.slice(2) }} />
</div>
)
}
if (line.startsWith('## ') || line.startsWith('# ')) {
const txt = line.replace(/^#+\s*/, '')
return <p key={i} style={{ fontWeight: 600, marginTop: 12, marginBottom: 6, color: 'var(--text-primary)' }}>{txt}</p>
}
if (line.trim() === '') return <div key={i} style={{ height: 8 }} />
return <p key={i} style={{ marginBottom: 4 }} dangerouslySetInnerHTML={{ __html: processed }} />
})
}