Initial scaffold: wages app

Full wage cost reporting app — weekly/monthly views, rolling 12-week/12-month
history, budget management, Workforce API sync with SSE backfill, net sales
via forecasting public API, department filter, CSV export.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-23 09:03:52 +00:00
commit 2e0592eb90
37 changed files with 3078 additions and 0 deletions

7
backend/Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev
COPY src ./src
EXPOSE 3001
CMD ["node", "src/index.js"]

16
backend/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "hnf-wages-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"pg": "^8.13.1"
}
}

56
backend/src/auth.js Normal file
View file

@ -0,0 +1,56 @@
import { jwtVerify } from 'jose'
import { isOnsite } from './ip-check.js'
const APP_SLUG = process.env.APP_SLUG || 'wages'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
export async function requireAuth(request, reply) {
const token = request.cookies?.hnf_session
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
let payload
try {
const { payload: p } = await jwtVerify(token, secret)
payload = p
} catch {
return reply.status(401).send({ error: 'Invalid session' })
}
if (!payload.apps?.includes(APP_SLUG)) {
return reply.status(403).send({ error: 'No permission for this app' })
}
if (!payload.offsite_allowed) {
const clientIP = request.headers['x-real-ip'] || request.ip
if (!(await isOnsite(clientIP))) {
return reply.status(403).send({ error: 'Access restricted to site network' })
}
}
const prefix = `${APP_SLUG}:`
let caps
if (Array.isArray(payload.caps)) {
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
} else {
caps = ['view']
}
request.user = {
email: payload.sub,
name: payload.name,
is_admin: payload.is_admin ?? false,
caps,
}
}
export function hasCap(request, cap) {
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
}
export function requireCap(cap) {
return async (request, reply) => {
if (!hasCap(request, cap)) {
return reply.status(403).send({ error: `Missing capability: ${cap}` })
}
}
}

71
backend/src/db.js Normal file
View file

@ -0,0 +1,71 @@
import pg from 'pg'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS wage_budgets (
id SERIAL PRIMARY KEY,
month DATE NOT NULL UNIQUE,
budget_amount DECIMAL(10,2) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wage_actuals (
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(),
UNIQUE(date, department_id)
);
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(),
UNIQUE(date, department_id)
);
CREATE INDEX IF NOT EXISTS wage_scheduled_date_idx ON wage_scheduled(date);
CREATE TABLE IF NOT EXISTS wages_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`)
await pool.query(`
INSERT INTO wages_config (key, value) VALUES
('forecasting_url', ''),
('forecasting_api_key', ''),
('show_oncosts', 'true'),
('departments', ''),
('sync_last_at', ''),
('backfill_last_at', '')
ON CONFLICT (key) DO NOTHING
`)
}
export async function getConfig(key) {
const res = await pool.query('SELECT value FROM wages_config WHERE key = $1', [key])
return res.rows[0]?.value || null
}
export async function setConfig(key, value) {
await pool.query(
`INSERT INTO wages_config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
[key, value ?? '']
)
}

40
backend/src/index.js Normal file
View file

@ -0,0 +1,40 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import { initDb } from './db.js'
import { actualsRoutes } from './routes/actuals.js'
import { scheduledRoutes } from './routes/scheduled.js'
import { netSalesRoutes } from './routes/net-sales.js'
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 { startScheduler } from './lib/scheduler.js'
const app = Fastify({ logger: true, trustProxy: true })
const startedAt = Date.now()
await app.register(cookie)
await app.register(cors, {
origin: process.env.CORS_ORIGIN || false,
credentials: true,
})
app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) }))
await app.register(actualsRoutes)
await app.register(scheduledRoutes)
await app.register(netSalesRoutes)
await app.register(budgetsRoutes)
await app.register(exportRoutes)
await app.register(syncRoutes)
await app.register(settingsRoutes)
try {
await initDb()
startScheduler()
await app.listen({ port: 3001, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}

80
backend/src/ip-check.js Normal file
View file

@ -0,0 +1,80 @@
import dns from 'dns/promises'
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
const TTL = 5 * 60 * 1000
const cache = new Map()
const PUBLIC_IP_URLS = [
'https://api.ipify.org',
'https://ifconfig.co/ip',
'https://icanhazip.com',
]
function normalizeIP(ip) {
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
}
function isIPv4(s) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
}
function ipInCidr(ip, cidr) {
const [range, bits] = cidr.split('/')
if (!isIPv4(ip) || !isIPv4(range)) return false
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
return (toInt(ip) & mask) === (toInt(range) & mask)
}
async function fetchPublicIP() {
for (const url of PUBLIC_IP_URLS) {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 4000)
const res = await fetch(url, { signal: ctrl.signal })
clearTimeout(timer)
if (!res.ok) continue
const ip = (await res.text()).trim()
if (isIPv4(ip)) return ip
} catch {
// try next
}
}
return null
}
async function resolveDynamic(key, resolver) {
const hit = cache.get(key)
if (hit && Date.now() < hit.expiry) return hit.ip
const ip = await resolver()
if (ip) {
cache.set(key, { ip, expiry: Date.now() + TTL })
return ip
}
return hit ? hit.ip : null
}
export async function isOnsite(requestIP) {
if (matchers.length === 0 || matchers.includes('disabled')) return true
const ip = normalizeIP(requestIP)
if (!ip) return false
for (const m of matchers) {
if (m === 'auto') {
const pub = await resolveDynamic('auto', fetchPublicIP)
if (pub && ip === pub) return true
} else if (m.includes('/')) {
if (ipInCidr(ip, m)) return true
} else if (/[a-zA-Z]/.test(m)) {
const resolved = await resolveDynamic(m, async () => {
try { return (await dns.resolve4(m))[0] } catch { return null }
})
if (resolved && ip === resolved) return true
} else {
if (ip === m) return true
}
}
return false
}

View file

@ -0,0 +1,20 @@
import { runRollingSync } from './workforce.js'
import { setConfig } from '../db.js'
const INTERVAL_MS = 60 * 60 * 1000 // 1 hour
async function doSync() {
try {
const result = await runRollingSync()
await setConfig('sync_last_at', new Date().toISOString())
console.log(`[scheduler] sync complete — ${result.actualRows} actual rows, ${result.scheduledRows} scheduled rows`)
} catch (err) {
console.error('[scheduler] sync failed:', err.message)
}
}
export function startScheduler() {
// Run once at startup (allow app to be ready first)
setTimeout(doSync, 5000)
setInterval(doSync, INTERVAL_MS)
}

View file

@ -0,0 +1,229 @@
import { pool, getConfig } from '../db.js'
const SETTINGS_URL = process.env.SETTINGS_URL || 'http://10.10.10.116:3080'
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
let _credsCache = null
async function getWorkforceCreds() {
if (_credsCache && Date.now() < _credsCache.expires_at) return _credsCache.creds
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/workforce`, {
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error('Workforce integration not configured — add bearer token in Settings')
const creds = await res.json()
if (!creds.bearer_token) throw new Error('Workforce integration not configured — add bearer token in Settings')
_credsCache = { creds, expires_at: Date.now() + 5 * 60_000 }
return creds
}
async function wfFetch(path) {
const creds = await getWorkforceCreds()
const base = creds.base_url || 'https://my.workforce.com'
const res = await fetch(`${base}${path}`, {
headers: { Authorization: `Bearer ${creds.bearer_token}` },
signal: AbortSignal.timeout(15000),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Workforce API ${res.status}${body ? ': ' + body.slice(0, 200) : ''}`)
}
return res.json()
}
async function wfFetchPaged(path) {
const results = []
let page = 1
while (true) {
const sep = path.includes('?') ? '&' : '?'
const data = await wfFetch(`${path}${sep}page=${page}&page_size=100`)
const items = Array.isArray(data)
? data
: (data.users ?? data.departments ?? data.schedules ?? data.shifts ?? [])
results.push(...items)
if (items.length < 100) break
page++
}
return results
}
export async function fetchAllDepartments() {
const creds = await getWorkforceCreds()
const locationId = creds.location_id ? String(creds.location_id) : null
const all = await wfFetchPaged('/api/v2/departments')
const filtered = locationId ? all.filter(d => String(d.location_id) === locationId) : all
return filtered.map(d => ({ id: String(d.id), name: d.name }))
}
async function getEnabledDeptIds() {
const val = await getConfig('departments')
if (!val) return null // null means "all enabled"
try {
const depts = JSON.parse(val)
if (!Array.isArray(depts) || depts.length === 0) return null
const enabled = depts.filter(d => d.enabled !== false).map(d => d.id)
return enabled.length > 0 ? enabled : null
} catch {
return null
}
}
async function getDeptNameMap() {
const depts = await fetchAllDepartments()
return Object.fromEntries(depts.map(d => [d.id, d.name]))
}
export async function syncActuals(from, to) {
const creds = await getWorkforceCreds()
const locationId = creds.location_id
const enabledDeptIds = await getEnabledDeptIds()
let path = `/api/v2/shifts?from=${from}&to=${to}&show_costs=true&include_oncosts=true`
if (locationId) path += `&report_location_id=${locationId}`
const shifts = await wfFetchPaged(path)
const deptNameMap = await getDeptNameMap()
const byDateDept = {}
for (const s of shifts) {
const deptId = String(s.department_id)
if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue
const date = s.date
const key = `${date}:${deptId}`
if (!byDateDept[key]) {
byDateDept[key] = {
date,
department_id: deptId,
department_name: deptNameMap[deptId] || s.department_name || deptId,
base_cost: 0,
total_cost: 0,
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
}
for (const row of Object.values(byDateDept)) {
await pool.query(
`INSERT INTO wage_actuals (date, department_id, department_name, base_cost, total_cost, shift_count, cached_at)
VALUES ($1, $2, $3, $4, $5, $6, 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()`,
[row.date, row.department_id, row.department_name,
row.base_cost.toFixed(2), row.total_cost.toFixed(2), row.shift_count]
)
}
return Object.keys(byDateDept).length
}
export async function syncScheduled(from, to) {
const creds = await getWorkforceCreds()
const locationId = creds.location_id
const enabledDeptIds = await getEnabledDeptIds()
let path = `/api/v2/schedules?from=${from}&to=${to}&show_costs=true&include_oncosts=true`
if (locationId) path += `&location_id=${locationId}`
const schedules = await wfFetchPaged(path)
const deptNameMap = await getDeptNameMap()
const byDateDept = {}
for (const s of schedules) {
const deptId = String(s.department_id)
if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue
const date = s.date || new Date(s.start * 1000).toISOString().slice(0, 10)
const key = `${date}:${deptId}`
if (!byDateDept[key]) {
byDateDept[key] = {
date,
department_id: deptId,
department_name: deptNameMap[deptId] || deptId,
base_cost: 0,
total_cost: 0,
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
}
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())
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()`,
[row.date, row.department_id, row.department_name,
row.base_cost.toFixed(2), row.total_cost.toFixed(2), row.shift_count]
)
}
return Object.keys(byDateDept).length
}
export async function runRollingSync() {
const today = new Date()
const to = today.toISOString().slice(0, 10)
const fromDate = new Date(today)
fromDate.setDate(fromDate.getDate() - 35)
const from = fromDate.toISOString().slice(0, 10)
const fwdDate = new Date(today)
fwdDate.setDate(fwdDate.getDate() + 14)
const fwd = fwdDate.toISOString().slice(0, 10)
const [actualRows, scheduledRows] = await Promise.all([
syncActuals(from, to),
syncScheduled(to, fwd),
])
return { actualRows, scheduledRows }
}
export async function runBackfill(onProgress, signal) {
const today = new Date()
const endDate = new Date(today)
endDate.setDate(endDate.getDate() - 1)
const startDate = new Date(today)
startDate.setMonth(startDate.getMonth() - 13)
const totalDays = Math.max(1, Math.ceil((endDate - startDate) / 86_400_000))
let processedDays = 0
let current = new Date(startDate)
while (current <= endDate) {
if (signal?.aborted) break
const weekEnd = new Date(current)
weekEnd.setDate(weekEnd.getDate() + 6)
if (weekEnd > endDate) weekEnd.setTime(endDate.getTime())
const from = current.toISOString().slice(0, 10)
const to = weekEnd.toISOString().slice(0, 10)
await syncActuals(from, to)
const daysInBatch = Math.ceil((weekEnd - current) / 86_400_000) + 1
processedDays += daysInBatch
onProgress?.({ processed: processedDays, total: totalDays, current: from })
current.setDate(current.getDate() + 7)
await new Promise(r => setTimeout(r, 250))
}
}

View file

@ -0,0 +1,44 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, getConfig } from '../db.js'
export async function actualsRoutes(fastify) {
fastify.addHook('preHandler', requireAuth)
fastify.get('/api/actuals', { preHandler: requireCap('view') }, async (request, reply) => {
const { from, to } = request.query
if (!from || !to) return reply.status(400).send({ error: 'from and to required' })
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
const costCol = showOncosts ? 'total_cost' : 'base_cost'
const res = await pool.query(
`SELECT date, department_id, department_name,
base_cost, total_cost, shift_count
FROM wage_actuals
WHERE date >= $1 AND date <= $2
ORDER BY date, department_name`,
[from, to]
)
// Group by department, emit { dept_id, dept_name, days: { 'YYYY-MM-DD': cost } }
const deptMap = {}
for (const row of res.rows) {
const d = row.date.toISOString().slice(0, 10)
if (!deptMap[row.department_id]) {
deptMap[row.department_id] = {
department_id: row.department_id,
department_name: row.department_name,
days: {},
}
}
deptMap[row.department_id].days[d] = {
base_cost: parseFloat(row.base_cost),
total_cost: parseFloat(row.total_cost),
cost: parseFloat(showOncosts ? row.total_cost : row.base_cost),
shift_count: row.shift_count,
}
}
return { departments: Object.values(deptMap), show_oncosts: showOncosts }
})
}

View file

@ -0,0 +1,37 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function budgetsRoutes(fastify) {
fastify.addHook('preHandler', requireAuth)
fastify.get('/api/budgets', { preHandler: requireCap('view') }, async () => {
const res = await pool.query(
`SELECT to_char(month, 'YYYY-MM-DD') AS month, budget_amount
FROM wage_budgets
ORDER BY month`
)
return { budgets: res.rows.map(r => ({ month: r.month, budget_amount: parseFloat(r.budget_amount) })) }
})
fastify.put('/api/budgets/:month', { preHandler: requireCap('budget') }, async (request, reply) => {
const { month } = request.params
const { budget_amount } = request.body || {}
if (!/^\d{4}-\d{2}$/.test(month)) {
return reply.status(400).send({ error: 'month must be YYYY-MM' })
}
const amount = parseFloat(budget_amount)
if (isNaN(amount) || amount < 0) {
return reply.status(400).send({ error: 'budget_amount must be a non-negative number' })
}
// Store as first day of month
const monthDate = `${month}-01`
await pool.query(
`INSERT INTO wage_budgets (month, budget_amount, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (month) DO UPDATE SET budget_amount = EXCLUDED.budget_amount, updated_at = NOW()`,
[monthDate, amount]
)
return { ok: true }
})
}

View file

@ -0,0 +1,84 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, getConfig } from '../db.js'
function toCsv(headers, rows) {
const escape = v => {
const s = String(v ?? '')
return s.includes(',') || s.includes('"') || s.includes('\n')
? `"${s.replace(/"/g, '""')}"`
: s
}
return [headers, ...rows].map(r => r.map(escape).join(',')).join('\n')
}
function fmt(n) { return n == null ? '' : Number(n).toFixed(2) }
function pct(a, b) { return b > 0 ? ((a / b) * 100).toFixed(1) + '%' : '' }
export async function exportRoutes(fastify) {
fastify.addHook('preHandler', requireAuth)
// GET /api/export?view=weekly|monthly|rolling-weeks|rolling-months&from=YYYY-MM-DD&to=YYYY-MM-DD
fastify.get('/api/export', { preHandler: requireCap('view') }, async (request, reply) => {
const { view, from, to } = request.query
if (!view || !from || !to) return reply.status(400).send({ error: 'view, from and to required' })
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
let csv = ''
const filename = `wages-${view}-${from}-${to}.csv`
if (view === 'weekly' || view === 'monthly') {
const actRes = await pool.query(
`SELECT date, department_id, department_name,
${showOncosts ? 'total_cost' : 'base_cost'} AS cost, shift_count
FROM wage_actuals
WHERE date >= $1 AND date <= $2
ORDER BY department_name, date`,
[from, to]
)
const headers = ['Department', 'Date', 'Cost (£)', 'Shifts']
const rows = actRes.rows.map(r => [
r.department_name,
r.date.toISOString().slice(0, 10),
fmt(r.cost),
r.shift_count,
])
csv = toCsv(headers, rows)
} else {
// rolling views — aggregate by week or month
const actRes = await pool.query(
`SELECT date, ${showOncosts ? 'total_cost' : 'base_cost'} AS cost
FROM wage_actuals
WHERE date >= $1 AND date <= $2
ORDER BY date`,
[from, to]
)
const isWeekly = view === 'rolling-weeks'
const buckets = {}
for (const row of actRes.rows) {
const d = new Date(row.date.toISOString().slice(0, 10) + 'T00:00:00')
let label
if (isWeekly) {
// ISO week ending Saturday
const sat = new Date(d)
sat.setDate(sat.getDate() + (6 - d.getDay()))
label = `w/e ${sat.toISOString().slice(0, 10)}`
} else {
label = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
buckets[label] = (buckets[label] ?? 0) + parseFloat(row.cost)
}
const headers = [isWeekly ? 'Week Ending' : 'Month', 'Total Wages (£)']
const rows = Object.entries(buckets).map(([label, cost]) => [label, fmt(cost)])
csv = toCsv(headers, rows)
}
reply.header('Content-Type', 'text/csv')
reply.header('Content-Disposition', `attachment; filename="${filename}"`)
return reply.send(csv)
})
}

View file

@ -0,0 +1,45 @@
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()
}
function daysBetween(from, to) {
const a = new Date(from + 'T00:00:00')
const b = new Date(to + 'T00:00:00')
return Math.max(1, Math.ceil((b - a) / 86_400_000) + 1)
}
export async function netSalesRoutes(fastify) {
fastify.addHook('preHandler', requireAuth)
// Returns daily net sales and prior-year net sales for a date range.
fastify.get('/api/net-sales', { preHandler: requireCap('view') }, async (request, reply) => {
const { from, to } = request.query
if (!from || !to) return reply.status(400).send({ error: 'from and to required' })
const days = daysBetween(from, to)
const data = await fcFetch(`/forecast/revenue?start_date=${from}&days=${days}&type=all&dow_align=true`)
const result = (data?.data ?? []).map(d => ({
date: d.date,
net_sales: parseFloat(d.total?.otb ?? 0),
py_sales: parseFloat(d.total?.prior_final ?? 0),
accom: parseFloat(d.accom?.otb ?? 0),
dry: parseFloat(d.dry?.otb ?? 0),
wet: parseFloat(d.wet?.otb ?? 0),
is_past: d.is_past ?? true,
}))
return { days: result }
})
}

View file

@ -0,0 +1,40 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, getConfig } from '../db.js'
export async function scheduledRoutes(fastify) {
fastify.addHook('preHandler', requireAuth)
fastify.get('/api/scheduled', { preHandler: requireCap('view') }, async (request, reply) => {
const { from, to } = request.query
if (!from || !to) return reply.status(400).send({ error: 'from and to required' })
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
const res = await pool.query(
`SELECT date, department_id, department_name,
base_cost, total_cost, shift_count
FROM wage_scheduled
WHERE date >= $1 AND date <= $2
ORDER BY date, department_name`,
[from, to]
)
const deptMap = {}
for (const row of res.rows) {
const d = row.date.toISOString().slice(0, 10)
if (!deptMap[row.department_id]) {
deptMap[row.department_id] = {
department_id: row.department_id,
department_name: row.department_name,
days: {},
}
}
deptMap[row.department_id].days[d] = {
cost: parseFloat(showOncosts ? row.total_cost : row.base_cost),
shift_count: row.shift_count,
}
}
return { departments: Object.values(deptMap) }
})
}

View file

@ -0,0 +1,30 @@
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',
])
export async function settingsRoutes(fastify) {
fastify.addHook('preHandler', requireAuth)
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')
ORDER BY key`
)
return { settings: res.rows }
})
fastify.put('/api/settings', { preHandler: requireCap('settings') }, async (request, reply) => {
const { settings } = request.body || {}
if (!Array.isArray(settings)) return reply.status(400).send({ error: 'settings must be an array' })
for (const { key, value } of settings) {
if (!ALLOWED_KEYS.has(key)) continue
await setConfig(key, value ?? '')
}
return { ok: true }
})
}

View file

@ -0,0 +1,78 @@
import { requireAuth, requireCap } from '../auth.js'
import { getConfig, setConfig } from '../db.js'
import { runRollingSync, runBackfill } from '../lib/workforce.js'
import { fetchAllDepartments } from '../lib/workforce.js'
let _backfillAbort = null
export async function syncRoutes(fastify) {
fastify.addHook('preHandler', requireAuth)
fastify.get('/api/sync/status', { preHandler: requireCap('view') }, async () => {
return {
sync_last_at: await getConfig('sync_last_at'),
backfill_last_at: await getConfig('backfill_last_at'),
backfill_running: _backfillAbort !== null,
}
})
fastify.post('/api/sync', { preHandler: requireCap('sync') }, async (_, reply) => {
try {
const result = await runRollingSync()
await setConfig('sync_last_at', new Date().toISOString())
return { ok: true, actual_rows: result.actualRows, scheduled_rows: result.scheduledRows }
} catch (err) {
return reply.status(500).send({ error: err.message })
}
})
// Backfill via Server-Sent Events so the frontend can track progress
fastify.post('/api/sync/backfill', { preHandler: requireCap('sync') }, async (request, reply) => {
if (_backfillAbort) {
_backfillAbort.abort()
_backfillAbort = null
}
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
const ac = new AbortController()
_backfillAbort = ac
request.raw.on('close', () => ac.abort())
try {
await runBackfill(({ processed, total, current }) => {
reply.raw.write(`data: ${JSON.stringify({ processed, total, current })}\n\n`)
}, ac.signal)
await setConfig('backfill_last_at', new Date().toISOString())
reply.raw.write(`data: ${JSON.stringify({ done: true })}\n\n`)
} catch (err) {
reply.raw.write(`data: ${JSON.stringify({ error: err.message })}\n\n`)
} finally {
_backfillAbort = null
reply.raw.end()
}
})
fastify.post('/api/sync/backfill/cancel', { preHandler: requireCap('sync') }, async () => {
if (_backfillAbort) {
_backfillAbort.abort()
_backfillAbort = null
}
return { ok: true }
})
// Fetch departments from Workforce (for the settings filter)
fastify.get('/api/departments', { preHandler: requireCap('settings') }, async (_, reply) => {
try {
const depts = await fetchAllDepartments()
return { departments: depts }
} catch (err) {
return reply.status(500).send({ error: err.message })
}
})
}

36
docker-compose.yml Normal file
View file

@ -0,0 +1,36 @@
services:
backend:
build: ./backend
security_opt:
- apparmor=unconfined
environment:
- DATABASE_URL=${DATABASE_URL}
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
- SETTINGS_URL=${SETTINGS_URL}
- SETTINGS_SECRET=${SETTINGS_SECRET}
- APP_SLUG=wages
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
interval: 10s
retries: 5
start_period: 20s
restart: unless-stopped
frontend:
build:
context: ./frontend
args:
VITE_HOTEL_NAME: ${VITE_HOTEL_NAME}
security_opt:
- apparmor=unconfined
ports:
- "${FRONTEND_PORT:-3080}:80"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
networks:
default:
driver: bridge

13
frontend/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
ARG VITE_HOTEL_NAME
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html/wages
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#065f46" />
<title>Wage Costs</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

55
frontend/nginx.conf Normal file
View file

@ -0,0 +1,55 @@
server {
location = /wages/manifest.webmanifest {
default_type application/manifest+json;
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location = /wages/sw.js {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location = /wages/registerSW.js {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
listen 80;
server_name localhost;
root /usr/share/nginx/html;
location /wages/api/auth/ {
proxy_pass http://10.10.10.101:3001/api/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /wages/api/ {
proxy_pass http://backend:3001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Cache-Control "no-store";
}
location /wages/health {
proxy_pass http://backend:3001/health;
}
location ~* /wages/.*\.(js|css|png|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location /wages/ {
add_header Cache-Control "no-cache" always;
try_files $uri $uri/ /wages/index.html;
}
location = / {
return 301 /wages/;
}
}

25
frontend/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "hnf-wages-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^3.9.2"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.5",
"vite-plugin-pwa": "^1.3.0"
}
}

77
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,77 @@
import { useState } from 'react'
import { DollarSign, CalendarDays, TrendingUp, BarChart3, Wallet, Settings } from 'lucide-react'
import AuthGate, { useAuth } from './components/AuthGate'
import { UpdateBanner } from './components/UpdateBanner'
import { useVersionCheck } from './hooks/useVersionCheck'
import { can } from './types'
import Weekly from './pages/Weekly'
import Monthly from './pages/Monthly'
import Rolling12Weeks from './pages/Rolling12Weeks'
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'
const NAV: { id: Page; label: string; icon: React.ElementType; cap?: string }[] = [
{ id: 'weekly', label: 'Weekly', icon: CalendarDays },
{ id: 'monthly', label: 'Monthly', icon: TrendingUp },
{ id: 'rolling-weeks', label: '12 Weeks', icon: BarChart3 },
{ id: 'rolling-months', label: '12 Months', icon: BarChart3 },
{ id: 'budgets', label: 'Budgets', icon: Wallet, cap: 'budget' },
{ id: 'settings', label: 'Settings', icon: Settings, cap: 'settings' },
]
function Shell() {
const { user } = useAuth()
const [page, setPage] = useState<Page>('weekly')
const hotelName = import.meta.env.VITE_HOTEL_NAME || 'Hotel'
return (
<div className="app-shell">
<nav className="sidebar">
<div className="sidebar-logo">
<DollarSign size={16} strokeWidth={1.75} />
Wage Costs
</div>
<div className="sidebar-nav">
{NAV.filter(n => !n.cap || can(user, n.cap)).map(n => (
<div
key={n.id}
className={`nav-item${page === n.id ? ' active' : ''}`}
onClick={() => setPage(n.id)}
>
<n.icon size={16} strokeWidth={1.75} />
{n.label}
</div>
))}
</div>
<div style={{ padding: '12px 16px', fontSize: '11px', color: 'rgba(255,255,255,0.3)' }}>
{hotelName}
</div>
</nav>
<main className="content">
{page === 'weekly' && <Weekly />}
{page === 'monthly' && <Monthly />}
{page === 'rolling-weeks' && <Rolling12Weeks />}
{page === 'rolling-months' && <Rolling12Months />}
{page === 'budgets' && <Budgets />}
{page === 'settings' && <SettingsPage />}
</main>
</div>
)
}
export default function App() {
const updateAvailable = useVersionCheck('/wages/health')
return (
<>
<AuthGate>
<Shell />
</AuthGate>
<UpdateBanner visible={updateAvailable} />
</>
)
}

71
frontend/src/api.ts Normal file
View file

@ -0,0 +1,71 @@
import type { DeptActuals, DeptScheduled, NetSalesDay, WageBudget, AppSetting } from './types'
const BASE = '/wages/api'
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
if (res.status === 401) {
;(window.top ?? window).location.href = '/login'
throw new Error('Unauthenticated')
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error((err as { error?: string }).error || `Request failed: ${res.status}`)
}
return res.json()
}
export function getActuals(from: string, to: string): Promise<{ departments: DeptActuals[]; show_oncosts: boolean }> {
return request(`/actuals?from=${from}&to=${to}`)
}
export function getScheduled(from: string, to: string): Promise<{ departments: DeptScheduled[] }> {
return request(`/scheduled?from=${from}&to=${to}`)
}
export function getNetSales(from: string, to: string): Promise<{ days: NetSalesDay[] }> {
return request(`/net-sales?from=${from}&to=${to}`)
}
export function getBudgets(): Promise<{ budgets: WageBudget[] }> {
return request('/budgets')
}
export function saveBudget(month: string, budget_amount: number): Promise<{ ok: boolean }> {
return request(`/budgets/${month}`, {
method: 'PUT',
body: JSON.stringify({ budget_amount }),
})
}
export function triggerSync(): Promise<{ ok: boolean; actual_rows: number; scheduled_rows: number }> {
return request('/sync', { method: 'POST' })
}
export function getSyncStatus(): Promise<{ sync_last_at: string | null; backfill_last_at: string | null; backfill_running: boolean }> {
return request('/sync/status')
}
export function cancelBackfill(): Promise<{ ok: boolean }> {
return request('/sync/backfill/cancel', { method: 'POST' })
}
export function getDepartments(): Promise<{ departments: { id: string; name: string }[] }> {
return request('/departments')
}
export function getSettings(): Promise<{ settings: AppSetting[] }> {
return request('/settings')
}
export function saveSettings(settings: { key: string; value: string }[]): Promise<{ ok: boolean }> {
return request('/settings', { method: 'PUT', body: JSON.stringify({ settings }) })
}
export function downloadExport(view: string, from: string, to: string): void {
window.open(`${BASE}/export?view=${view}&from=${from}&to=${to}`, '_blank')
}

View file

@ -0,0 +1,41 @@
import { useEffect, useState, createContext, useContext } from 'react'
import type { User } from '../types'
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
return ctx
}
export default function AuthGate({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
fetch('/wages/api/auth/verify?app=wages', { credentials: 'include' })
.then(r => {
if (!r.ok) {
;(window.top ?? window).location.href = '/login'
return null
}
return r.json()
})
.then(data => { if (data) setUser(data) })
.catch(() => { ;(window.top ?? window).location.href = '/login' })
}, [])
if (!user) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', color: '#6b7280',
}}>
Loading
</div>
)
}
return <Ctx.Provider value={{ user }}>{children}</Ctx.Provider>
}

View file

@ -0,0 +1,28 @@
import { RefreshCw } from 'lucide-react'
export function UpdateBanner({ visible }: { visible: boolean }) {
if (!visible) return null
return (
<div style={{
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 9999,
background: 'var(--sidebar)', color: 'var(--text-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: '12px', padding: '10px 16px', fontSize: '14px',
boxShadow: '0 -2px 8px rgba(0,0,0,0.3)',
}}>
<span>A new version is available.</span>
<button
onClick={() => window.location.reload()}
style={{
display: 'flex', alignItems: 'center', gap: '6px',
background: 'var(--accent)', color: 'var(--sidebar)',
border: 'none', borderRadius: '4px', padding: '6px 14px',
fontWeight: 600, cursor: 'pointer', fontSize: '13px',
}}
>
<RefreshCw size={14} strokeWidth={1.75} />
Reload
</button>
</div>
)
}

View file

@ -0,0 +1,31 @@
import { useEffect, useState } from 'react'
const POLL_MS = 2 * 60 * 1000
export function useVersionCheck(healthUrl: string) {
const [updateAvailable, setUpdateAvailable] = useState(false)
useEffect(() => {
let seenVersion: string | null = null
async function check() {
try {
const res = await fetch(healthUrl, { cache: 'no-store' })
if (!res.ok) return
const data = await res.json()
const v: string | undefined = data.version
if (!v) return
if (seenVersion === null) { seenVersion = v }
else if (v !== seenVersion) { setUpdateAvailable(true) }
} catch { /* skip */ }
}
check()
const interval = setInterval(check, POLL_MS)
const onVisible = () => { if (document.visibilityState === 'visible') check() }
document.addEventListener('visibilitychange', onVisible)
return () => { clearInterval(interval); document.removeEventListener('visibilitychange', onVisible) }
}, [healthUrl])
return updateAvailable
}

325
frontend/src/index.css Normal file
View file

@ -0,0 +1,325 @@
/* Stack design system tokens */
:root {
--navy: #1a1a2e;
--gold: #c9a84c;
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--text-primary: #1a1a2e;
--text-muted: #6b7280;
--border: #e5e7eb;
--radius: 8px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.12);
/* App theme — dark green for finance */
--app-primary: #065f46;
--app-primary-light: #059669;
--app-primary-dark: #064e3b;
/* UpdateBanner aliases */
--sidebar: var(--navy);
--text-light: #ffffff;
--accent: var(--gold);
/* Layout */
--sidebar-w: 200px;
--topbar-h: 56px;
}
*, *::before, *::after { box-sizing: border-box; }
html, body, #root {
height: 100%;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--body-bg);
color: var(--text-primary);
font-size: 14px;
}
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* ── App shell ──────────────────────────────────────────────────── */
.app-shell {
display: flex;
height: 100vh;
overflow: hidden;
}
/* ── Sidebar ────────────────────────────────────────────────────── */
.sidebar {
width: var(--sidebar-w);
background: var(--navy);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow-y: auto;
}
.sidebar-logo {
padding: 20px 16px 12px;
color: var(--gold);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
border-bottom: 1px solid rgba(255,255,255,0.08);
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-nav {
padding: 8px 0;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
color: rgba(255,255,255,0.65);
cursor: pointer;
font-size: 13px;
border-left: 3px solid transparent;
transition: all 0.15s;
user-select: none;
}
.nav-item:hover {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.9);
}
.nav-item.active {
background: rgba(201,168,76,0.12);
color: var(--gold);
border-left-color: var(--gold);
font-weight: 500;
}
/* ── Content area ───────────────────────────────────────────────── */
.content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* ── Cards ──────────────────────────────────────────────────────── */
.card {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 20px;
margin-bottom: 20px;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
/* ── Summary cards ──────────────────────────────────────────────── */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
margin-bottom: 20px;
}
.summary-card {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 16px;
}
.summary-card .label {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.summary-card .value {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.1;
}
.summary-card .sub {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
/* ── Tables ─────────────────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table th {
text-align: left;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.data-table th.right,
.data-table td.right { text-align: right; }
.data-table td {
padding: 9px 12px;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
}
.data-table tr:last-child td { border-bottom: none; }
.data-table tr.total-row td {
font-weight: 700;
border-top: 2px solid var(--border);
border-bottom: none;
}
.data-table tr:hover:not(.total-row) td {
background: var(--body-bg);
}
/* ── Traffic lights ─────────────────────────────────────────────── */
.pct-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.pct-green { background: #d1fae5; color: #065f46; }
.pct-amber { background: #fef3c7; color: #92400e; }
.pct-red { background: #fee2e2; color: #991b1b; }
.variance-over { color: #dc2626; font-weight: 600; }
.variance-under { color: #059669; font-weight: 600; }
/* ── Partial / forecast ─────────────────────────────────────────── */
.partial-badge {
display: inline-block;
font-size: 10px;
font-weight: 500;
color: var(--text-muted);
background: var(--body-bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 6px;
margin-left: 6px;
vertical-align: middle;
}
/* ── Buttons ────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: opacity 0.15s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn:hover:not(:disabled) { opacity: 0.88; }
.btn-primary {
background: var(--app-primary);
color: #fff;
}
.btn-secondary {
background: var(--body-bg);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-gold {
background: var(--gold);
color: var(--navy);
}
/* ── Page header ────────────────────────────────────────────────── */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
/* ── Week / month selector ──────────────────────────────────────── */
.period-nav {
display: flex;
align-items: center;
gap: 10px;
}
.period-label {
font-size: 14px;
font-weight: 600;
min-width: 150px;
text-align: center;
}
/* ── Form elements ──────────────────────────────────────────────── */
input[type="number"], input[type="text"] {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
color: var(--text-primary);
background: var(--card-bg);
}
input[type="number"]:focus,
input[type="text"]:focus {
outline: 2px solid var(--app-primary-light);
border-color: var(--app-primary-light);
}
/* ── Footnote ───────────────────────────────────────────────────── */
.footnote {
font-size: 11px;
color: var(--text-muted);
margin-top: 8px;
font-style: italic;
}
/* ── Loading/error states ───────────────────────────────────────── */
.state-center {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-muted);
font-size: 14px;
}

10
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View file

@ -0,0 +1,174 @@
import { useState, useEffect, useRef } from 'react'
import { getBudgets, saveBudget } from '../api'
import type { WageBudget } from '../types'
function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() }
function getMonthRange(): { year: number; month: number }[] {
const today = new Date()
const months: { year: number; month: number }[] = []
for (let i = -3; i <= 3; i++) {
let m = today.getMonth() + 1 + i
let y = today.getFullYear()
while (m <= 0) { m += 12; y-- }
while (m > 12) { m -= 12; y++ }
months.push({ year: y, month: m })
}
return months
}
const MONTH_LABELS = ['January','February','March','April','May','June','July','August','September','October','November','December']
export default function Budgets() {
const [budgets, setBudgets] = useState<Record<string, number>>({})
const [editing, setEditing] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<Record<string, boolean>>({})
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({})
const months = getMonthRange()
useEffect(() => {
getBudgets()
.then(res => {
const map: Record<string, number> = {}
for (const b of res.budgets as WageBudget[]) {
const key = b.month.slice(0, 7) // YYYY-MM
map[key] = b.budget_amount
}
setBudgets(map)
})
.catch(e => setError(e.message))
.finally(() => setLoading(false))
}, [])
const monthKey = (y: number, m: number) => `${y}-${String(m).padStart(2, '0')}`
const handleFocus = (key: string) => {
const current = budgets[key]
setEditing(e => ({ ...e, [key]: current != null ? String(current) : '' }))
}
const handleChange = (key: string, val: string) => {
setEditing(e => ({ ...e, [key]: val }))
}
const handleSave = async (key: string) => {
const raw = editing[key]?.trim()
if (raw === '') {
setEditing(e => { const n = { ...e }; delete n[key]; return n })
return
}
const amount = parseFloat(raw)
if (isNaN(amount)) {
setEditing(e => { const n = { ...e }; delete n[key]; return n })
return
}
setSaving(s => ({ ...s, [key]: true }))
try {
await saveBudget(key, amount)
setBudgets(b => ({ ...b, [key]: amount }))
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Save failed')
} finally {
setSaving(s => { const n = { ...s }; delete n[key]; return n })
setEditing(e => { const n = { ...e }; delete n[key]; return n })
}
}
const handleKeyDown = (key: string, e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSave(key)
// Tab focus to next
const keys = months.map(m => monthKey(m.year, m.month))
const idx = keys.indexOf(key)
if (idx >= 0 && idx < keys.length - 1) {
setTimeout(() => inputRefs.current[keys[idx + 1]]?.focus(), 50)
}
}
if (e.key === 'Escape') {
setEditing(e2 => { const n = { ...e2 }; delete n[key]; return n })
}
}
if (loading) return <div className="state-center">Loading</div>
return (
<div>
<div className="page-header">
<h1 className="page-title">Wage Budgets</h1>
</div>
{error && <div style={{ color: '#dc2626', marginBottom: 16 }}>{error}</div>}
<div className="card">
<p style={{ color: 'var(--text-muted)', marginTop: 0, fontSize: 13 }}>
Enter the total monthly wages budget (FD figure). Click a cell to edit, press Enter or Tab to save.
</p>
<table className="data-table">
<thead>
<tr>
<th>Month</th>
<th className="right">Budget</th>
<th className="right" style={{ color: 'var(--text-muted)', fontWeight: 400 }}>Weekly equiv.</th>
</tr>
</thead>
<tbody>
{months.map(({ year, month }) => {
const key = monthKey(year, month)
const current = budgets[key]
const isEditing = key in editing
const dim = daysInMonth(year, month)
const weekly = current != null ? (current * 7 / dim) : null
return (
<tr key={key}>
<td style={{ fontWeight: 500 }}>
{MONTH_LABELS[month - 1]} {year}
</td>
<td className="right" style={{ width: 160 }}>
{isEditing ? (
<input
type="number"
ref={el => { inputRefs.current[key] = el }}
value={editing[key]}
onChange={e => handleChange(key, e.target.value)}
onBlur={() => handleSave(key)}
onKeyDown={e => handleKeyDown(key, e)}
style={{ width: 140, textAlign: 'right' }}
autoFocus
min={0}
step={100}
/>
) : (
<span
onClick={() => handleFocus(key)}
style={{
cursor: 'text',
display: 'inline-block',
minWidth: 100,
padding: '4px 8px',
borderRadius: 4,
border: '1px dashed var(--border)',
textAlign: 'right',
color: current != null ? 'var(--text-primary)' : 'var(--text-muted)',
}}
>
{saving[key] ? 'Saving…' : current != null ? `£${current.toLocaleString('en-GB')}` : 'Click to set'}
</span>
)}
</td>
<td className="right" style={{ color: 'var(--text-muted)' }}>
{weekly != null ? `£${Math.round(weekly).toLocaleString('en-GB')}` : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}

View file

@ -0,0 +1,274 @@
import { useState, useEffect, useCallback } from 'react'
import { ChevronLeft, ChevronRight, Download } from 'lucide-react'
import {
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, Cell,
} from 'recharts'
import { getActuals, getScheduled, getNetSales, getBudgets, downloadExport } from '../api'
import type { DeptActuals, WageBudget } from '../types'
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
function addDays(d: Date, n: number): Date { const r = new Date(d); r.setDate(r.getDate() + n); return r }
function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() }
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
function pctClass(pct: number): string { return pct <= 100 ? 'pct-green' : pct <= 110 ? 'pct-amber' : 'pct-red' }
const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
export default function Monthly() {
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth() + 1) // 1-based
const [depts, setDepts] = useState<DeptActuals[]>([])
const [scheduled, setScheduled] = useState<Record<string, Record<string, number>>>({}) // dept_id → date → cost
const [netSales, setNetSales] = useState(0)
const [budget, setBudget] = useState<number | null>(null)
const [showOncosts, setShowOncosts] = useState(true)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const dim = daysInMonth(year, month)
const monthStr = `${year}-${String(month).padStart(2, '0')}`
const fromStr = `${monthStr}-01`
const toStr = `${monthStr}-${String(dim).padStart(2, '0')}`
const todayStr = fmt(today)
const isCurrentMonth = year === today.getFullYear() && month === today.getMonth() + 1
const load = useCallback(async () => {
setLoading(true); setError(null)
try {
const [actRes, schRes, salesRes, budRes] = await Promise.all([
getActuals(fromStr, toStr),
getScheduled(todayStr, toStr),
getNetSales(fromStr, todayStr),
getBudgets(),
])
setDepts(actRes.departments)
setShowOncosts(actRes.show_oncosts)
// Build scheduled map
const schMap: Record<string, Record<string, number>> = {}
for (const dep of schRes.departments) {
schMap[dep.department_id] = {}
for (const [date, val] of Object.entries(dep.days)) {
schMap[dep.department_id][date] = val.cost
}
}
setScheduled(schMap)
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
const bRow = budRes.budgets.find(b => b.month === `${fromStr}`)
setBudget(bRow ? bRow.budget_amount : null)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load')
} finally {
setLoading(false)
}
}, [fromStr, toStr, todayStr])
useEffect(() => { load() }, [load])
const prev = () => { if (month === 1) { setYear(y => y - 1); setMonth(12) } else { setMonth(m => m - 1) } }
const next = () => { if (month === 12) { setYear(y => y + 1); setMonth(1) } else { setMonth(m => m + 1) } }
// Build dept summary: actual MTD + forecast EOM
const deptSummary = depts.map((dep, idx) => {
const actualMTD = Object.entries(dep.days)
.filter(([d]) => d <= todayStr)
.reduce((s, [, v]) => s + v.cost, 0)
// Forecast remaining days
let forecastRem = 0
for (let day = 1; day <= dim; day++) {
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
if (dateStr <= todayStr) continue
// Priority: rota → prior week same DoW actual
const rotaCost = schMap(dep.department_id, dateStr)
if (rotaCost != null) {
forecastRem += rotaCost
continue
}
const priorDate = addDays(new Date(dateStr + 'T00:00:00'), -7)
const priorStr = fmt(priorDate)
const priorCost = dep.days[priorStr]?.cost
if (priorCost != null) forecastRem += priorCost
}
return {
department_id: dep.department_id,
department_name: dep.department_name,
actual_mtd: actualMTD,
forecast_eom: actualMTD + forecastRem,
color: DEPT_COLORS[idx % DEPT_COLORS.length],
}
}).sort((a, b) => b.forecast_eom - a.forecast_eom)
function schMap(deptId: string, date: string): number | null {
return scheduled[deptId]?.[date] ?? null
}
const totalActual = deptSummary.reduce((s, d) => s + d.actual_mtd, 0)
const totalForecast = deptSummary.reduce((s, d) => s + d.forecast_eom, 0)
const pctBudget = budget != null && budget > 0 ? (totalForecast / budget) * 100 : null
const pctSales = netSales > 0 ? (totalActual / netSales) * 100 : null
const variance = budget != null ? totalForecast - budget : null
// Build chart data: group by week
const weeks: { label: string; actual: number; forecast: number; isPast: boolean }[] = []
for (let w = 0; w * 7 < dim; w++) {
const wStart = w * 7 + 1
const wEnd = Math.min(wStart + 6, dim)
const wEndDate = new Date(`${monthStr}-${String(wEnd).padStart(2, '0')}T00:00:00`)
const isPast = wEndDate < today
let actual = 0, forecast = 0
for (let day = wStart; day <= wEnd; day++) {
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
const isActual = dateStr <= todayStr
const total = deptSummary.reduce((s, dep) => {
if (isActual) return s + (depts.find(d => d.department_id === dep.department_id)?.days[dateStr]?.cost ?? 0)
const rota = schMap(dep.department_id, dateStr)
if (rota != null) return s + rota
const prior = depts.find(d => d.department_id === dep.department_id)?.days[fmt(addDays(new Date(dateStr + 'T00:00:00'), -7))]?.cost ?? 0
return s + prior
}, 0)
if (isActual) actual += total; else forecast += total
}
weeks.push({ label: `W${w + 1}`, actual, forecast, isPast })
}
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
return (
<div>
<div className="page-header">
<h1 className="page-title">Monthly View</h1>
<button className="btn btn-secondary" onClick={() => downloadExport('monthly', fromStr, toStr)}>
<Download size={14} strokeWidth={1.75} /> CSV
</button>
</div>
<div className="period-nav" style={{ marginBottom: 20 }}>
<button className="btn btn-secondary" onClick={prev}><ChevronLeft size={16} strokeWidth={1.75} /></button>
<span className="period-label">{monthLabel}</span>
<button className="btn btn-secondary" onClick={next} disabled={isCurrentMonth}><ChevronRight size={16} strokeWidth={1.75} /></button>
</div>
<div className="summary-grid">
<div className="summary-card">
<div className="label">Actual MTD</div>
<div className="value">{fmtMoney(totalActual)}</div>
</div>
<div className="summary-card">
<div className="label">Forecast EOM</div>
<div className="value">{fmtMoney(totalForecast)}</div>
<div className="sub">rota + prior-week actual</div>
</div>
<div className="summary-card">
<div className="label">Monthly Budget</div>
<div className="value">{budget != null ? fmtMoney(budget) : '—'}</div>
</div>
<div className="summary-card">
<div className="label">% Budget (Forecast)</div>
<div className="value">
{pctBudget != null
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
: '—'}
</div>
{variance != null && (
<div className={`sub ${variance > 0 ? 'variance-over' : 'variance-under'}`}>
{variance > 0 ? `+${fmtMoney(variance)} over` : `${fmtMoney(Math.abs(variance))} under`}
</div>
)}
</div>
<div className="summary-card">
<div className="label">Net Sales MTD</div>
<div className="value">{fmtMoney(netSales)}</div>
</div>
<div className="summary-card">
<div className="label">% Net Sales</div>
<div className="value">{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}</div>
</div>
</div>
{loading && <div className="state-center">Loading</div>}
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
{!loading && !error && (
<>
{/* Stacked bar chart */}
<div className="card">
<div className="card-title">Weekly Breakdown</div>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={weeks} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis tickFormatter={v => `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} />
<Tooltip formatter={(v: number) => fmtMoney(v)} />
<Legend />
{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.4} />
))}
</Bar>
))}
</BarChart>
</ResponsiveContainer>
</div>
{/* Dept breakdown table */}
<div className="card">
<table className="data-table">
<thead>
<tr>
<th>Department</th>
<th className="right">Actual MTD</th>
<th className="right">Forecast EOM</th>
<th className="right">Budget</th>
<th className="right">% Budget</th>
<th className="right">Variance</th>
</tr>
</thead>
<tbody>
{deptSummary.map(dep => {
const dp = budget != null && budget > 0 ? (dep.forecast_eom / budget) * 100 : null
const dv = budget != null ? dep.forecast_eom - budget : null
return (
<tr key={dep.department_id}>
<td><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: dep.color, marginRight: 8 }} />{dep.department_name}</td>
<td className="right">{fmtMoney(dep.actual_mtd)}</td>
<td className="right">{fmtMoney(dep.forecast_eom)}</td>
<td className="right"></td>
<td className="right">
{dp != null ? <span className={`pct-badge ${pctClass(dp)}`}>{dp.toFixed(1)}%</span> : '—'}
</td>
<td className="right">
{dv != null && <span className={dv > 0 ? 'variance-over' : 'variance-under'}>{dv > 0 ? '+' : ''}{fmtMoney(dv)}</span>}
</td>
</tr>
)
})}
<tr className="total-row">
<td>Total</td>
<td className="right">{fmtMoney(totalActual)}</td>
<td className="right">{fmtMoney(totalForecast)}</td>
<td className="right">{budget != null ? fmtMoney(budget) : '—'}</td>
<td className="right">
{pctBudget != null ? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span> : '—'}
</td>
<td className="right">
{variance != null && <span className={variance > 0 ? 'variance-over' : 'variance-under'}>{variance > 0 ? '+' : ''}{fmtMoney(variance)}</span>}
</td>
</tr>
</tbody>
</table>
{showOncosts && <p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>}
</div>
</>
)}
</div>
)
}

View file

@ -0,0 +1,198 @@
import { useState, useEffect } from 'react'
import { Download } from 'lucide-react'
import {
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer,
} from 'recharts'
import { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
import type { WageBudget } from '../types'
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
function pctClass(p: number): string { return p <= 100 ? 'pct-green' : p <= 110 ? 'pct-amber' : 'pct-red' }
function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() }
const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
const MONTH_LABELS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
export default function Rolling12Months() {
const [rows, setRows] = useState<{ label: string; wages: number; budget: number | null; sales: number; pySales: number; partial: boolean }[]>([])
const [deptCols, setDeptCols] = useState<{ id: string; name: string; color: string }[]>([])
const [chartData, setChartData] = useState<Record<string, number | string>[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
;(async () => {
setLoading(true); setError(null)
try {
const today = new Date()
const curY = today.getFullYear()
const curM = today.getMonth() + 1 // 1-based
// 13 months: 12 complete + current partial
const months: { year: number; month: number }[] = []
for (let i = 12; i >= 0; i--) {
let m = curM - i
let y = curY
while (m <= 0) { m += 12; y-- }
months.push({ year: y, month: m })
}
const rangeFrom = `${months[0].year}-${String(months[0].month).padStart(2, '0')}-01`
const lastMon = months[months.length - 1]
const lastDim = daysInMonth(lastMon.year, lastMon.month)
const rangeTo = `${lastMon.year}-${String(lastMon.month).padStart(2, '0')}-${String(lastDim).padStart(2, '0')}`
const [actRes, salesRes, budRes] = await Promise.all([
getActuals(rangeFrom, rangeTo),
getNetSales(rangeFrom, rangeTo),
getBudgets(),
])
const salesByDate: Record<string, { sales: number; py: number }> = {}
for (const d of salesRes.days) salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales }
const budgetMap: Record<string, number> = {}
for (const b of budRes.budgets as WageBudget[]) budgetMap[b.month] = b.budget_amount
const depts = actRes.departments
const cols = depts.map((d, i) => ({ id: d.department_id, name: d.department_name, color: DEPT_COLORS[i % DEPT_COLORS.length] }))
setDeptCols(cols)
const tableRows: typeof rows = []
const cData: Record<string, number | string>[] = []
const todayStr = fmt(today)
for (const { year, month } of months) {
const dim = daysInMonth(year, month)
const monthStr = `${year}-${String(month).padStart(2, '0')}`
const monthFrom = `${monthStr}-01`
const monthTo = `${monthStr}-${String(dim).padStart(2, '0')}`
const isCurrentMonth = year === curY && month === curM
const effectiveTo = isCurrentMonth ? todayStr : monthTo
let wages = 0
const deptWages: Record<string, number> = {}
for (const dep of depts) {
let dCost = 0
for (const [date, val] of Object.entries(dep.days)) {
if (date >= monthFrom && date <= effectiveTo) dCost += val.cost
}
wages += dCost
deptWages[dep.department_id] = dCost
}
let sales = 0, pySales = 0
for (const [date, val] of Object.entries(salesByDate)) {
if (date >= monthFrom && date <= effectiveTo) { sales += val.sales; pySales += val.py }
}
const monKey = `${monthFrom}`
const budget = budgetMap[monKey] ?? null
const label = `${MONTH_LABELS[month - 1]}-${String(year).slice(2)}`
tableRows.push({ label, wages, budget, sales, pySales, partial: isCurrentMonth })
const cdRow: Record<string, number | string> = { label }
for (const dep of depts) cdRow[dep.department_name] = deptWages[dep.department_id] ?? 0
cData.push(cdRow)
}
setRows(tableRows)
setChartData(cData)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load')
} finally {
setLoading(false)
}
})()
}, [])
const today = new Date()
const curY = today.getFullYear()
const curM = today.getMonth() + 1
let fromY = curY, fromM = curM - 12
while (fromM <= 0) { fromM += 12; fromY-- }
const rangeFrom = `${fromY}-${String(fromM).padStart(2, '0')}-01`
const rangeTo = `${curY}-${String(curM).padStart(2, '0')}-${String(daysInMonth(curY, curM)).padStart(2, '0')}`
return (
<div>
<div className="page-header">
<h1 className="page-title">Rolling 12 Months</h1>
<button className="btn btn-secondary" onClick={() => downloadExport('rolling-months', rangeFrom, rangeTo)}>
<Download size={14} strokeWidth={1.75} /> CSV
</button>
</div>
{loading && <div className="state-center">Loading</div>}
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
{!loading && !error && (
<>
<div className="card">
<div className="card-title">Wages by Department (monthly)</div>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
<XAxis dataKey="label" tick={{ fontSize: 10 }} />
<YAxis tickFormatter={v => `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} />
<Tooltip formatter={(v: number) => fmtMoney(v)} />
<Legend />
{deptCols.map(dep => (
<Bar key={dep.id} dataKey={dep.name} stackId="a" fill={dep.color} />
))}
</BarChart>
</ResponsiveContainer>
</div>
<div className="card">
<table className="data-table">
<thead>
<tr>
<th>Month</th>
<th className="right">Total Wages</th>
<th className="right">Budget</th>
<th className="right">Var vs Budget</th>
<th className="right">% Budget</th>
<th className="right">Net Sales</th>
<th className="right">% Net Sales</th>
<th className="right">PY Net Sales</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => {
const pctB = r.budget != null && r.budget > 0 ? (r.wages / r.budget) * 100 : null
const pctS = r.sales > 0 ? (r.wages / r.sales) * 100 : null
const vari = r.budget != null ? r.wages - r.budget : null
return (
<tr key={i} style={r.partial ? { opacity: 0.7 } : {}}>
<td>
{r.label}
{r.partial && <span className="partial-badge">current</span>}
</td>
<td className="right">{fmtMoney(r.wages)}</td>
<td className="right">{r.budget != null ? fmtMoney(r.budget) : '—'}</td>
<td className="right">
{vari != null && (
<span className={vari > 0 ? 'variance-over' : 'variance-under'}>
{vari > 0 ? '+' : ''}{fmtMoney(vari)}
</span>
)}
</td>
<td className="right">
{pctB != null ? <span className={`pct-badge ${pctClass(pctB)}`}>{pctB.toFixed(1)}%</span> : '—'}
</td>
<td className="right">{r.sales > 0 ? fmtMoney(r.sales) : '—'}</td>
<td className="right">{pctS != null ? `${pctS.toFixed(1)}%` : '—'}</td>
<td className="right">{r.pySales > 0 ? fmtMoney(r.pySales) : '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</>
)}
</div>
)
}

View file

@ -0,0 +1,205 @@
import { useState, useEffect } from 'react'
import { Download } from 'lucide-react'
import {
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer,
LineChart, Line, CartesianGrid, ReferenceLine,
} from 'recharts'
import { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
import type { WageBudget } from '../types'
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
function addDays(d: Date, n: number): Date { const r = new Date(d); r.setDate(r.getDate() + n); return r }
function startOfWeek(d: Date): Date {
const day = d.getDay()
const r = new Date(d)
r.setDate(d.getDate() + (day === 0 ? -6 : 1 - day))
r.setHours(0, 0, 0, 0)
return r
}
function daysInMonth(d: Date): number { return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate() }
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
function pctClass(p: number): string { return p <= 100 ? 'pct-green' : p <= 110 ? 'pct-amber' : 'pct-red' }
const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
export default function Rolling12Weeks() {
const [rows, setRows] = useState<{ label: string; wages: number; budget: number | null; sales: number; pySales: number; partial: boolean }[]>([])
const [deptCols, setDeptCols] = useState<{ id: string; name: string; color: string }[]>([])
const [chartData, setChartData] = useState<Record<string, number | string>[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
;(async () => {
setLoading(true); setError(null)
try {
const today = new Date()
const thisMonday = startOfWeek(today)
// 13 weeks back from Monday = 12 complete weeks + current (partial)
const rangeStart = addDays(thisMonday, -12 * 7)
const rangeEnd = addDays(thisMonday, 6) // end of current week
const [actRes, salesRes, budRes] = await Promise.all([
getActuals(fmt(rangeStart), fmt(rangeEnd)),
getNetSales(fmt(rangeStart), fmt(rangeEnd)),
getBudgets(),
])
const salesByDate: Record<string, { sales: number; py: number }> = {}
for (const d of salesRes.days) {
salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales }
}
const budgetMap: Record<string, number> = {}
for (const b of budRes.budgets as WageBudget[]) {
budgetMap[b.month] = b.budget_amount
}
// Dept lookup
const depts = actRes.departments
const cols = depts.map((d, i) => ({ id: d.department_id, name: d.department_name, color: DEPT_COLORS[i % DEPT_COLORS.length] }))
setDeptCols(cols)
const tableRows: typeof rows = []
const cData: Record<string, number | string>[] = []
for (let w = 0; w < 13; w++) {
const wStart = addDays(rangeStart, w * 7)
const wEnd = addDays(wStart, 6)
const isPartial = wStart.toDateString() === thisMonday.toDateString()
const effectiveEnd = isPartial ? today : wEnd
let wages = 0
const deptWages: Record<string, number> = {}
for (const dep of depts) {
let dCost = 0
for (let i = 0; i <= 6; i++) {
const d = addDays(wStart, i)
if (d > effectiveEnd) break
const ds = fmt(d)
dCost += dep.days[ds]?.cost ?? 0
}
wages += dCost
deptWages[dep.department_id] = dCost
}
let sales = 0, pySales = 0
for (let i = 0; i <= 6; i++) {
const ds = fmt(addDays(wStart, i))
sales += salesByDate[ds]?.sales ?? 0
pySales += salesByDate[ds]?.py ?? 0
}
// Pro-rata budget
const monStr = `${wStart.getFullYear()}-${String(wStart.getMonth() + 1).padStart(2, '0')}-01`
const monthBudget = budgetMap[monStr]
const budget = monthBudget != null
? monthBudget * (isPartial ? (Math.ceil((today.getTime() - wStart.getTime()) / 86_400_000) + 1) : 7) / daysInMonth(wStart)
: null
const label = `w/e ${wEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}`
tableRows.push({ label, wages, budget, sales, pySales, partial: isPartial })
const cdRow: Record<string, number | string> = { label }
for (const dep of depts) cdRow[dep.department_name] = deptWages[dep.department_id] ?? 0
cdRow._wages = wages
cdRow._budget = budget ?? 0
cData.push(cdRow)
}
setRows(tableRows)
setChartData(cData)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load')
} finally {
setLoading(false)
}
})()
}, [])
const today = new Date()
const rangeStart = addDays(startOfWeek(today), -12 * 7)
const rangeEnd = addDays(startOfWeek(today), 6)
return (
<div>
<div className="page-header">
<h1 className="page-title">Rolling 12 Weeks</h1>
<button className="btn btn-secondary" onClick={() => downloadExport('rolling-weeks', fmt(rangeStart), fmt(rangeEnd))}>
<Download size={14} strokeWidth={1.75} /> CSV
</button>
</div>
{loading && <div className="state-center">Loading</div>}
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
{!loading && !error && (
<>
<div className="card">
<div className="card-title">Wages by Department (weekly)</div>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
<XAxis dataKey="label" tick={{ fontSize: 10 }} />
<YAxis tickFormatter={v => `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} />
<Tooltip formatter={(v: number) => fmtMoney(v)} />
<Legend />
{deptCols.map(dep => (
<Bar key={dep.id} dataKey={dep.name} stackId="a" fill={dep.color} />
))}
</BarChart>
</ResponsiveContainer>
</div>
<div className="card">
<table className="data-table">
<thead>
<tr>
<th>Week</th>
<th className="right">Total Wages</th>
<th className="right">Budget</th>
<th className="right">Var vs Budget</th>
<th className="right">% Budget</th>
<th className="right">Net Sales</th>
<th className="right">% Net Sales</th>
<th className="right">PY Net Sales</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => {
const pctB = r.budget != null && r.budget > 0 ? (r.wages / r.budget) * 100 : null
const pctS = r.sales > 0 ? (r.wages / r.sales) * 100 : null
const vari = r.budget != null ? r.wages - r.budget : null
return (
<tr key={i} style={r.partial ? { opacity: 0.7 } : {}}>
<td>
{r.label}
{r.partial && <span className="partial-badge">current</span>}
</td>
<td className="right">{fmtMoney(r.wages)}</td>
<td className="right">{r.budget != null ? fmtMoney(r.budget) : '—'}</td>
<td className="right">
{vari != null && (
<span className={vari > 0 ? 'variance-over' : 'variance-under'}>
{vari > 0 ? '+' : ''}{fmtMoney(vari)}
</span>
)}
</td>
<td className="right">
{pctB != null ? <span className={`pct-badge ${pctClass(pctB)}`}>{pctB.toFixed(1)}%</span> : '—'}
</td>
<td className="right">{r.sales > 0 ? fmtMoney(r.sales) : '—'}</td>
<td className="right">{pctS != null ? `${pctS.toFixed(1)}%` : '—'}</td>
<td className="right">{r.pySales > 0 ? fmtMoney(r.pySales) : '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</>
)}
</div>
)
}

View file

@ -0,0 +1,275 @@
import { useState, useEffect } from 'react'
import { RefreshCw, Download, X, CheckSquare, Square } from 'lucide-react'
import { getSettings, saveSettings, getDepartments, triggerSync, getSyncStatus, cancelBackfill } from '../api'
import type { AppSetting, Department } from '../types'
function fmtDate(iso: string | null): string {
if (!iso) return 'Never'
return new Date(iso).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
}
export default function SettingsPage() {
const [settings, setSettings] = useState<Record<string, string>>({})
const [depts, setDepts] = useState<Department[]>([])
const [syncStatus, setSyncStatus] = useState<{ sync_last_at: string | null; backfill_last_at: string | null; backfill_running: boolean } | null>(null)
const [backfillProg, setBackfillProg] = useState<{ processed: number; total: number; current: string } | null>(null)
const [syncing, setSyncing] = useState(false)
const [loading, setLoading] = useState(true)
const [fetchingDepts, setFetchingDepts] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([getSettings(), getSyncStatus()])
.then(([settRes, statusRes]) => {
const map: Record<string, string> = {}
for (const s of settRes.settings as AppSetting[]) map[s.key] = s.value
setSettings(map)
setSyncStatus(statusRes)
// Parse saved departments if present
if (map.departments) {
try { setDepts(JSON.parse(map.departments)) } catch { /* ignore */ }
}
})
.catch(e => setError(e.message))
.finally(() => setLoading(false))
}, [])
const handleChange = (key: string, value: string) => {
setSettings(s => ({ ...s, [key]: value }))
}
const handleSave = async () => {
setError(null)
try {
const deptsJson = depts.length > 0 ? JSON.stringify(depts) : ''
await saveSettings([
{ key: 'forecasting_url', value: settings.forecasting_url ?? '' },
{ key: 'forecasting_api_key', value: settings.forecasting_api_key ?? '' },
{ key: 'show_oncosts', value: settings.show_oncosts ?? 'true' },
{ key: 'departments', value: deptsJson },
])
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Save failed')
}
}
const handleFetchDepts = async () => {
setFetchingDepts(true); setError(null)
try {
const res = await getDepartments()
// Merge with existing enabled state
const existing = Object.fromEntries(depts.map(d => [d.id, d.enabled]))
const merged = res.departments.map(d => ({
...d,
enabled: existing[d.id] ?? true,
}))
setDepts(merged)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to fetch departments')
} finally {
setFetchingDepts(false)
}
}
const toggleDept = (id: string) => {
setDepts(ds => ds.map(d => d.id === id ? { ...d, enabled: d.enabled === false } : d))
}
const toggleAll = (enabled: boolean) => {
setDepts(ds => ds.map(d => ({ ...d, enabled })))
}
const handleSync = async () => {
setSyncing(true); setError(null)
try {
const res = await triggerSync()
setSyncStatus(s => s ? { ...s, sync_last_at: new Date().toISOString() } : s)
alert(`Sync complete — ${res.actual_rows} actual rows, ${res.scheduled_rows} scheduled rows`)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Sync failed')
} finally {
setSyncing(false)
}
}
const handleBackfill = async () => {
if (!confirm('Start deep backfill? This will fetch ~13 months of Workforce data and may take a few minutes.')) return
setBackfillProg({ processed: 0, total: 1, current: '…' })
setError(null)
const es = new EventSource('/wages/api/sync/backfill', { withCredentials: true })
const doPost = () => {
fetch('/wages/api/sync/backfill', {
method: 'POST',
credentials: 'include',
}).catch(() => {})
}
doPost()
es.onmessage = (e) => {
const data = JSON.parse(e.data)
if (data.done) {
es.close()
setBackfillProg(null)
setSyncStatus(s => s ? { ...s, backfill_last_at: new Date().toISOString() } : s)
} else if (data.error) {
es.close()
setError(data.error)
setBackfillProg(null)
} else {
setBackfillProg(data)
}
}
es.onerror = () => { es.close(); setBackfillProg(null) }
}
const handleCancelBackfill = async () => {
await cancelBackfill()
setBackfillProg(null)
}
if (loading) return <div className="state-center">Loading</div>
return (
<div>
<div className="page-header">
<h1 className="page-title">Settings</h1>
<button className="btn btn-primary" onClick={handleSave}>
{saved ? 'Saved!' : 'Save Settings'}
</button>
</div>
{error && <div style={{ color: '#dc2626', marginBottom: 16, padding: '8px 12px', background: '#fee2e2', borderRadius: 6 }}>{error}</div>}
{/* Forecasting API */}
<div className="card">
<div className="card-title">Net Sales Forecasting API</div>
<div style={{ display: 'grid', gap: 12 }}>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 4, color: 'var(--text-muted)' }}>
Forecasting URL
</label>
<input
type="text"
value={settings.forecasting_url ?? ''}
onChange={e => handleChange('forecasting_url', e.target.value)}
placeholder="http://10.10.10.113:3080"
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 4, color: 'var(--text-muted)' }}>
API Key
</label>
<input
type="text"
value={settings.forecasting_api_key ?? ''}
onChange={e => handleChange('forecasting_api_key', e.target.value)}
placeholder="fk_…"
/>
</div>
</div>
</div>
{/* On-costs toggle */}
<div className="card">
<div className="card-title">Cost Display</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 13 }}>
<input
type="checkbox"
checked={settings.show_oncosts !== 'false'}
onChange={e => handleChange('show_oncosts', e.target.checked ? 'true' : 'false')}
/>
Include estimated employer on-costs (NI) in displayed figures
</label>
<p style={{ margin: '8px 0 0', fontSize: 12, color: 'var(--text-muted)' }}>
When enabled, all wage figures include Workforce-estimated employer contributions. Final payroll is in Sage.
</p>
</div>
{/* Department filter */}
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div className="card-title" style={{ margin: 0 }}>Department Filter</div>
<button className="btn btn-secondary" onClick={handleFetchDepts} disabled={fetchingDepts}>
<RefreshCw size={14} strokeWidth={1.75} />
{fetchingDepts ? 'Fetching…' : 'Fetch from Workforce'}
</button>
</div>
{depts.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>
Click "Fetch from Workforce" to load departments. All will be enabled by default.
</p>
) : (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<button className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => toggleAll(true)}>Select all</button>
<button className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => toggleAll(false)}>Deselect all</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 6 }}>
{depts.map(d => (
<label key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13, padding: '6px 8px', borderRadius: 6, background: d.enabled !== false ? 'var(--body-bg)' : 'transparent' }}>
<span onClick={() => toggleDept(d.id)}>
{d.enabled !== false
? <CheckSquare size={16} strokeWidth={1.75} color="var(--app-primary)" />
: <Square size={16} strokeWidth={1.75} color="var(--text-muted)" />}
</span>
{d.name}
</label>
))}
</div>
</>
)}
<p style={{ marginTop: 10, fontSize: 12, color: 'var(--text-muted)' }}>
Unticked departments are excluded from all reports and sync. Save Settings to apply.
</p>
</div>
{/* Sync */}
<div className="card">
<div className="card-title">Data Sync</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
<button className="btn btn-primary" onClick={handleSync} disabled={syncing || backfillProg != null}>
<RefreshCw size={14} strokeWidth={1.75} />
{syncing ? 'Syncing…' : 'Sync Now (35 days)'}
</button>
<button className="btn btn-secondary" onClick={handleBackfill} disabled={syncing || backfillProg != null}>
<Download size={14} strokeWidth={1.75} />
Deep Backfill (13 months)
</button>
{backfillProg && (
<button className="btn btn-secondary" onClick={handleCancelBackfill}>
<X size={14} strokeWidth={1.75} /> Cancel
</button>
)}
</div>
{backfillProg && (
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Fetching {backfillProg.current}</span>
<span>{backfillProg.processed} / {backfillProg.total} days</span>
</div>
<div style={{ height: 6, background: 'var(--border)', borderRadius: 3 }}>
<div style={{ height: '100%', width: `${Math.min(100, (backfillProg.processed / backfillProg.total) * 100)}%`, background: 'var(--app-primary)', borderRadius: 3, transition: 'width 0.3s' }} />
</div>
</div>
)}
<div style={{ fontSize: 13, color: 'var(--text-muted)', display: 'grid', gap: 4 }}>
<div>Last sync: <strong>{fmtDate(syncStatus?.sync_last_at ?? null)}</strong></div>
<div>Last backfill: <strong>{fmtDate(syncStatus?.backfill_last_at ?? null)}</strong></div>
</div>
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-muted)' }}>
Sync Now pulls the last 35 days of timesheets + next 14 days of schedules. Auto-sync runs every hour.
Deep Backfill fetches the full 13-month history at 250ms per week to avoid rate limits.
</p>
</div>
</div>
)
}

View file

@ -0,0 +1,206 @@
import { useState, useEffect, useCallback } from 'react'
import { ChevronLeft, ChevronRight, Download } from 'lucide-react'
import { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
import type { DeptActuals, WageBudget } from '../types'
function startOfWeek(d: Date): Date {
const day = d.getDay()
const diff = (day === 0 ? -6 : 1 - day) // Mon = start
const r = new Date(d)
r.setDate(d.getDate() + diff)
r.setHours(0, 0, 0, 0)
return r
}
function addDays(d: Date, n: number): Date {
const r = new Date(d)
r.setDate(r.getDate() + n)
return r
}
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
function fmtMoney(n: number): string { return `£${n.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}` }
function daysInMonth(date: Date): number { return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate() }
function pctClass(pct: number | null): string {
if (pct == null) return ''
if (pct <= 100) return 'pct-green'
if (pct <= 110) return 'pct-amber'
return 'pct-red'
}
export default function Weekly() {
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date()))
const [depts, setDepts] = useState<DeptActuals[]>([])
const [netSales, setNetSales] = useState(0)
const [pySales, setPySales] = useState(0)
const [budget, setBudget] = useState<number | null>(null)
const [showOncosts, setShowOncosts] = useState(true)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const weekEnd = addDays(weekStart, 6)
const fromStr = fmt(weekStart)
const toStr = fmt(weekEnd)
const load = useCallback(async () => {
setLoading(true); setError(null)
try {
const [actRes, salesRes, budgetRes] = await Promise.all([
getActuals(fromStr, toStr),
getNetSales(fromStr, toStr),
getBudgets(),
])
setDepts(actRes.departments)
setShowOncosts(actRes.show_oncosts)
const totalSales = salesRes.days.reduce((s, d) => s + d.net_sales, 0)
const totalPY = salesRes.days.reduce((s, d) => s + d.py_sales, 0)
setNetSales(totalSales)
setPySales(totalPY)
// Find budget for the month of weekStart
const monthKey = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-01`
const bRow = (budgetRes.budgets as WageBudget[]).find(b => b.month === monthKey)
if (bRow) {
const dim = daysInMonth(weekStart)
// Pro-rata: days in the selected week ÷ days in month
const today = new Date()
let weekDays = 7
if (weekStart <= today && today <= weekEnd) {
weekDays = Math.ceil((today.getTime() - weekStart.getTime()) / 86_400_000) + 1
}
setBudget(bRow.budget_amount * (weekDays / dim))
} else {
setBudget(null)
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load')
} finally {
setLoading(false)
}
}, [fromStr, toStr, weekStart, weekEnd])
useEffect(() => { load() }, [load])
const prev = () => setWeekStart(d => addDays(d, -7))
const next = () => setWeekStart(d => addDays(d, 7))
const isCurrentWeek = fmt(startOfWeek(new Date())) === fmt(weekStart)
// Totals
const deptTotals = depts.map(dep => {
const cost = Object.values(dep.days).reduce((s, d) => s + d.cost, 0)
return { department_name: dep.department_name, cost }
}).sort((a, b) => b.cost - a.cost)
const totalWages = deptTotals.reduce((s, d) => s + d.cost, 0)
const pctBudget = budget != null && budget > 0 ? (totalWages / budget) * 100 : null
const pctSales = netSales > 0 ? (totalWages / netSales) * 100 : null
const weekLabel = `${weekStart.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}`
return (
<div>
<div className="page-header">
<h1 className="page-title">Weekly Wages</h1>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button className="btn btn-secondary" onClick={() => downloadExport('weekly', fromStr, toStr)}>
<Download size={14} strokeWidth={1.75} /> CSV
</button>
</div>
</div>
<div className="period-nav" style={{ marginBottom: 20 }}>
<button className="btn btn-secondary" onClick={prev}><ChevronLeft size={16} strokeWidth={1.75} /></button>
<span className="period-label">{weekLabel}</span>
<button className="btn btn-secondary" onClick={next} disabled={isCurrentWeek}><ChevronRight size={16} strokeWidth={1.75} /></button>
</div>
{/* Summary cards */}
<div className="summary-grid">
<div className="summary-card">
<div className="label">Total Wages</div>
<div className="value">{fmtMoney(totalWages)}</div>
<div className="sub">{showOncosts ? 'incl. on-costs' : 'base cost'}</div>
</div>
<div className="summary-card">
<div className="label">Pro-rata Budget</div>
<div className="value">{budget != null ? fmtMoney(budget) : '—'}</div>
<div className="sub">proportion of monthly</div>
</div>
<div className="summary-card">
<div className="label">% vs Budget</div>
<div className="value">
{pctBudget != null
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
: '—'}
</div>
</div>
<div className="summary-card">
<div className="label">Net Sales</div>
<div className="value">{fmtMoney(netSales)}</div>
{pySales > 0 && <div className="sub">PY {fmtMoney(pySales)}</div>}
</div>
<div className="summary-card">
<div className="label">% of Net Sales</div>
<div className="value">{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}</div>
</div>
</div>
{loading && <div className="state-center">Loading</div>}
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
{!loading && !error && (
<div className="card">
<table className="data-table">
<thead>
<tr>
<th>Department</th>
<th className="right">Wages</th>
<th className="right">Budget (pro-rata)</th>
<th className="right">% Budget</th>
<th className="right">Net Sales</th>
<th className="right">% Net Sales</th>
</tr>
</thead>
<tbody>
{deptTotals.map(dep => {
const depPct = budget != null && budget > 0 ? (dep.cost / budget) * 100 : null
const depSPct = netSales > 0 ? (dep.cost / netSales) * 100 : null
return (
<tr key={dep.department_name}>
<td>{dep.department_name}</td>
<td className="right">{fmtMoney(dep.cost)}</td>
<td className="right"></td>
<td className="right">
{depPct != null
? <span className={`pct-badge ${pctClass(depPct)}`}>{depPct.toFixed(1)}%</span>
: '—'}
</td>
<td className="right">{fmtMoney(netSales)}</td>
<td className="right">{depSPct != null ? `${depSPct.toFixed(1)}%` : '—'}</td>
</tr>
)
})}
<tr className="total-row">
<td>Total</td>
<td className="right">{fmtMoney(totalWages)}</td>
<td className="right">{budget != null ? fmtMoney(budget) : '—'}</td>
<td className="right">
{pctBudget != null
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
: '—'}
</td>
<td className="right">{fmtMoney(netSales)}</td>
<td className="right">{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}</td>
</tr>
</tbody>
</table>
{showOncosts && (
<p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>
)}
</div>
)}
</div>
)
}

49
frontend/src/types.ts Normal file
View file

@ -0,0 +1,49 @@
export interface User {
email: string
name: string
is_admin: boolean
caps: string[]
}
export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap)
}
export interface DeptActuals {
department_id: string
department_name: string
days: Record<string, { base_cost: number; total_cost: number; cost: number; shift_count: number }>
}
export interface DeptScheduled {
department_id: string
department_name: string
days: Record<string, { cost: number; shift_count: number }>
}
export interface NetSalesDay {
date: string
net_sales: number
py_sales: number
accom: number
dry: number
wet: number
is_past: boolean
}
export interface WageBudget {
month: string // 'YYYY-MM-DD' (first of month)
budget_amount: number
}
export interface Department {
id: string
name: string
enabled?: boolean
}
export interface AppSetting {
key: string
value: string
updated_at: string
}

19
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}

31
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
base: '/wages/',
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Wage Costs',
short_name: 'Wages',
start_url: '/wages/',
scope: '/wages/',
display: 'standalone',
theme_color: '#065f46',
background_color: '#065f46',
icons: [
{ src: '/wages/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/wages/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
workbox: {
navigateFallback: '/wages/index.html',
navigateFallbackDenylist: [/\/api\//],
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
},
}),
],
})

45
seed-app.js Normal file
View file

@ -0,0 +1,45 @@
// Run against the auth DB to register the wages app and its capabilities.
// Usage: DATABASE_URL=postgresql://... node seed-app.js
import pg from 'pg'
const { Pool } = pg
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
await pool.query(`
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
VALUES (
'wages',
'Wage Costs',
'Live wage cost reporting — weekly, monthly, and rolling history vs budget and net sales',
'/wages',
'DollarSign',
'#065f46',
'Finance',
'10.10.10.124',
3080
)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
base_path = EXCLUDED.base_path,
icon = EXCLUDED.icon,
theme_color = EXCLUDED.theme_color,
category = EXCLUDED.category,
internal_host = EXCLUDED.internal_host,
internal_port = EXCLUDED.internal_port;
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
SELECT a.id, c.slug, c.name, c.description, c.sort_order
FROM apps a, (VALUES
('view', 'View Reports', 'View all wage cost reports (weekly, monthly, rolling)', 1),
('budget', 'Edit Budgets', 'Set monthly wage budget targets', 2),
('sync', 'Manual Sync', 'Trigger a Workforce API data sync or backfill', 3),
('settings', 'Settings', 'App settings, API configuration and department filter', 4)
) AS c(slug, name, description, sort_order)
WHERE a.slug = 'wages'
ON CONFLICT (app_id, slug) DO NOTHING;
`)
console.log('wages seeded')
await pool.end()