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

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