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,93 @@
import { useState, useEffect, useCallback } from 'react'
import { Bot, Clock, RefreshCw } from 'lucide-react'
import { getLatestInsight, generateInsight } from '../api'
import { formatAge, renderContent } from '../lib/aiInsight'
import type { AIInsight } from '../types'
const REFRESH_MS = 5 * 60_000
export default function Dashboard() {
const [insight, setInsight] = useState<AIInsight | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [generating, setGenerating] = useState(false)
const [genError, setGenError] = useState<string | null>(null)
const load = useCallback(() => {
getLatestInsight()
.then(setInsight)
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load insight'))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
load()
const id = setInterval(load, REFRESH_MS)
return () => clearInterval(id)
}, [load])
const handleGenerate = async () => {
setGenerating(true)
setGenError(null)
try {
await generateInsight()
load()
} catch (e) {
setGenError(e instanceof Error ? e.message : 'Failed to generate insight')
} finally {
setGenerating(false)
}
}
return (
<div>
<div className="page-header">
<h1 className="page-title">Dashboard</h1>
<button className="btn btn-primary" onClick={handleGenerate} disabled={generating}>
<RefreshCw size={14} strokeWidth={1.75} />
{generating ? 'Generating…' : 'Generate Now'}
</button>
</div>
{genError && <div className="state-center" style={{ color: '#dc2626', marginBottom: 16 }}>{genError}</div>}
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8, margin: 0 }}>
<Bot size={16} strokeWidth={1.75} color="var(--gold)" />
AI Insight
</div>
{insight && (
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'var(--text-muted)' }}>
<Clock size={12} strokeWidth={1.75} />
{formatAge(insight.generated_at)}
{insight.model && (
<span style={{ marginLeft: 6, background: 'var(--body-bg)', borderRadius: 4, padding: '1px 6px' }}>
{insight.model}
</span>
)}
</span>
)}
</div>
{loading && <div className="state-center">Loading</div>}
{!loading && error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
{!loading && !error && !insight && (
<div className="state-center">
No insight generated yet. Click <strong>Generate Now</strong> to produce a summary.
</div>
)}
{!loading && !error && insight && (
<div style={{ fontSize: 13.5, lineHeight: 1.65, color: 'var(--text-primary)' }}>
<div style={{ marginBottom: 16 }}>{renderContent(insight.content)}</div>
{(insight.input_tokens || insight.output_tokens) && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
{insight.input_tokens} / {insight.output_tokens} tokens · {insight.triggered_by}
</div>
)}
</div>
)}
</div>
</div>
)
}