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
93
frontend/src/pages/Dashboard.tsx
Normal file
93
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue