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:
commit
2e0592eb90
37 changed files with 3078 additions and 0 deletions
56
backend/src/auth.js
Normal file
56
backend/src/auth.js
Normal 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
71
backend/src/db.js
Normal 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
40
backend/src/index.js
Normal 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
80
backend/src/ip-check.js
Normal 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
|
||||
}
|
||||
20
backend/src/lib/scheduler.js
Normal file
20
backend/src/lib/scheduler.js
Normal 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)
|
||||
}
|
||||
229
backend/src/lib/workforce.js
Normal file
229
backend/src/lib/workforce.js
Normal 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))
|
||||
}
|
||||
}
|
||||
44
backend/src/routes/actuals.js
Normal file
44
backend/src/routes/actuals.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
37
backend/src/routes/budgets.js
Normal file
37
backend/src/routes/budgets.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
84
backend/src/routes/export.js
Normal file
84
backend/src/routes/export.js
Normal 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)
|
||||
})
|
||||
}
|
||||
45
backend/src/routes/net-sales.js
Normal file
45
backend/src/routes/net-sales.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
40
backend/src/routes/scheduled.js
Normal file
40
backend/src/routes/scheduled.js
Normal 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) }
|
||||
})
|
||||
}
|
||||
30
backend/src/routes/settings.js
Normal file
30
backend/src/routes/settings.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
78
backend/src/routes/sync.js
Normal file
78
backend/src/routes/sync.js
Normal 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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue