From 2e0592eb9033707714a5263949c67db260dea346 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 23 Jul 2026 09:03:52 +0000 Subject: [PATCH] Initial scaffold: wages app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/Dockerfile | 7 + backend/package.json | 16 ++ backend/src/auth.js | 56 ++++ backend/src/db.js | 71 +++++ backend/src/index.js | 40 +++ backend/src/ip-check.js | 80 ++++++ backend/src/lib/scheduler.js | 20 ++ backend/src/lib/workforce.js | 229 ++++++++++++++++ backend/src/routes/actuals.js | 44 +++ backend/src/routes/budgets.js | 37 +++ backend/src/routes/export.js | 84 ++++++ backend/src/routes/net-sales.js | 45 ++++ backend/src/routes/scheduled.js | 40 +++ backend/src/routes/settings.js | 30 +++ backend/src/routes/sync.js | 78 ++++++ docker-compose.yml | 36 +++ frontend/Dockerfile | 13 + frontend/index.html | 13 + frontend/nginx.conf | 55 ++++ frontend/package.json | 25 ++ frontend/src/App.tsx | 77 ++++++ frontend/src/api.ts | 71 +++++ frontend/src/components/AuthGate.tsx | 41 +++ frontend/src/components/UpdateBanner.tsx | 28 ++ frontend/src/hooks/useVersionCheck.ts | 31 +++ frontend/src/index.css | 325 +++++++++++++++++++++++ frontend/src/main.tsx | 10 + frontend/src/pages/Budgets.tsx | 174 ++++++++++++ frontend/src/pages/Monthly.tsx | 274 +++++++++++++++++++ frontend/src/pages/Rolling12Months.tsx | 198 ++++++++++++++ frontend/src/pages/Rolling12Weeks.tsx | 205 ++++++++++++++ frontend/src/pages/Settings.tsx | 275 +++++++++++++++++++ frontend/src/pages/Weekly.tsx | 206 ++++++++++++++ frontend/src/types.ts | 49 ++++ frontend/tsconfig.json | 19 ++ frontend/vite.config.ts | 31 +++ seed-app.js | 45 ++++ 37 files changed, 3078 insertions(+) create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/auth.js create mode 100644 backend/src/db.js create mode 100644 backend/src/index.js create mode 100644 backend/src/ip-check.js create mode 100644 backend/src/lib/scheduler.js create mode 100644 backend/src/lib/workforce.js create mode 100644 backend/src/routes/actuals.js create mode 100644 backend/src/routes/budgets.js create mode 100644 backend/src/routes/export.js create mode 100644 backend/src/routes/net-sales.js create mode 100644 backend/src/routes/scheduled.js create mode 100644 backend/src/routes/settings.js create mode 100644 backend/src/routes/sync.js create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/AuthGate.tsx create mode 100644 frontend/src/components/UpdateBanner.tsx create mode 100644 frontend/src/hooks/useVersionCheck.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Budgets.tsx create mode 100644 frontend/src/pages/Monthly.tsx create mode 100644 frontend/src/pages/Rolling12Months.tsx create mode 100644 frontend/src/pages/Rolling12Weeks.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/Weekly.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 seed-app.js diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..35a6156 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c4b9957 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/auth.js b/backend/src/auth.js new file mode 100644 index 0000000..54a8f5d --- /dev/null +++ b/backend/src/auth.js @@ -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}` }) + } + } +} diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..5679957 --- /dev/null +++ b/backend/src/db.js @@ -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 ?? ''] + ) +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..e947a39 --- /dev/null +++ b/backend/src/index.js @@ -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) +} diff --git a/backend/src/ip-check.js b/backend/src/ip-check.js new file mode 100644 index 0000000..4d8cb19 --- /dev/null +++ b/backend/src/ip-check.js @@ -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 +} diff --git a/backend/src/lib/scheduler.js b/backend/src/lib/scheduler.js new file mode 100644 index 0000000..aa7ee50 --- /dev/null +++ b/backend/src/lib/scheduler.js @@ -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) +} diff --git a/backend/src/lib/workforce.js b/backend/src/lib/workforce.js new file mode 100644 index 0000000..8319675 --- /dev/null +++ b/backend/src/lib/workforce.js @@ -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)) + } +} diff --git a/backend/src/routes/actuals.js b/backend/src/routes/actuals.js new file mode 100644 index 0000000..1f096d4 --- /dev/null +++ b/backend/src/routes/actuals.js @@ -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 } + }) +} diff --git a/backend/src/routes/budgets.js b/backend/src/routes/budgets.js new file mode 100644 index 0000000..21e94d5 --- /dev/null +++ b/backend/src/routes/budgets.js @@ -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 } + }) +} diff --git a/backend/src/routes/export.js b/backend/src/routes/export.js new file mode 100644 index 0000000..3224f78 --- /dev/null +++ b/backend/src/routes/export.js @@ -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) + }) +} diff --git a/backend/src/routes/net-sales.js b/backend/src/routes/net-sales.js new file mode 100644 index 0000000..4018493 --- /dev/null +++ b/backend/src/routes/net-sales.js @@ -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 } + }) +} diff --git a/backend/src/routes/scheduled.js b/backend/src/routes/scheduled.js new file mode 100644 index 0000000..9b54432 --- /dev/null +++ b/backend/src/routes/scheduled.js @@ -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) } + }) +} diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js new file mode 100644 index 0000000..e21a381 --- /dev/null +++ b/backend/src/routes/settings.js @@ -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 } + }) +} diff --git a/backend/src/routes/sync.js b/backend/src/routes/sync.js new file mode 100644 index 0000000..b2397fc --- /dev/null +++ b/backend/src/routes/sync.js @@ -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 }) + } + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9d8b17b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e6185f0 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e9ea79a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Wage Costs + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..54eca8c --- /dev/null +++ b/frontend/nginx.conf @@ -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/; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0c01514 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..33e20e0 --- /dev/null +++ b/frontend/src/App.tsx @@ -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('weekly') + + const hotelName = import.meta.env.VITE_HOTEL_NAME || 'Hotel' + + return ( +
+ + +
+ {page === 'weekly' && } + {page === 'monthly' && } + {page === 'rolling-weeks' && } + {page === 'rolling-months' && } + {page === 'budgets' && } + {page === 'settings' && } +
+
+ ) +} + +export default function App() { + const updateAvailable = useVersionCheck('/wages/health') + return ( + <> + + + + + + ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..12c85d8 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,71 @@ +import type { DeptActuals, DeptScheduled, NetSalesDay, WageBudget, AppSetting } from './types' + +const BASE = '/wages/api' + +async function request(path: string, opts: RequestInit = {}): Promise { + 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') +} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 0000000..7e6d832 --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState, createContext, useContext } from 'react' +import type { User } from '../types' + +interface AuthCtx { user: User } +const Ctx = createContext(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(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 ( +
+ Loading… +
+ ) + } + + return {children} +} diff --git a/frontend/src/components/UpdateBanner.tsx b/frontend/src/components/UpdateBanner.tsx new file mode 100644 index 0000000..8b815c3 --- /dev/null +++ b/frontend/src/components/UpdateBanner.tsx @@ -0,0 +1,28 @@ +import { RefreshCw } from 'lucide-react' + +export function UpdateBanner({ visible }: { visible: boolean }) { + if (!visible) return null + return ( +
+ A new version is available. + +
+ ) +} diff --git a/frontend/src/hooks/useVersionCheck.ts b/frontend/src/hooks/useVersionCheck.ts new file mode 100644 index 0000000..b0dc04d --- /dev/null +++ b/frontend/src/hooks/useVersionCheck.ts @@ -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 +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..99352e2 --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..520b520 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + +) diff --git a/frontend/src/pages/Budgets.tsx b/frontend/src/pages/Budgets.tsx new file mode 100644 index 0000000..0fbc320 --- /dev/null +++ b/frontend/src/pages/Budgets.tsx @@ -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>({}) + const [editing, setEditing] = useState>({}) + const [saving, setSaving] = useState>({}) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const inputRefs = useRef>({}) + + const months = getMonthRange() + + useEffect(() => { + getBudgets() + .then(res => { + const map: Record = {} + 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
Loading…
+ + return ( +
+
+

Wage Budgets

+
+ + {error &&
{error}
} + +
+

+ Enter the total monthly wages budget (FD figure). Click a cell to edit, press Enter or Tab to save. +

+ + + + + + + + + + {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 ( + + + + + + ) + })} + +
MonthBudgetWeekly equiv.
+ {MONTH_LABELS[month - 1]} {year} + + {isEditing ? ( + { 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} + /> + ) : ( + 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'} + + )} + + {weekly != null ? `£${Math.round(weekly).toLocaleString('en-GB')}` : '—'} +
+
+
+ ) +} diff --git a/frontend/src/pages/Monthly.tsx b/frontend/src/pages/Monthly.tsx new file mode 100644 index 0000000..a11e6c9 --- /dev/null +++ b/frontend/src/pages/Monthly.tsx @@ -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([]) + const [scheduled, setScheduled] = useState>>({}) // dept_id → date → cost + const [netSales, setNetSales] = useState(0) + const [budget, setBudget] = useState(null) + const [showOncosts, setShowOncosts] = useState(true) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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> = {} + 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 ( +
+
+

Monthly View

+ +
+ +
+ + {monthLabel} + +
+ +
+
+
Actual MTD
+
{fmtMoney(totalActual)}
+
+
+
Forecast EOM
+
{fmtMoney(totalForecast)}
+
rota + prior-week actual
+
+
+
Monthly Budget
+
{budget != null ? fmtMoney(budget) : '—'}
+
+
+
% Budget (Forecast)
+
+ {pctBudget != null + ? {pctBudget.toFixed(1)}% + : '—'} +
+ {variance != null && ( +
0 ? 'variance-over' : 'variance-under'}`}> + {variance > 0 ? `+${fmtMoney(variance)} over` : `${fmtMoney(Math.abs(variance))} under`} +
+ )} +
+
+
Net Sales MTD
+
{fmtMoney(netSales)}
+
+
+
% Net Sales
+
{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}
+
+
+ + {loading &&
Loading…
} + {error &&
{error}
} + + {!loading && !error && ( + <> + {/* Stacked bar chart */} +
+
Weekly Breakdown
+ + + + `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} /> + fmtMoney(v)} /> + + {deptSummary.map(dep => ( + + {weeks.map((w, i) => ( + + ))} + + ))} + + +
+ + {/* Dept breakdown table */} +
+ + + + + + + + + + + + + {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 ( + + + + + + + + + ) + })} + + + + + + + + + +
DepartmentActual MTDForecast → EOMBudget% BudgetVariance
{dep.department_name}{fmtMoney(dep.actual_mtd)}{fmtMoney(dep.forecast_eom)} + {dp != null ? {dp.toFixed(1)}% : '—'} + + {dv != null && 0 ? 'variance-over' : 'variance-under'}>{dv > 0 ? '+' : ''}{fmtMoney(dv)}} +
Total{fmtMoney(totalActual)}{fmtMoney(totalForecast)}{budget != null ? fmtMoney(budget) : '—'} + {pctBudget != null ? {pctBudget.toFixed(1)}% : '—'} + + {variance != null && 0 ? 'variance-over' : 'variance-under'}>{variance > 0 ? '+' : ''}{fmtMoney(variance)}} +
+ {showOncosts &&

Includes estimated employer on-costs. Final payroll figures are in Sage.

} +
+ + )} +
+ ) +} diff --git a/frontend/src/pages/Rolling12Months.tsx b/frontend/src/pages/Rolling12Months.tsx new file mode 100644 index 0000000..a8a847c --- /dev/null +++ b/frontend/src/pages/Rolling12Months.tsx @@ -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[]>([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 = {} + for (const d of salesRes.days) salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales } + + const budgetMap: Record = {} + 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[] = [] + 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 = {} + 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 = { 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 ( +
+
+

Rolling 12 Months

+ +
+ + {loading &&
Loading…
} + {error &&
{error}
} + + {!loading && !error && ( + <> +
+
Wages by Department (monthly)
+ + + + `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} /> + fmtMoney(v)} /> + + {deptCols.map(dep => ( + + ))} + + +
+ +
+ + + + + + + + + + + + + + + {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 ( + + + + + + + + + + + ) + })} + +
MonthTotal WagesBudgetVar vs Budget% BudgetNet Sales% Net SalesPY Net Sales
+ {r.label} + {r.partial && current} + {fmtMoney(r.wages)}{r.budget != null ? fmtMoney(r.budget) : '—'} + {vari != null && ( + 0 ? 'variance-over' : 'variance-under'}> + {vari > 0 ? '+' : ''}{fmtMoney(vari)} + + )} + + {pctB != null ? {pctB.toFixed(1)}% : '—'} + {r.sales > 0 ? fmtMoney(r.sales) : '—'}{pctS != null ? `${pctS.toFixed(1)}%` : '—'}{r.pySales > 0 ? fmtMoney(r.pySales) : '—'}
+
+ + )} +
+ ) +} diff --git a/frontend/src/pages/Rolling12Weeks.tsx b/frontend/src/pages/Rolling12Weeks.tsx new file mode 100644 index 0000000..2a547c8 --- /dev/null +++ b/frontend/src/pages/Rolling12Weeks.tsx @@ -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[]>([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 = {} + for (const d of salesRes.days) { + salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales } + } + + const budgetMap: Record = {} + 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[] = [] + + 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 = {} + + 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 = { 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 ( +
+
+

Rolling 12 Weeks

+ +
+ + {loading &&
Loading…
} + {error &&
{error}
} + + {!loading && !error && ( + <> +
+
Wages by Department (weekly)
+ + + + `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} /> + fmtMoney(v)} /> + + {deptCols.map(dep => ( + + ))} + + +
+ +
+ + + + + + + + + + + + + + + {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 ( + + + + + + + + + + + ) + })} + +
WeekTotal WagesBudgetVar vs Budget% BudgetNet Sales% Net SalesPY Net Sales
+ {r.label} + {r.partial && current} + {fmtMoney(r.wages)}{r.budget != null ? fmtMoney(r.budget) : '—'} + {vari != null && ( + 0 ? 'variance-over' : 'variance-under'}> + {vari > 0 ? '+' : ''}{fmtMoney(vari)} + + )} + + {pctB != null ? {pctB.toFixed(1)}% : '—'} + {r.sales > 0 ? fmtMoney(r.sales) : '—'}{pctS != null ? `${pctS.toFixed(1)}%` : '—'}{r.pySales > 0 ? fmtMoney(r.pySales) : '—'}
+
+ + )} +
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..af97677 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -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>({}) + const [depts, setDepts] = useState([]) + 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(null) + + useEffect(() => { + Promise.all([getSettings(), getSyncStatus()]) + .then(([settRes, statusRes]) => { + const map: Record = {} + 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
Loading…
+ + return ( +
+
+

Settings

+ +
+ + {error &&
{error}
} + + {/* Forecasting API */} +
+
Net Sales — Forecasting API
+
+
+ + handleChange('forecasting_url', e.target.value)} + placeholder="http://10.10.10.113:3080" + /> +
+
+ + handleChange('forecasting_api_key', e.target.value)} + placeholder="fk_…" + /> +
+
+
+ + {/* On-costs toggle */} +
+
Cost Display
+ +

+ When enabled, all wage figures include Workforce-estimated employer contributions. Final payroll is in Sage. +

+
+ + {/* Department filter */} +
+
+
Department Filter
+ +
+ + {depts.length === 0 ? ( +

+ Click "Fetch from Workforce" to load departments. All will be enabled by default. +

+ ) : ( + <> +
+ + +
+
+ {depts.map(d => ( + + ))} +
+ + )} +

+ Unticked departments are excluded from all reports and sync. Save Settings to apply. +

+
+ + {/* Sync */} +
+
Data Sync
+
+ + + {backfillProg && ( + + )} +
+ + {backfillProg && ( +
+
+ Fetching {backfillProg.current}… + {backfillProg.processed} / {backfillProg.total} days +
+
+
+
+
+ )} + +
+
Last sync: {fmtDate(syncStatus?.sync_last_at ?? null)}
+
Last backfill: {fmtDate(syncStatus?.backfill_last_at ?? null)}
+
+

+ 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. +

+
+
+ ) +} diff --git a/frontend/src/pages/Weekly.tsx b/frontend/src/pages/Weekly.tsx new file mode 100644 index 0000000..2a5bf49 --- /dev/null +++ b/frontend/src/pages/Weekly.tsx @@ -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(() => startOfWeek(new Date())) + const [depts, setDepts] = useState([]) + const [netSales, setNetSales] = useState(0) + const [pySales, setPySales] = useState(0) + const [budget, setBudget] = useState(null) + const [showOncosts, setShowOncosts] = useState(true) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+

Weekly Wages

+
+ +
+
+ +
+ + {weekLabel} + +
+ + {/* Summary cards */} +
+
+
Total Wages
+
{fmtMoney(totalWages)}
+
{showOncosts ? 'incl. on-costs' : 'base cost'}
+
+
+
Pro-rata Budget
+
{budget != null ? fmtMoney(budget) : '—'}
+
proportion of monthly
+
+
+
% vs Budget
+
+ {pctBudget != null + ? {pctBudget.toFixed(1)}% + : '—'} +
+
+
+
Net Sales
+
{fmtMoney(netSales)}
+ {pySales > 0 &&
PY {fmtMoney(pySales)}
} +
+
+
% of Net Sales
+
{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}
+
+
+ + {loading &&
Loading…
} + {error &&
{error}
} + + {!loading && !error && ( +
+ + + + + + + + + + + + + {deptTotals.map(dep => { + const depPct = budget != null && budget > 0 ? (dep.cost / budget) * 100 : null + const depSPct = netSales > 0 ? (dep.cost / netSales) * 100 : null + return ( + + + + + + + + + ) + })} + + + + + + + + + +
DepartmentWagesBudget (pro-rata)% BudgetNet Sales% Net Sales
{dep.department_name}{fmtMoney(dep.cost)} + {depPct != null + ? {depPct.toFixed(1)}% + : '—'} + {fmtMoney(netSales)}{depSPct != null ? `${depSPct.toFixed(1)}%` : '—'}
Total{fmtMoney(totalWages)}{budget != null ? fmtMoney(budget) : '—'} + {pctBudget != null + ? {pctBudget.toFixed(1)}% + : '—'} + {fmtMoney(netSales)}{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}
+ {showOncosts && ( +

Includes estimated employer on-costs. Final payroll figures are in Sage.

+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..3840162 --- /dev/null +++ b/frontend/src/types.ts @@ -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 +} + +export interface DeptScheduled { + department_id: string + department_name: string + days: Record +} + +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 +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..79a2287 --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..ac822ba --- /dev/null +++ b/frontend/vite.config.ts @@ -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}'], + }, + }), + ], +}) diff --git a/seed-app.js b/seed-app.js new file mode 100644 index 0000000..7bd313c --- /dev/null +++ b/seed-app.js @@ -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()