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
37
frontend/src/lib/aiInsight.tsx
Normal file
37
frontend/src/lib/aiInsight.tsx
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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 }} />
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue