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
|
|
@ -1,9 +1,10 @@
|
|||
import { useState } from 'react'
|
||||
import { DollarSign, CalendarDays, TrendingUp, BarChart3, Wallet, Settings, Menu, LogOut } from 'lucide-react'
|
||||
import { DollarSign, CalendarDays, TrendingUp, BarChart3, Wallet, Settings, Menu, LogOut, Bot } from 'lucide-react'
|
||||
import AuthGate, { useAuth } from './components/AuthGate'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { useVersionCheck } from './hooks/useVersionCheck'
|
||||
import { can } from './types'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Weekly from './pages/Weekly'
|
||||
import Monthly from './pages/Monthly'
|
||||
import Rolling12Weeks from './pages/Rolling12Weeks'
|
||||
|
|
@ -11,9 +12,10 @@ import Rolling12Months from './pages/Rolling12Months'
|
|||
import Budgets from './pages/Budgets'
|
||||
import SettingsPage from './pages/Settings'
|
||||
|
||||
type Page = 'weekly' | 'monthly' | 'rolling-weeks' | 'rolling-months' | 'budgets' | 'settings'
|
||||
type Page = 'dashboard' | 'weekly' | 'monthly' | 'rolling-weeks' | 'rolling-months' | 'budgets' | 'settings'
|
||||
|
||||
const NAV: { id: Page; label: string; icon: React.ElementType; cap?: string }[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: Bot },
|
||||
{ id: 'weekly', label: 'Weekly', icon: CalendarDays },
|
||||
{ id: 'monthly', label: 'Monthly', icon: TrendingUp },
|
||||
{ id: 'rolling-weeks', label: '12 Weeks', icon: BarChart3 },
|
||||
|
|
@ -80,6 +82,7 @@ function Shell() {
|
|||
</header>
|
||||
|
||||
<main className="content">
|
||||
{page === 'dashboard' && <Dashboard />}
|
||||
{page === 'weekly' && <Weekly />}
|
||||
{page === 'monthly' && <Monthly />}
|
||||
{page === 'rolling-weeks' && <Rolling12Weeks />}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { DeptActuals, DeptScheduled, NetSalesDay, WageBudget, AppSetting, DeptPct, EmployeeDetail } from './types'
|
||||
import type { DeptActuals, DeptScheduled, ForecastMethod, NetSalesDay, WageBudget, AppSetting, DeptPct, EmployeeDetail, AIInsight } from './types'
|
||||
|
||||
const BASE = '/wages/api'
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
|||
return res.json()
|
||||
}
|
||||
|
||||
export function getActuals(from: string, to: string): Promise<{ departments: DeptActuals[]; show_oncosts: boolean; dept_pcts: Record<string, number> }> {
|
||||
export function getActuals(from: string, to: string): Promise<{ departments: DeptActuals[]; show_oncosts: boolean; dept_pcts: Record<string, number>; forecast_method: ForecastMethod }> {
|
||||
return request(`/actuals?from=${from}&to=${to}`)
|
||||
}
|
||||
|
||||
|
|
@ -82,3 +82,15 @@ export function getDeptDetail(deptId: string, from: string, to: string): Promise
|
|||
export function downloadExport(view: string, from: string, to: string): void {
|
||||
window.open(`${BASE}/export?view=${view}&from=${from}&to=${to}`, '_blank')
|
||||
}
|
||||
|
||||
export function getLatestInsight(): Promise<AIInsight | null> {
|
||||
return request('/ai-insights/latest')
|
||||
}
|
||||
|
||||
export function generateInsight(): Promise<{ success: boolean; content: string; input_tokens: number; output_tokens: number; model: string }> {
|
||||
return request('/ai-insights/generate', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function testAiInsightsConnection(): Promise<{ status: string; message: string }> {
|
||||
return request('/ai-insights/test', { method: 'POST' })
|
||||
}
|
||||
|
|
|
|||
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 }} />
|
||||
})
|
||||
}
|
||||
52
frontend/src/lib/forecast.ts
Normal file
52
frontend/src/lib/forecast.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { DeptScheduled } from '../types'
|
||||
|
||||
export type ForecastTier = 'actual' | 'rota-published' | 'rota-draft' | 'none'
|
||||
|
||||
export interface ForecastDayResult {
|
||||
cost: number
|
||||
tier: ForecastTier
|
||||
}
|
||||
|
||||
function addDaysStr(dateStr: string, n: number): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
d.setDate(d.getDate() + n)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const MAX_HOPS = 6 // 6 * 7 = 42 days back, comfortably within the 35-day actuals sync window
|
||||
|
||||
/**
|
||||
* Tiered forecast cost for a single department + date. Walks backward in 7-day steps from
|
||||
* `dateStr`; at each probed date, checks in priority order and stops at the first hit:
|
||||
* 1. Actual cost (ground truth — always wins if present).
|
||||
* 2. Published rota cost.
|
||||
* 3. Draft/unpublished rota cost, only if `includeUnpublished` is true.
|
||||
* If none apply, steps back another 7 days and repeats. This single rule covers "past days
|
||||
* use actuals", "near-term future days use published rota", and "days beyond any rota data
|
||||
* repeat the same weekday from a prior period" — including the case where that prior period
|
||||
* is itself still in the future but has its own rota entry, rather than skipping straight
|
||||
* past it to an older actual.
|
||||
*/
|
||||
export function forecastDayCost(
|
||||
dateStr: string,
|
||||
actualDays: Record<string, { cost: number }> | undefined,
|
||||
scheduledDays: DeptScheduled['days'] | undefined,
|
||||
includeUnpublished: boolean,
|
||||
): ForecastDayResult {
|
||||
let probe = dateStr
|
||||
for (let hop = 0; hop <= MAX_HOPS; hop++) {
|
||||
const actual = actualDays?.[probe]
|
||||
if (actual != null) return { cost: actual.cost, tier: 'actual' }
|
||||
|
||||
const sched = scheduledDays?.[probe]
|
||||
const hasPublished = (sched?.published_shift_count ?? 0) > 0
|
||||
const hasUnpublished = (sched?.unpublished_shift_count ?? 0) > 0
|
||||
if (hasPublished || (includeUnpublished && hasUnpublished)) {
|
||||
const cost = (sched?.published_cost ?? 0) + (includeUnpublished ? (sched?.unpublished_cost ?? 0) : 0)
|
||||
return { cost, tier: hasPublished ? 'rota-published' : 'rota-draft' }
|
||||
}
|
||||
|
||||
probe = addDaysStr(probe, -7)
|
||||
}
|
||||
return { cost: 0, tier: 'none' }
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { RefreshCw, Download, X, CheckSquare, Square } from 'lucide-react'
|
||||
import { getSettings, saveSettings, getDepartments, triggerSync, getSyncStatus, cancelBackfill } from '../api'
|
||||
import { RefreshCw, Download, X, CheckSquare, Square, Bot } from 'lucide-react'
|
||||
import { getSettings, saveSettings, getDepartments, triggerSync, getSyncStatus, cancelBackfill, testAiInsightsConnection, generateInsight } from '../api'
|
||||
import type { AppSetting, Department } from '../types'
|
||||
|
||||
function fmtDate(iso: string | null): string {
|
||||
|
|
@ -18,6 +18,10 @@ export default function SettingsPage() {
|
|||
const [fetchingDepts, setFetchingDepts] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'connected' | 'error'>('idle')
|
||||
const [testMessage, setTestMessage] = useState('')
|
||||
const [genStatus, setGenStatus] = useState<'idle' | 'generating' | 'done' | 'error'>('idle')
|
||||
const [genMessage, setGenMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getSettings(), getSyncStatus()])
|
||||
|
|
@ -49,6 +53,11 @@ export default function SettingsPage() {
|
|||
{ key: 'forecasting_api_key', value: settings.forecasting_api_key ?? '' },
|
||||
{ key: 'show_oncosts', value: settings.show_oncosts ?? 'true' },
|
||||
{ key: 'departments', value: deptsJson },
|
||||
{ key: 'forecast_method', value: settings.forecast_method ?? 'repeat' },
|
||||
{ key: 'ai_insights_enabled', value: settings.ai_insights_enabled ?? 'false' },
|
||||
{ key: 'ai_insights_model', value: settings.ai_insights_model ?? 'claude-haiku-4-5-20251001' },
|
||||
{ key: 'ai_insights_schedule_time', value: settings.ai_insights_schedule_time ?? '07:15' },
|
||||
{ key: 'ai_insights_daily_token_budget', value: settings.ai_insights_daily_token_budget ?? '5000' },
|
||||
])
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
|
|
@ -133,6 +142,32 @@ export default function SettingsPage() {
|
|||
setBackfillProg(null)
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestStatus('testing'); setTestMessage('')
|
||||
try {
|
||||
const res = await testAiInsightsConnection()
|
||||
setTestStatus('connected')
|
||||
setTestMessage(res.message || 'Connected')
|
||||
} catch (e: unknown) {
|
||||
setTestStatus('error')
|
||||
setTestMessage(e instanceof Error ? e.message : 'Connection failed')
|
||||
}
|
||||
setTimeout(() => { setTestStatus('idle'); setTestMessage('') }, 5000)
|
||||
}
|
||||
|
||||
const handleGenerateNow = async () => {
|
||||
setGenStatus('generating'); setGenMessage('')
|
||||
try {
|
||||
const res = await generateInsight()
|
||||
setGenStatus('done')
|
||||
setGenMessage(`Generated! ${res.input_tokens} in / ${res.output_tokens} out tokens`)
|
||||
} catch (e: unknown) {
|
||||
setGenStatus('error')
|
||||
setGenMessage(e instanceof Error ? e.message : 'Generation failed')
|
||||
}
|
||||
setTimeout(() => { setGenStatus('idle'); setGenMessage('') }, 8000)
|
||||
}
|
||||
|
||||
if (loading) return <div className="state-center">Loading…</div>
|
||||
|
||||
return (
|
||||
|
|
@ -191,6 +226,107 @@ export default function SettingsPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Forecast method */}
|
||||
<div className="card">
|
||||
<div className="card-title">Forecast Method</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 13 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.forecast_method === 'rota'}
|
||||
onChange={e => handleChange('forecast_method', e.target.checked ? 'rota' : 'repeat')}
|
||||
/>
|
||||
Use published rota for near-term forecast (falls back to repeat-pattern automatically)
|
||||
</label>
|
||||
<p style={{ margin: '8px 0 0', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
When enabled, days with a published rota use its planned cost instead of repeating a prior period.
|
||||
Days without a published rota yet (including a part-built, unpublished one) still fall back to the
|
||||
repeat-pattern forecast, so a not-yet-finished rota can't drag the figure down. When disabled, forecasts
|
||||
always use the repeat-pattern method only, unchanged from before.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AI Insights */}
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Bot size={16} strokeWidth={1.75} color="var(--gold)" />
|
||||
AI Insights
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 13, marginBottom: 12 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.ai_insights_enabled === 'true'}
|
||||
onChange={e => handleChange('ai_insights_enabled', e.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
Enable daily AI-generated wage cost briefing
|
||||
</label>
|
||||
|
||||
<p style={{ margin: '0 0 12px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Uses the Anthropic (Claude) API key configured centrally in Portal → Settings → Integrations —
|
||||
not stored per-app here.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 4, color: 'var(--text-muted)' }}>
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
value={settings.ai_insights_model ?? 'claude-haiku-4-5-20251001'}
|
||||
onChange={e => handleChange('ai_insights_model', e.target.value)}
|
||||
>
|
||||
<option value="claude-haiku-4-5-20251001">Claude Haiku 4.5 (cheapest)</option>
|
||||
<option value="claude-sonnet-4-6">Claude Sonnet 4.6</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 4, color: 'var(--text-muted)' }}>
|
||||
Schedule Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={settings.ai_insights_schedule_time ?? '07:15'}
|
||||
onChange={e => handleChange('ai_insights_schedule_time', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 4, color: 'var(--text-muted)' }}>
|
||||
Daily Token Budget
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1000}
|
||||
max={50000}
|
||||
step={1000}
|
||||
value={settings.ai_insights_daily_token_budget ?? '5000'}
|
||||
onChange={e => handleChange('ai_insights_daily_token_budget', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={testStatus === 'testing'}>
|
||||
{testStatus === 'testing' ? 'Testing…' : 'Test Connection'}
|
||||
</button>
|
||||
{testMessage && (
|
||||
<div style={{ marginTop: 4, fontSize: 12, color: testStatus === 'connected' ? '#059669' : '#dc2626' }}>
|
||||
{testMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<button className="btn btn-primary" onClick={handleGenerateNow} disabled={genStatus === 'generating'}>
|
||||
{genStatus === 'generating' ? 'Generating…' : 'Generate Now'}
|
||||
</button>
|
||||
{genMessage && (
|
||||
<div style={{ marginTop: 4, fontSize: 12, color: genStatus === 'done' ? '#059669' : '#dc2626' }}>
|
||||
{genMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Department filter */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Download } from 'lucide-react'
|
||||
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 localStr(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
|
|
@ -68,6 +69,9 @@ export default function Weekly() {
|
|||
const pyToStr = addDaysStr(fromStr, -364 + daysElapsed)
|
||||
|
||||
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)
|
||||
|
|
@ -96,9 +100,10 @@ export default function Weekly() {
|
|||
const monthKeys = Array.from(new Set([monthKeyOf(fromStr), monthKeyOf(toStr)]))
|
||||
const monthRanges = monthKeys.map(monthRangeOf)
|
||||
|
||||
const [actRes, salesRes, budgetRes, pyActRes, monthSalesResList] = await Promise.all([
|
||||
const [actRes, schedRes, salesRes, budgetRes, pyActRes, monthSalesResList] = await Promise.all([
|
||||
// 14-day fetch: prev week + current week so prev-week data is in dep.days for forecast + comparison
|
||||
getActuals(prevWeekFrom, toStr),
|
||||
getScheduled(fromStr, toStr),
|
||||
getNetSales(fromStr, toStr),
|
||||
getBudgets(),
|
||||
getActuals(pyFromStr, pyToStr),
|
||||
|
|
@ -120,8 +125,10 @@ export default function Weekly() {
|
|||
setBudgetsByMonth(budgMap)
|
||||
|
||||
setDepts(actRes.departments)
|
||||
setScheduled(schedRes.departments)
|
||||
setShowOncosts(actRes.show_oncosts)
|
||||
setDeptPcts(actRes.dept_pcts)
|
||||
setForecastMethod(actRes.forecast_method)
|
||||
|
||||
// Net sales: WTD to yesterday for current week, full for past weeks
|
||||
const cutoffDate = isCurrentWk ? yesterdayStr : toStr
|
||||
|
|
@ -198,14 +205,17 @@ export default function Weekly() {
|
|||
.filter(([d]) => d >= fromStr && d <= wtdCutoff)
|
||||
.reduce((s, [, v]) => s + v.cost, 0)
|
||||
|
||||
// Forecast: WTD + prior-week same-day actual for each remaining day
|
||||
// Forecast: WTD + remaining days. When forecast_method is 'rota', remaining days prefer
|
||||
// published rota (or +draft rota if includeUnpublished), falling back to prior-week same-day
|
||||
// actual — see forecastDayCost. When 'repeat' (default), this is unchanged from before: no
|
||||
// scheduled data is passed in, so it always resolves to the prior-week same-day actual.
|
||||
let forecastFull = wtdCost
|
||||
if (isCurrentWeek) {
|
||||
const schedDep = forecastMethod === 'rota' ? scheduled.find(s => s.department_id === dep.department_id) : undefined
|
||||
for (let i = 0; i <= 6; i++) {
|
||||
const dateStr = addDaysStr(fromStr, i)
|
||||
if (dateStr <= yesterdayStr) continue
|
||||
const priorStr = addDaysStr(dateStr, -7) // same day last week — in dep.days (14-day fetch)
|
||||
forecastFull += dep.days[priorStr]?.cost ?? 0
|
||||
forecastFull += forecastDayCost(dateStr, dep.days, schedDep?.days, includeUnpublished).cost
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -294,7 +304,18 @@ export default function Weekly() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-row-label">Full week forecast</div>
|
||||
<div className="section-row-label" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Full week 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 Full Week</div>
|
||||
|
|
|
|||
|
|
@ -18,9 +18,16 @@ export interface DeptActuals {
|
|||
export interface DeptScheduled {
|
||||
department_id: string
|
||||
department_name: string
|
||||
days: Record<string, { cost: number; shift_count: number }>
|
||||
days: Record<string, {
|
||||
published_cost: number
|
||||
unpublished_cost: number
|
||||
published_shift_count: number
|
||||
unpublished_shift_count: number
|
||||
}>
|
||||
}
|
||||
|
||||
export type ForecastMethod = 'repeat' | 'rota'
|
||||
|
||||
export interface NetSalesDay {
|
||||
date: string
|
||||
net_sales: number
|
||||
|
|
@ -60,3 +67,13 @@ export interface EmployeeDetail {
|
|||
cost: number
|
||||
shift_count: number
|
||||
}
|
||||
|
||||
export interface AIInsight {
|
||||
id: number
|
||||
generated_at: string
|
||||
content: string
|
||||
model: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
triggered_by: string
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue