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

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.local
.DS_Store

1134
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@
"dev": "node --watch src/index.js"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"fastify": "^4.28.1",

View file

@ -26,14 +26,17 @@ export async function initDb() {
CREATE INDEX IF NOT EXISTS wage_actuals_date_idx ON wage_actuals(date);
CREATE TABLE IF NOT EXISTS wage_scheduled (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
department_id TEXT NOT NULL,
department_name TEXT NOT NULL,
base_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
total_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
shift_count INTEGER NOT NULL DEFAULT 0,
cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
department_id TEXT NOT NULL,
department_name TEXT NOT NULL,
published_base_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
published_total_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
published_shift_count INTEGER NOT NULL DEFAULT 0,
unpublished_base_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
unpublished_total_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
unpublished_shift_count INTEGER NOT NULL DEFAULT 0,
cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(date, department_id)
);
CREATE INDEX IF NOT EXISTS wage_scheduled_date_idx ON wage_scheduled(date);
@ -57,16 +60,58 @@ export async function initDb() {
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS ai_insights (
id SERIAL PRIMARY KEY,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
insight_type TEXT NOT NULL DEFAULT 'daily_summary',
content TEXT NOT NULL,
model TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
data_snapshot JSONB,
triggered_by TEXT NOT NULL DEFAULT 'scheduler'
);
CREATE INDEX IF NOT EXISTS ai_insights_generated_idx ON ai_insights(generated_at DESC);
`)
// One-time migration for pre-existing wage_scheduled tables: split the old combined
// base_cost/total_cost/shift_count into published/unpublished columns. wage_scheduled
// is a rolling 14-day cache repopulated by runRollingSync() within an hour of startup,
// so truncating it is safe — no fuzzy backfill of the old combined costs needed.
const oldCol = await pool.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = 'wage_scheduled' AND column_name = 'base_cost'
`)
if (oldCol.rows.length > 0) {
await pool.query(`TRUNCATE wage_scheduled`)
await pool.query(`
ALTER TABLE wage_scheduled
ADD COLUMN IF NOT EXISTS published_base_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS published_total_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS published_shift_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS unpublished_base_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS unpublished_total_cost DECIMAL(10,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS unpublished_shift_count INTEGER NOT NULL DEFAULT 0,
DROP COLUMN base_cost,
DROP COLUMN total_cost,
DROP COLUMN shift_count
`)
}
await pool.query(`
INSERT INTO wages_config (key, value) VALUES
('forecasting_url', ''),
('forecasting_api_key', ''),
('show_oncosts', 'true'),
('departments', ''),
('forecast_method', 'repeat'),
('sync_last_at', ''),
('backfill_last_at', '')
('backfill_last_at', ''),
('ai_insights_enabled', 'false'),
('ai_insights_model', 'claude-haiku-4-5-20251001'),
('ai_insights_schedule_time', '07:15'),
('ai_insights_daily_token_budget', '5000')
ON CONFLICT (key) DO NOTHING
`)
}

View file

@ -9,6 +9,7 @@ import { budgetsRoutes } from './routes/budgets.js'
import { exportRoutes } from './routes/export.js'
import { syncRoutes } from './routes/sync.js'
import { settingsRoutes } from './routes/settings.js'
import { aiInsightsRoutes } from './routes/ai-insights.js'
import { startScheduler } from './lib/scheduler.js'
const app = Fastify({ logger: true, trustProxy: true })
@ -29,6 +30,7 @@ await app.register(budgetsRoutes)
await app.register(exportRoutes)
await app.register(syncRoutes)
await app.register(settingsRoutes)
await app.register(aiInsightsRoutes)
try {
await initDb()

View file

@ -0,0 +1,485 @@
import Anthropic from '@anthropic-ai/sdk'
import { pool, getConfig } from '../db.js'
import { getAnthropicApiKey } from '../lib/central-settings.js'
import { fcFetch } from '../lib/forecasting-client.js'
import { forecastDayCost } from '../lib/forecast.js'
export const DEFAULT_MODEL = 'claude-haiku-4-5-20251001'
export const DEFAULT_DAILY_TOKEN_BUDGET = 5000
const MAX_OUTPUT_TOKENS = 600
export const MANUAL_RATE_LIMIT_MINUTES = 5
function toISODate(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function fmtDateUK(isoDate) {
const [y, m, d] = String(isoDate).split('-')
return `${d}/${m}/${y}`
}
function fmtMoney(n) {
return n == null ? '-' : `£${Number(n).toLocaleString('en-GB', { maximumFractionDigits: 0 })}`
}
function fmtPct(n) {
return n == null ? '-' : `${(n * 100).toFixed(0)}%`
}
async function getCostCol() {
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
return showOncosts ? 'total_cost' : 'base_cost'
}
export async function getAiInsightsConfig() {
const [enabled, model, scheduleTime, dailyTokenBudget] = await Promise.all([
getConfig('ai_insights_enabled'),
getConfig('ai_insights_model'),
getConfig('ai_insights_schedule_time'),
getConfig('ai_insights_daily_token_budget'),
])
return {
enabled: enabled === 'true',
model: model || DEFAULT_MODEL,
scheduleTime: scheduleTime || '07:15',
dailyTokenBudget: parseInt(dailyTokenBudget, 10) || DEFAULT_DAILY_TOKEN_BUDGET,
}
}
export async function checkDailyBudget(budgetTokens) {
const res = await pool.query(
`SELECT COALESCE(SUM(input_tokens + output_tokens), 0) AS total
FROM ai_insights WHERE generated_at >= CURRENT_DATE`
)
const usedToday = parseInt(res.rows[0].total, 10)
return { withinBudget: usedToday < budgetTokens, usedToday }
}
// Month-to-date total wages cost (company-wide) vs the total month budget.
// No per-department budget split — that split is a rough estimate and skews comparisons.
export async function gatherMonthProgressData() {
const costCol = await getCostCol()
const today = new Date()
const monthStart = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-01`
const yesterdayStr = toISODate(new Date(today.getTime() - 86_400_000))
const daysInMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate()
const daysElapsed = Math.max(0, today.getDate() - 1)
const [actualRes, budgetRes] = await Promise.all([
pool.query(
`SELECT COALESCE(SUM(${costCol}), 0) AS mtd_cost
FROM wage_actuals WHERE date >= $1 AND date <= $2`,
[monthStart, yesterdayStr]
),
pool.query(`SELECT budget_amount FROM wage_budgets WHERE month = $1`, [monthStart]),
])
const mtdCost = parseFloat(actualRes.rows[0].mtd_cost)
const budgetAmount = budgetRes.rows[0] ? parseFloat(budgetRes.rows[0].budget_amount) : null
return {
monthStart,
monthLabel: monthStart.slice(0, 7),
yesterdayStr,
daysElapsed,
daysInMonth,
pctMonthElapsed: daysElapsed / daysInMonth,
mtdCost,
budgetAmount,
pctBudgetUsed: budgetAmount ? mtdCost / budgetAmount : null,
}
}
// Full previous month and full same-month-last-year totals (complete datasets, not
// sliced to a matching to-date range) — the model draws its own pace comparison.
export async function gatherPriorPeriodData() {
const costCol = await getCostCol()
const today = new Date()
const prevMonthStart = toISODate(new Date(today.getFullYear(), today.getMonth() - 1, 1))
const prevMonthEnd = toISODate(new Date(today.getFullYear(), today.getMonth(), 0))
const lastYearStart = toISODate(new Date(today.getFullYear() - 1, today.getMonth(), 1))
const lastYearEnd = toISODate(new Date(today.getFullYear() - 1, today.getMonth() + 1, 0))
const shapeDepts = rows => rows.map(r => ({
department_id: r.department_id,
department_name: r.department_name,
cost: parseFloat(r.cost),
}))
const deptQuery = (from, to) => pool.query(
`SELECT department_id, department_name, SUM(${costCol}) AS cost
FROM wage_actuals WHERE date >= $1 AND date <= $2
GROUP BY department_id, department_name ORDER BY SUM(${costCol}) DESC`,
[from, to]
)
const [prevMonthRes, lastYearRes] = await Promise.all([
deptQuery(prevMonthStart, prevMonthEnd),
deptQuery(lastYearStart, lastYearEnd),
])
const prevMonthDepts = shapeDepts(prevMonthRes.rows)
const lastYearDepts = shapeDepts(lastYearRes.rows)
return {
prevMonthLabel: prevMonthStart.slice(0, 7),
prevMonthTotal: prevMonthDepts.reduce((s, d) => s + d.cost, 0),
prevMonthDepts,
lastYearLabel: lastYearStart.slice(0, 7),
lastYearTotal: lastYearDepts.reduce((s, d) => s + d.cost, 0),
lastYearDepts,
}
}
// Department-level variance between scheduled (rota) and actual cost, over the trailing
// window. Rota rows for past dates aren't deleted once synced, so this covers real history.
export async function gatherRotaVsActualData(days = 28) {
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
const actualCostCol = showOncosts ? 'total_cost' : 'base_cost'
const schedCostExpr = showOncosts
? '(published_total_cost + unpublished_total_cost)'
: '(published_base_cost + unpublished_base_cost)'
const today = new Date()
const fromStr = toISODate(new Date(today.getTime() - days * 86_400_000))
const toStr = toISODate(new Date(today.getTime() - 86_400_000))
const [actualRes, schedRes] = await Promise.all([
pool.query(
`SELECT date, department_id, department_name, ${actualCostCol} AS cost
FROM wage_actuals WHERE date >= $1 AND date <= $2`,
[fromStr, toStr]
),
pool.query(
`SELECT date, department_id, ${schedCostExpr} AS cost
FROM wage_scheduled WHERE date >= $1 AND date <= $2`,
[fromStr, toStr]
),
])
const schedMap = {}
for (const r of schedRes.rows) {
schedMap[`${r.date.toISOString().slice(0, 10)}:${r.department_id}`] = parseFloat(r.cost)
}
const byDept = {}
for (const r of actualRes.rows) {
const dateStr = r.date.toISOString().slice(0, 10)
const dep = r.department_id
byDept[dep] ??= { department_id: dep, department_name: r.department_name, actualTotal: 0, schedTotal: 0, daysWithRota: 0 }
const actualCost = parseFloat(r.cost)
const schedCost = schedMap[`${dateStr}:${dep}`]
byDept[dep].actualTotal += actualCost
if (schedCost != null) {
byDept[dep].schedTotal += schedCost
byDept[dep].daysWithRota++
}
}
const depts = Object.values(byDept)
.map(d => ({
...d,
variance: d.actualTotal - d.schedTotal,
variancePct: d.schedTotal > 0 ? (d.actualTotal - d.schedTotal) / d.schedTotal : null,
}))
.sort((a, b) => Math.abs(b.variance) - Math.abs(a.variance))
return { fromStr, toStr, depts }
}
// Forward-looking projection — reuses the app's existing tiered forecastDayCost() logic
// and the forecast_method setting already used by the Monthly/Weekly pages, so this never
// disagrees with what those pages show. Not related to forecasting app's revenue model.
export async function gatherForecastData(monthProgress) {
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
const forecastMethodRaw = await getConfig('forecast_method')
const forecastMethod = forecastMethodRaw === 'rota' ? 'rota' : 'repeat'
const today = new Date()
const todayStr = toISODate(today)
const monthEndDate = new Date(today.getFullYear(), today.getMonth() + 1, 0)
const monthEndStr = toISODate(monthEndDate)
const actualsFrom = toISODate(new Date(today.getTime() - 42 * 86_400_000))
const [actualRes, schedRes] = await Promise.all([
pool.query(
`SELECT date, department_id, department_name, ${showOncosts ? 'total_cost' : 'base_cost'} AS cost
FROM wage_actuals WHERE date >= $1 AND date < $2`,
[actualsFrom, todayStr]
),
pool.query(
`SELECT date, department_id, department_name,
published_base_cost, published_total_cost, published_shift_count,
unpublished_base_cost, unpublished_total_cost, unpublished_shift_count
FROM wage_scheduled WHERE date >= $1 AND date <= $2`,
[todayStr, monthEndStr]
),
])
const deptNames = {}
const actualByDept = {}
for (const r of actualRes.rows) {
const dep = r.department_id
deptNames[dep] = r.department_name
actualByDept[dep] ??= {}
actualByDept[dep][r.date.toISOString().slice(0, 10)] = { cost: parseFloat(r.cost) }
}
const schedByDept = {}
for (const r of schedRes.rows) {
const dep = r.department_id
deptNames[dep] = r.department_name
schedByDept[dep] ??= {}
schedByDept[dep][r.date.toISOString().slice(0, 10)] = {
published_cost: parseFloat(showOncosts ? r.published_total_cost : r.published_base_cost),
unpublished_cost: parseFloat(showOncosts ? r.unpublished_total_cost : r.unpublished_base_cost),
published_shift_count: r.published_shift_count,
unpublished_shift_count: r.unpublished_shift_count,
}
}
const remainingDays = []
for (let d = new Date(today); d <= monthEndDate; d.setDate(d.getDate() + 1)) {
remainingDays.push(toISODate(d))
}
let remainingTotal = 0
const byDept = []
for (const dep of Object.keys(deptNames)) {
const schedDep = forecastMethod === 'rota' ? schedByDept[dep] : undefined
let depRemaining = 0
for (const dateStr of remainingDays) {
depRemaining += forecastDayCost(dateStr, actualByDept[dep], schedDep, false).cost
}
remainingTotal += depRemaining
byDept.push({ department_id: dep, department_name: deptNames[dep], remaining: depRemaining })
}
const projectedTotal = (monthProgress?.mtdCost ?? 0) + remainingTotal
return {
forecastMethod,
remainingDaysCount: remainingDays.length,
remainingTotal,
projectedTotal,
byDept: byDept.sort((a, b) => b.remaining - a.remaining),
}
}
// Individual shifts/wages worth a look — top-cost employees over the trailing week.
export async function gatherEmployeeAnomalies(days = 7) {
const costCol = await getCostCol()
const today = new Date()
const fromStr = toISODate(new Date(today.getTime() - days * 86_400_000))
const toStr = toISODate(new Date(today.getTime() - 86_400_000))
const res = await pool.query(
`SELECT employee_id, employee_name, department_id,
SUM(${costCol}) AS cost, SUM(shift_count)::int AS shift_count
FROM wage_actuals_detail
WHERE date >= $1 AND date <= $2
GROUP BY employee_id, employee_name, department_id
ORDER BY SUM(${costCol}) DESC
LIMIT 15`,
[fromStr, toStr]
)
return {
fromStr,
toStr,
employees: res.rows.map(r => ({
employee_id: r.employee_id,
employee_name: r.employee_name,
department_id: r.department_id,
cost: parseFloat(r.cost),
shift_count: r.shift_count,
})),
}
}
// Wage cost as a % of net sales, month-to-date — reuses the same cross-app call net-sales.js
// already makes to the forecasting app's public revenue API.
export async function gatherRevenueCorrelation(monthProgress) {
try {
const days = Math.max(1, monthProgress.daysElapsed)
const data = await fcFetch(`/forecast/revenue?start_date=${monthProgress.monthStart}&days=${days}&type=all&dow_align=true`)
const rows = data?.data ?? []
const totalRevenue = rows.reduce((s, d) => s + parseFloat(d.total?.forecast ?? d.total?.otb ?? 0), 0)
return {
totalRevenue,
wagePct: totalRevenue > 0 ? (monthProgress.mtdCost / totalRevenue) * 100 : null,
daysCovered: rows.length,
}
} catch (e) {
return { error: e.message }
}
}
export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation) {
const systemMsg =
"You are an AI assistant for a wage cost controller in a UK hospitality business. Analyze the data below and " +
"provide a concise daily briefing (3-5 bullet points). Focus on: how this month's wage cost is tracking " +
"against budget, the month-end forecast, notable variance between rota and actual cost by department and " +
"what might be causing it, individual pay anomalies worth a look, and wage cost as a percentage of revenue. " +
"Use UK date format (DD/MM/YYYY) and GBP (£) for all monetary values. Be specific with department/employee " +
"names and numbers. Keep it actionable — no fluff or generic advice."
const lines = []
lines.push(`## Month to Date (${monthProgress.monthLabel})`)
lines.push(
`Day ${monthProgress.daysElapsed} of ${monthProgress.daysInMonth} (${fmtPct(monthProgress.pctMonthElapsed)} of month elapsed)`
)
lines.push(
`MTD cost: ${fmtMoney(monthProgress.mtdCost)} | Month budget: ${fmtMoney(monthProgress.budgetAmount)}` +
(monthProgress.pctBudgetUsed != null ? ` (${fmtPct(monthProgress.pctBudgetUsed)} used)` : '')
)
lines.push('')
lines.push('## Prior Period Comparison (full-month totals, for context — not a budget split)')
lines.push(`Previous month (${priorPeriod.prevMonthLabel}) total: ${fmtMoney(priorPeriod.prevMonthTotal)}`)
for (const d of priorPeriod.prevMonthDepts) lines.push(` ${d.department_name}: ${fmtMoney(d.cost)}`)
lines.push(`Same month last year (${priorPeriod.lastYearLabel}) total: ${fmtMoney(priorPeriod.lastYearTotal)}`)
for (const d of priorPeriod.lastYearDepts) lines.push(` ${d.department_name}: ${fmtMoney(d.cost)}`)
lines.push('')
lines.push(`## Rota vs Actual Variance (${fmtDateUK(rotaVsActual.fromStr)} - ${fmtDateUK(rotaVsActual.toStr)})`)
lines.push('Department | Actual | Rota | Variance | Variance %')
for (const d of rotaVsActual.depts) {
lines.push(
`${d.department_name} | ${fmtMoney(d.actualTotal)} | ${fmtMoney(d.schedTotal)} | ` +
`${fmtMoney(d.variance)} | ${fmtPct(d.variancePct)}`
)
}
lines.push('')
lines.push(`## Forecast (method: ${forecast.forecastMethod}, ${forecast.remainingDaysCount} days remaining)`)
lines.push(
`Projected month-end total: ${fmtMoney(forecast.projectedTotal)} vs budget ${fmtMoney(monthProgress.budgetAmount)}` +
(monthProgress.budgetAmount != null
? ` (${forecast.projectedTotal > monthProgress.budgetAmount ? 'over' : 'under'} by ${fmtMoney(Math.abs(forecast.projectedTotal - monthProgress.budgetAmount))})`
: '')
)
for (const d of forecast.byDept) lines.push(` ${d.department_name} remaining: ${fmtMoney(d.remaining)}`)
lines.push('')
lines.push(`## Notable Individual Shifts/Wages (${fmtDateUK(anomalies.fromStr)} - ${fmtDateUK(anomalies.toStr)})`)
lines.push('Employee | Department | Cost | Shifts')
for (const e of anomalies.employees) {
lines.push(`${e.employee_name} | ${e.department_id} | ${fmtMoney(e.cost)} | ${e.shift_count}`)
}
lines.push('')
lines.push('## Wage Cost vs Revenue (month to date)')
if (revenueCorrelation.error) {
lines.push(`Unavailable: ${revenueCorrelation.error}`)
} else {
lines.push(
`Revenue: ${fmtMoney(revenueCorrelation.totalRevenue)} | Wage cost: ${fmtMoney(monthProgress.mtdCost)} | ` +
`Wage %: ${revenueCorrelation.wagePct != null ? revenueCorrelation.wagePct.toFixed(1) + '%' : '-'}`
)
}
return { systemMsg, userMsg: lines.join('\n') }
}
export async function callLlm(apiKey, systemMsg, userMsg, model) {
const client = new Anthropic({ apiKey })
const response = await client.messages.create({
model,
max_tokens: MAX_OUTPUT_TOKENS,
temperature: 0.2,
system: systemMsg,
messages: [{ role: 'user', content: userMsg }],
})
const content = response.content?.[0]?.text ?? ''
return {
content,
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
model,
}
}
export async function saveInsight({ content, model, input_tokens, output_tokens, data_snapshot, triggered_by }) {
await pool.query(
`INSERT INTO ai_insights (content, model, input_tokens, output_tokens, data_snapshot, triggered_by)
VALUES ($1, $2, $3, $4, $5::jsonb, $6)`,
[content, model, input_tokens, output_tokens, JSON.stringify(data_snapshot), triggered_by]
)
}
export async function cleanupOldInsights(keepDays = 90) {
await pool.query(`DELETE FROM ai_insights WHERE generated_at < NOW() - ($1 || ' days')::interval`, [keepDays])
}
export async function generateInsight(triggeredBy = 'scheduler') {
const config = await getAiInsightsConfig()
if (!config.enabled) return { success: false, error: 'AI insights disabled' }
let apiKey
try {
apiKey = await getAnthropicApiKey()
} catch (e) {
return { success: false, error: e.message }
}
const { withinBudget, usedToday } = await checkDailyBudget(config.dailyTokenBudget)
if (!withinBudget) {
return { success: false, error: `Daily token budget exceeded (${usedToday}/${config.dailyTokenBudget} tokens used today)` }
}
const monthProgress = await gatherMonthProgressData()
const [priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation] = await Promise.all([
gatherPriorPeriodData(),
gatherRotaVsActualData(),
gatherForecastData(monthProgress),
gatherEmployeeAnomalies(),
gatherRevenueCorrelation(monthProgress),
])
const { systemMsg, userMsg } = buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation)
let result
try {
result = await callLlm(apiKey, systemMsg, userMsg, config.model)
} catch (e) {
return { success: false, error: `LLM call failed: ${e.message}` }
}
await saveInsight({
content: result.content,
model: result.model,
input_tokens: result.input_tokens,
output_tokens: result.output_tokens,
data_snapshot: { monthProgress, priorPeriod, rotaVsActual, forecast, anomalies, revenueCorrelation },
triggered_by: triggeredBy,
})
try {
await cleanupOldInsights(90)
} catch (e) {
console.warn('[ai-insights] cleanup failed:', e.message)
}
return {
success: true,
content: result.content,
input_tokens: result.input_tokens,
output_tokens: result.output_tokens,
model: result.model,
}
}
export async function runAiInsightsGeneration() {
try {
const result = await generateInsight('scheduler')
if (result.success) {
console.log('[ai-insights] scheduled generation completed')
} else {
console.log('[ai-insights] scheduled generation skipped:', result.error)
}
} catch (e) {
console.error('[ai-insights] scheduled generation failed:', e.message)
}
}

View file

@ -0,0 +1,17 @@
const SETTINGS_URL = process.env.SETTINGS_URL || 'http://10.10.10.116:3080'
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
let _cache = null
export async function getAnthropicApiKey() {
if (_cache && Date.now() < _cache.expires_at) return _cache.api_key
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/anthropic`, {
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error('Anthropic integration not configured — add API key in Portal → Settings → Integrations')
const { api_key } = await res.json()
if (!api_key) throw new Error('Anthropic integration not configured — add API key in Portal → Settings → Integrations')
_cache = { api_key, expires_at: Date.now() + 5 * 60_000 }
return api_key
}

View file

@ -0,0 +1,29 @@
// Backend port of wages/frontend/src/lib/forecast.ts's forecastDayCost — keep both in sync.
const MAX_HOPS = 6 // 6 * 7 = 42 days back, comfortably within the 35-day actuals sync window
function addDaysStr(dateStr, n) {
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')}`
}
// Tiered forecast cost for a single department + date: actual (if present) → published
// rota → draft rota (if includeUnpublished) → same-weekday recurring fallback (7-day hops).
export function forecastDayCost(dateStr, actualDays, scheduledDays, includeUnpublished) {
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' }
}

View file

@ -0,0 +1,13 @@
import { getConfig } from '../db.js'
export async function fcFetch(path) {
const apiKey = await getConfig('forecasting_api_key')
const baseUrl = (await getConfig('forecasting_url')) || 'http://10.10.10.113:3080'
if (!apiKey) throw new Error('Forecasting API key not configured — add it in Settings')
const res = await fetch(`${baseUrl}/forecasting/api/public${path}`, {
headers: { 'X-API-Key': apiKey },
signal: AbortSignal.timeout(15000),
})
if (!res.ok) throw new Error(`Forecasting API ${res.status}${path}`)
return res.json()
}

View file

@ -1,7 +1,11 @@
import { runRollingSync } from './workforce.js'
import { setConfig } from '../db.js'
import { getConfig, setConfig } from '../db.js'
import { runAiInsightsGeneration } from '../jobs/ai-insights.js'
const INTERVAL_MS = 60 * 60 * 1000 // 1 hour
const AI_INSIGHTS_CHECK_MS = 60 * 1000 // 1 minute — no cron library in this app, poll wall-clock instead
let _lastAiInsightsRunDate = null
async function doSync() {
try {
@ -13,8 +17,21 @@ async function doSync() {
}
}
async function maybeRunAiInsights() {
const scheduleTime = (await getConfig('ai_insights_schedule_time')) || '07:15'
const [hour, minute] = scheduleTime.split(':').map(Number)
const now = new Date()
const todayKey = now.toISOString().slice(0, 10)
if (_lastAiInsightsRunDate === todayKey) return
if (now.getHours() === hour && now.getMinutes() === minute) {
_lastAiInsightsRunDate = todayKey
await runAiInsightsGeneration()
}
}
export function startScheduler() {
// Run once at startup (allow app to be ready first)
setTimeout(doSync, 5000)
setInterval(doSync, INTERVAL_MS)
setInterval(maybeRunAiInsights, AI_INSIGHTS_CHECK_MS)
}

View file

@ -215,6 +215,9 @@ export async function syncScheduled(from, to) {
let path = `/api/v2/schedules?from=${from}&to=${to}&show_costs=true&include_oncosts=true`
if (locationId) path += `&location_id=${locationId}`
// No published_only param: this endpoint defaults to published_only=false, returning
// both published and draft schedules in one call. Each schedule's own last_published_at
// (null until it's been published to its employee) tells us which bucket it belongs to.
const schedules = await wfFetchPaged(path)
const allDepts = await wfFetchPaged('/api/v2/departments')
@ -230,30 +233,58 @@ export async function syncScheduled(from, to) {
if (!byDateDept[key]) {
byDateDept[key] = {
date,
department_id: deptId,
department_name: deptNameMap[deptId] || deptId,
base_cost: 0,
total_cost: 0,
shift_count: 0,
department_id: deptId,
department_name: deptNameMap[deptId] || deptId,
published_base_cost: 0,
published_total_cost: 0,
published_shift_count: 0,
unpublished_base_cost: 0,
unpublished_total_cost: 0,
unpublished_shift_count: 0,
}
}
byDateDept[key].base_cost += parseFloat(s.cost ?? 0)
byDateDept[key].total_cost += parseFloat(s.cost_with_oncosts ?? s.cost ?? 0)
byDateDept[key].shift_count += 1
const baseCost = parseFloat(s.cost ?? 0)
const totalCost = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0)
const row = byDateDept[key]
if (s.last_published_at != null) {
row.published_base_cost += baseCost
row.published_total_cost += totalCost
row.published_shift_count += 1
} else {
row.unpublished_base_cost += baseCost
row.unpublished_total_cost += totalCost
row.unpublished_shift_count += 1
}
}
// Mirror syncActuals: recompute the whole [from,to] range from the API on every call,
// so delete first — otherwise a shift that got published, edited, or removed since the
// last sync (very likely, since a rota is actively being built) leaves a stale row behind.
// The write itself is still an upsert (not a plain insert): the 14-day rota window overlaps
// between the scheduler's hourly auto-sync and a manager's manual "Sync Now" click, so two
// syncScheduled calls can race on the same date range — a plain insert after a shared delete
// would throw a duplicate-key error if both land between each other's delete and insert.
await pool.query(`DELETE FROM wage_scheduled WHERE date >= $1 AND date <= $2`, [from, to])
for (const row of Object.values(byDateDept)) {
await pool.query(
`INSERT INTO wage_scheduled (date, department_id, department_name, base_cost, total_cost, shift_count, cached_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`INSERT INTO wage_scheduled
(date, department_id, department_name,
published_base_cost, published_total_cost, published_shift_count,
unpublished_base_cost, unpublished_total_cost, unpublished_shift_count, cached_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
ON CONFLICT (date, department_id) DO UPDATE SET
department_name = EXCLUDED.department_name,
base_cost = EXCLUDED.base_cost,
total_cost = EXCLUDED.total_cost,
shift_count = EXCLUDED.shift_count,
cached_at = NOW()`,
department_name = EXCLUDED.department_name,
published_base_cost = EXCLUDED.published_base_cost,
published_total_cost = EXCLUDED.published_total_cost,
published_shift_count = EXCLUDED.published_shift_count,
unpublished_base_cost = EXCLUDED.unpublished_base_cost,
unpublished_total_cost = EXCLUDED.unpublished_total_cost,
unpublished_shift_count = EXCLUDED.unpublished_shift_count,
cached_at = NOW()`,
[row.date, row.department_id, row.department_name,
row.base_cost.toFixed(2), row.total_cost.toFixed(2), row.shift_count]
row.published_base_cost.toFixed(2), row.published_total_cost.toFixed(2), row.published_shift_count,
row.unpublished_base_cost.toFixed(2), row.unpublished_total_cost.toFixed(2), row.unpublished_shift_count]
)
}

View file

@ -8,9 +8,10 @@ export async function actualsRoutes(fastify) {
const { from, to } = request.query
if (!from || !to) return reply.status(400).send({ error: 'from and to required' })
const [showOncostsRaw, pctsRaw, dbRes] = await Promise.all([
const [showOncostsRaw, pctsRaw, forecastMethodRaw, dbRes] = await Promise.all([
getConfig('show_oncosts'),
getConfig('dept_budget_pcts'),
getConfig('forecast_method'),
pool.query(
`SELECT date, department_id, department_name,
base_cost, total_cost, shift_count
@ -46,7 +47,12 @@ export async function actualsRoutes(fastify) {
try { for (const item of JSON.parse(pctsRaw)) dept_pcts[item.id] = item.pct } catch {}
}
return { departments: Object.values(deptMap), show_oncosts: showOncosts, dept_pcts }
return {
departments: Object.values(deptMap),
show_oncosts: showOncosts,
dept_pcts,
forecast_method: forecastMethodRaw === 'rota' ? 'rota' : 'repeat',
}
})
fastify.get('/api/actuals/dept-detail', { preHandler: requireCap('view') }, async (request, reply) => {

View file

@ -0,0 +1,80 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, getConfig } from '../db.js'
import { getAnthropicApiKey } from '../lib/central-settings.js'
import { generateInsight, MANUAL_RATE_LIMIT_MINUTES, DEFAULT_MODEL } from '../jobs/ai-insights.js'
import Anthropic from '@anthropic-ai/sdk'
export async function aiInsightsRoutes(fastify) {
fastify.addHook('preHandler', requireAuth)
fastify.get('/api/ai-insights/latest', { preHandler: requireCap('view') }, async () => {
const res = await pool.query(
`SELECT id, generated_at, content, model, input_tokens, output_tokens, triggered_by
FROM ai_insights ORDER BY generated_at DESC LIMIT 1`
)
return res.rows[0] || null
})
fastify.get('/api/ai-insights/history', { preHandler: requireCap('view') }, async (request) => {
const limit = Math.min(parseInt(request.query.limit, 10) || 20, 100)
const offset = parseInt(request.query.offset, 10) || 0
const [rowsRes, countRes] = await Promise.all([
pool.query(
`SELECT id, generated_at, content, model, input_tokens, output_tokens, triggered_by
FROM ai_insights ORDER BY generated_at DESC LIMIT $1 OFFSET $2`,
[limit, offset]
),
pool.query(`SELECT COUNT(*)::int AS count FROM ai_insights`),
])
return { insights: rowsRes.rows, total: countRes.rows[0].count }
})
fastify.get('/api/ai-insights/usage', { preHandler: requireCap('view') }, async () => {
const res = await pool.query(
`SELECT COALESCE(SUM(input_tokens), 0)::int AS input_tokens,
COALESCE(SUM(output_tokens), 0)::int AS output_tokens
FROM ai_insights WHERE generated_at >= CURRENT_DATE`
)
return res.rows[0]
})
fastify.post('/api/ai-insights/generate', { preHandler: requireCap('settings') }, async (_, reply) => {
const lastManual = await pool.query(
`SELECT generated_at FROM ai_insights WHERE triggered_by = 'manual' ORDER BY generated_at DESC LIMIT 1`
)
if (lastManual.rows[0]) {
const elapsedMs = Date.now() - new Date(lastManual.rows[0].generated_at).getTime()
const remainingMin = MANUAL_RATE_LIMIT_MINUTES - Math.floor(elapsedMs / 60_000)
if (remainingMin > 0) {
return reply.status(429).send({ error: `Please wait ${remainingMin}m more before generating again.` })
}
}
const result = await generateInsight('manual')
if (!result.success) return reply.status(400).send({ error: result.error })
return result
})
fastify.post('/api/ai-insights/test', { preHandler: requireCap('settings') }, async (_, reply) => {
let apiKey
try {
apiKey = await getAnthropicApiKey()
} catch (e) {
return reply.status(400).send({ error: e.message })
}
const model = (await getConfig('ai_insights_model')) || DEFAULT_MODEL
try {
const client = new Anthropic({ apiKey })
const response = await client.messages.create({
model,
max_tokens: 10,
messages: [{ role: 'user', content: "Say 'connected' in one word." }],
})
return { status: 'connected', message: `Successfully connected to ${model}`, response: response.content?.[0]?.text ?? '' }
} catch (e) {
if (e.status === 401) return reply.status(401).send({ error: 'Invalid API key' })
return reply.status(500).send({ error: `Connection failed: ${e.message}` })
}
})
}

View file

@ -1,17 +1,5 @@
import { requireAuth, requireCap } from '../auth.js'
import { getConfig } from '../db.js'
async function fcFetch(path) {
const apiKey = await getConfig('forecasting_api_key')
const baseUrl = (await getConfig('forecasting_url')) || 'http://10.10.10.113:3080'
if (!apiKey) throw new Error('Forecasting API key not configured — add it in Settings')
const res = await fetch(`${baseUrl}/forecasting/api/public${path}`, {
headers: { 'X-API-Key': apiKey },
signal: AbortSignal.timeout(15000),
})
if (!res.ok) throw new Error(`Forecasting API ${res.status}${path}`)
return res.json()
}
import { fcFetch } from '../lib/forecasting-client.js'
function daysBetween(from, to) {
const a = new Date(from + 'T00:00:00')

View file

@ -12,7 +12,8 @@ export async function scheduledRoutes(fastify) {
const res = await pool.query(
`SELECT date, department_id, department_name,
base_cost, total_cost, shift_count
published_base_cost, published_total_cost, published_shift_count,
unpublished_base_cost, unpublished_total_cost, unpublished_shift_count
FROM wage_scheduled
WHERE date >= $1 AND date <= $2
ORDER BY date, department_name`,
@ -30,8 +31,10 @@ export async function scheduledRoutes(fastify) {
}
}
deptMap[row.department_id].days[d] = {
cost: parseFloat(showOncosts ? row.total_cost : row.base_cost),
shift_count: row.shift_count,
published_cost: parseFloat(showOncosts ? row.published_total_cost : row.published_base_cost),
unpublished_cost: parseFloat(showOncosts ? row.unpublished_total_cost : row.unpublished_base_cost),
published_shift_count: row.published_shift_count,
unpublished_shift_count: row.unpublished_shift_count,
}
}

View file

@ -2,7 +2,8 @@ import { requireAuth, requireCap } from '../auth.js'
import { pool, getConfig, setConfig } from '../db.js'
const ALLOWED_KEYS = new Set([
'forecasting_url', 'forecasting_api_key', 'show_oncosts', 'departments', 'dept_budget_pcts',
'forecasting_url', 'forecasting_api_key', 'show_oncosts', 'departments', 'dept_budget_pcts', 'forecast_method',
'ai_insights_enabled', 'ai_insights_model', 'ai_insights_schedule_time', 'ai_insights_daily_token_budget',
])
export async function settingsRoutes(fastify) {
@ -11,7 +12,11 @@ export async function settingsRoutes(fastify) {
fastify.get('/api/settings', { preHandler: requireCap('settings') }, async () => {
const res = await pool.query(
`SELECT key, value, updated_at FROM wages_config
WHERE key IN ('forecasting_url','forecasting_api_key','show_oncosts','departments','sync_last_at','backfill_last_at')
WHERE key IN (
'forecasting_url','forecasting_api_key','show_oncosts','departments','forecast_method',
'sync_last_at','backfill_last_at',
'ai_insights_enabled','ai_insights_model','ai_insights_schedule_time','ai_insights_daily_token_budget'
)
ORDER BY key`
)
return { settings: res.rows }

6578
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -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 />}

View file

@ -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' })
}

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 }} />
})
}

View 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' }
}

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>
)
}

View file

@ -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 && (

View file

@ -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 }}>

View file

@ -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>

View file

@ -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
}