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
7
backend/Dockerfile
Normal file
7
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install --omit=dev
|
||||
COPY src ./src
|
||||
EXPOSE 3001
|
||||
CMD ["node", "src/index.js"]
|
||||
16
backend/package.json
Normal file
16
backend/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "hnf-wages-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^9.4.0",
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"fastify": "^4.28.1",
|
||||
"jose": "^5.9.6",
|
||||
"pg": "^8.13.1"
|
||||
}
|
||||
}
|
||||
56
backend/src/auth.js
Normal file
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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
||||
- SETTINGS_URL=${SETTINGS_URL}
|
||||
- SETTINGS_SECRET=${SETTINGS_SECRET}
|
||||
- APP_SLUG=wages
|
||||
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
|
||||
interval: 10s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_HOTEL_NAME: ${VITE_HOTEL_NAME}
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3080}:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
ARG VITE_HOTEL_NAME
|
||||
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html/wages
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#065f46" />
|
||||
<title>Wage Costs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
55
frontend/nginx.conf
Normal file
55
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
server {
|
||||
location = /wages/manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /wages/sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /wages/registerSW.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location /wages/api/auth/ {
|
||||
proxy_pass http://10.10.10.101:3001/api/auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /wages/api/ {
|
||||
proxy_pass http://backend:3001/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location /wages/health {
|
||||
proxy_pass http://backend:3001/health;
|
||||
}
|
||||
|
||||
location ~* /wages/.*\.(js|css|png|ico|svg|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /wages/ {
|
||||
add_header Cache-Control "no-cache" always;
|
||||
try_files $uri $uri/ /wages/index.html;
|
||||
}
|
||||
|
||||
location = / {
|
||||
return 301 /wages/;
|
||||
}
|
||||
}
|
||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "hnf-wages-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^3.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
}
|
||||
}
|
||||
77
frontend/src/App.tsx
Normal file
77
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useState } from 'react'
|
||||
import { DollarSign, CalendarDays, TrendingUp, BarChart3, Wallet, Settings } from 'lucide-react'
|
||||
import AuthGate, { useAuth } from './components/AuthGate'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { useVersionCheck } from './hooks/useVersionCheck'
|
||||
import { can } from './types'
|
||||
import Weekly from './pages/Weekly'
|
||||
import Monthly from './pages/Monthly'
|
||||
import Rolling12Weeks from './pages/Rolling12Weeks'
|
||||
import Rolling12Months from './pages/Rolling12Months'
|
||||
import Budgets from './pages/Budgets'
|
||||
import SettingsPage from './pages/Settings'
|
||||
|
||||
type Page = 'weekly' | 'monthly' | 'rolling-weeks' | 'rolling-months' | 'budgets' | 'settings'
|
||||
|
||||
const NAV: { id: Page; label: string; icon: React.ElementType; cap?: string }[] = [
|
||||
{ id: 'weekly', label: 'Weekly', icon: CalendarDays },
|
||||
{ id: 'monthly', label: 'Monthly', icon: TrendingUp },
|
||||
{ id: 'rolling-weeks', label: '12 Weeks', icon: BarChart3 },
|
||||
{ id: 'rolling-months', label: '12 Months', icon: BarChart3 },
|
||||
{ id: 'budgets', label: 'Budgets', icon: Wallet, cap: 'budget' },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings, cap: 'settings' },
|
||||
]
|
||||
|
||||
function Shell() {
|
||||
const { user } = useAuth()
|
||||
const [page, setPage] = useState<Page>('weekly')
|
||||
|
||||
const hotelName = import.meta.env.VITE_HOTEL_NAME || 'Hotel'
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<nav className="sidebar">
|
||||
<div className="sidebar-logo">
|
||||
<DollarSign size={16} strokeWidth={1.75} />
|
||||
Wage Costs
|
||||
</div>
|
||||
<div className="sidebar-nav">
|
||||
{NAV.filter(n => !n.cap || can(user, n.cap)).map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`nav-item${page === n.id ? ' active' : ''}`}
|
||||
onClick={() => setPage(n.id)}
|
||||
>
|
||||
<n.icon size={16} strokeWidth={1.75} />
|
||||
{n.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', fontSize: '11px', color: 'rgba(255,255,255,0.3)' }}>
|
||||
{hotelName}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="content">
|
||||
{page === 'weekly' && <Weekly />}
|
||||
{page === 'monthly' && <Monthly />}
|
||||
{page === 'rolling-weeks' && <Rolling12Weeks />}
|
||||
{page === 'rolling-months' && <Rolling12Months />}
|
||||
{page === 'budgets' && <Budgets />}
|
||||
{page === 'settings' && <SettingsPage />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const updateAvailable = useVersionCheck('/wages/health')
|
||||
return (
|
||||
<>
|
||||
<AuthGate>
|
||||
<Shell />
|
||||
</AuthGate>
|
||||
<UpdateBanner visible={updateAvailable} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
71
frontend/src/api.ts
Normal file
71
frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { DeptActuals, DeptScheduled, NetSalesDay, WageBudget, AppSetting } from './types'
|
||||
|
||||
const BASE = '/wages/api'
|
||||
|
||||
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||||
...opts,
|
||||
})
|
||||
if (res.status === 401) {
|
||||
;(window.top ?? window).location.href = '/login'
|
||||
throw new Error('Unauthenticated')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error((err as { error?: string }).error || `Request failed: ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function getActuals(from: string, to: string): Promise<{ departments: DeptActuals[]; show_oncosts: boolean }> {
|
||||
return request(`/actuals?from=${from}&to=${to}`)
|
||||
}
|
||||
|
||||
export function getScheduled(from: string, to: string): Promise<{ departments: DeptScheduled[] }> {
|
||||
return request(`/scheduled?from=${from}&to=${to}`)
|
||||
}
|
||||
|
||||
export function getNetSales(from: string, to: string): Promise<{ days: NetSalesDay[] }> {
|
||||
return request(`/net-sales?from=${from}&to=${to}`)
|
||||
}
|
||||
|
||||
export function getBudgets(): Promise<{ budgets: WageBudget[] }> {
|
||||
return request('/budgets')
|
||||
}
|
||||
|
||||
export function saveBudget(month: string, budget_amount: number): Promise<{ ok: boolean }> {
|
||||
return request(`/budgets/${month}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ budget_amount }),
|
||||
})
|
||||
}
|
||||
|
||||
export function triggerSync(): Promise<{ ok: boolean; actual_rows: number; scheduled_rows: number }> {
|
||||
return request('/sync', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getSyncStatus(): Promise<{ sync_last_at: string | null; backfill_last_at: string | null; backfill_running: boolean }> {
|
||||
return request('/sync/status')
|
||||
}
|
||||
|
||||
export function cancelBackfill(): Promise<{ ok: boolean }> {
|
||||
return request('/sync/backfill/cancel', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getDepartments(): Promise<{ departments: { id: string; name: string }[] }> {
|
||||
return request('/departments')
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<{ settings: AppSetting[] }> {
|
||||
return request('/settings')
|
||||
}
|
||||
|
||||
export function saveSettings(settings: { key: string; value: string }[]): Promise<{ ok: boolean }> {
|
||||
return request('/settings', { method: 'PUT', body: JSON.stringify({ settings }) })
|
||||
}
|
||||
|
||||
export function downloadExport(view: string, from: string, to: string): void {
|
||||
window.open(`${BASE}/export?view=${view}&from=${from}&to=${to}`, '_blank')
|
||||
}
|
||||
41
frontend/src/components/AuthGate.tsx
Normal file
41
frontend/src/components/AuthGate.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useEffect, useState, createContext, useContext } from 'react'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthCtx { user: User }
|
||||
const Ctx = createContext<AuthCtx | null>(null)
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/wages/api/auth/verify?app=wages', { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (!r.ok) {
|
||||
;(window.top ?? window).location.href = '/login'
|
||||
return null
|
||||
}
|
||||
return r.json()
|
||||
})
|
||||
.then(data => { if (data) setUser(data) })
|
||||
.catch(() => { ;(window.top ?? window).location.href = '/login' })
|
||||
}, [])
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '100vh', color: '#6b7280',
|
||||
}}>
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <Ctx.Provider value={{ user }}>{children}</Ctx.Provider>
|
||||
}
|
||||
28
frontend/src/components/UpdateBanner.tsx
Normal file
28
frontend/src/components/UpdateBanner.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
export function UpdateBanner({ visible }: { visible: boolean }) {
|
||||
if (!visible) return null
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 9999,
|
||||
background: 'var(--sidebar)', color: 'var(--text-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
gap: '12px', padding: '10px 16px', fontSize: '14px',
|
||||
boxShadow: '0 -2px 8px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
<span>A new version is available.</span>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
background: 'var(--accent)', color: 'var(--sidebar)',
|
||||
border: 'none', borderRadius: '4px', padding: '6px 14px',
|
||||
fontWeight: 600, cursor: 'pointer', fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={1.75} />
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
frontend/src/hooks/useVersionCheck.ts
Normal file
31
frontend/src/hooks/useVersionCheck.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
const POLL_MS = 2 * 60 * 1000
|
||||
|
||||
export function useVersionCheck(healthUrl: string) {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let seenVersion: string | null = null
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const res = await fetch(healthUrl, { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const v: string | undefined = data.version
|
||||
if (!v) return
|
||||
if (seenVersion === null) { seenVersion = v }
|
||||
else if (v !== seenVersion) { setUpdateAvailable(true) }
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
check()
|
||||
const interval = setInterval(check, POLL_MS)
|
||||
const onVisible = () => { if (document.visibilityState === 'visible') check() }
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => { clearInterval(interval); document.removeEventListener('visibilitychange', onVisible) }
|
||||
}, [healthUrl])
|
||||
|
||||
return updateAvailable
|
||||
}
|
||||
325
frontend/src/index.css
Normal file
325
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
/* Stack design system tokens */
|
||||
:root {
|
||||
--navy: #1a1a2e;
|
||||
--gold: #c9a84c;
|
||||
--body-bg: #f4f5f7;
|
||||
--card-bg: #ffffff;
|
||||
--text-primary: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--radius: 8px;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.12);
|
||||
|
||||
/* App theme — dark green for finance */
|
||||
--app-primary: #065f46;
|
||||
--app-primary-light: #059669;
|
||||
--app-primary-dark: #064e3b;
|
||||
|
||||
/* UpdateBanner aliases */
|
||||
--sidebar: var(--navy);
|
||||
--text-light: #ffffff;
|
||||
--accent: var(--gold);
|
||||
|
||||
/* Layout */
|
||||
--sidebar-w: 200px;
|
||||
--topbar-h: 56px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--body-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
/* ── App shell ──────────────────────────────────────────────────── */
|
||||
.app-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ────────────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
background: var(--navy);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
padding: 20px 16px 12px;
|
||||
color: var(--gold);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 8px 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
color: rgba(255,255,255,0.65);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(201,168,76,0.12);
|
||||
color: var(--gold);
|
||||
border-left-color: var(--gold);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Content area ───────────────────────────────────────────────── */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ── Cards ──────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
/* ── Summary cards ──────────────────────────────────────────────── */
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.summary-card .label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.summary-card .value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.summary-card .sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Tables ─────────────────────────────────────────────────────── */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table th.right,
|
||||
.data-table td.right { text-align: right; }
|
||||
|
||||
.data-table td {
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
.data-table tr.total-row td {
|
||||
font-weight: 700;
|
||||
border-top: 2px solid var(--border);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-table tr:hover:not(.total-row) td {
|
||||
background: var(--body-bg);
|
||||
}
|
||||
|
||||
/* ── Traffic lights ─────────────────────────────────────────────── */
|
||||
.pct-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pct-green { background: #d1fae5; color: #065f46; }
|
||||
.pct-amber { background: #fef3c7; color: #92400e; }
|
||||
.pct-red { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.variance-over { color: #dc2626; font-weight: 600; }
|
||||
.variance-under { color: #059669; font-weight: 600; }
|
||||
|
||||
/* ── Partial / forecast ─────────────────────────────────────────── */
|
||||
.partial-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: var(--body-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn:hover:not(:disabled) { opacity: 0.88; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--app-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--body-bg);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-gold {
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
/* ── Page header ────────────────────────────────────────────────── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Week / month selector ──────────────────────────────────────── */
|
||||
.period-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.period-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
min-width: 150px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Form elements ──────────────────────────────────────────────── */
|
||||
input[type="number"], input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
input[type="number"]:focus,
|
||||
input[type="text"]:focus {
|
||||
outline: 2px solid var(--app-primary-light);
|
||||
border-color: var(--app-primary-light);
|
||||
}
|
||||
|
||||
/* ── Footnote ───────────────────────────────────────────────────── */
|
||||
.footnote {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Loading/error states ───────────────────────────────────────── */
|
||||
.state-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
174
frontend/src/pages/Budgets.tsx
Normal file
174
frontend/src/pages/Budgets.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { getBudgets, saveBudget } from '../api'
|
||||
import type { WageBudget } from '../types'
|
||||
|
||||
function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() }
|
||||
|
||||
function getMonthRange(): { year: number; month: number }[] {
|
||||
const today = new Date()
|
||||
const months: { year: number; month: number }[] = []
|
||||
for (let i = -3; i <= 3; i++) {
|
||||
let m = today.getMonth() + 1 + i
|
||||
let y = today.getFullYear()
|
||||
while (m <= 0) { m += 12; y-- }
|
||||
while (m > 12) { m -= 12; y++ }
|
||||
months.push({ year: y, month: m })
|
||||
}
|
||||
return months
|
||||
}
|
||||
|
||||
const MONTH_LABELS = ['January','February','March','April','May','June','July','August','September','October','November','December']
|
||||
|
||||
export default function Budgets() {
|
||||
const [budgets, setBudgets] = useState<Record<string, number>>({})
|
||||
const [editing, setEditing] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||
|
||||
const months = getMonthRange()
|
||||
|
||||
useEffect(() => {
|
||||
getBudgets()
|
||||
.then(res => {
|
||||
const map: Record<string, number> = {}
|
||||
for (const b of res.budgets as WageBudget[]) {
|
||||
const key = b.month.slice(0, 7) // YYYY-MM
|
||||
map[key] = b.budget_amount
|
||||
}
|
||||
setBudgets(map)
|
||||
})
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const monthKey = (y: number, m: number) => `${y}-${String(m).padStart(2, '0')}`
|
||||
|
||||
const handleFocus = (key: string) => {
|
||||
const current = budgets[key]
|
||||
setEditing(e => ({ ...e, [key]: current != null ? String(current) : '' }))
|
||||
}
|
||||
|
||||
const handleChange = (key: string, val: string) => {
|
||||
setEditing(e => ({ ...e, [key]: val }))
|
||||
}
|
||||
|
||||
const handleSave = async (key: string) => {
|
||||
const raw = editing[key]?.trim()
|
||||
if (raw === '') {
|
||||
setEditing(e => { const n = { ...e }; delete n[key]; return n })
|
||||
return
|
||||
}
|
||||
const amount = parseFloat(raw)
|
||||
if (isNaN(amount)) {
|
||||
setEditing(e => { const n = { ...e }; delete n[key]; return n })
|
||||
return
|
||||
}
|
||||
setSaving(s => ({ ...s, [key]: true }))
|
||||
try {
|
||||
await saveBudget(key, amount)
|
||||
setBudgets(b => ({ ...b, [key]: amount }))
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Save failed')
|
||||
} finally {
|
||||
setSaving(s => { const n = { ...s }; delete n[key]; return n })
|
||||
setEditing(e => { const n = { ...e }; delete n[key]; return n })
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (key: string, e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSave(key)
|
||||
// Tab focus to next
|
||||
const keys = months.map(m => monthKey(m.year, m.month))
|
||||
const idx = keys.indexOf(key)
|
||||
if (idx >= 0 && idx < keys.length - 1) {
|
||||
setTimeout(() => inputRefs.current[keys[idx + 1]]?.focus(), 50)
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditing(e2 => { const n = { ...e2 }; delete n[key]; return n })
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="state-center">Loading…</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Wage Budgets</h1>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: '#dc2626', marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
<p style={{ color: 'var(--text-muted)', marginTop: 0, fontSize: 13 }}>
|
||||
Enter the total monthly wages budget (FD figure). Click a cell to edit, press Enter or Tab to save.
|
||||
</p>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th className="right">Budget</th>
|
||||
<th className="right" style={{ color: 'var(--text-muted)', fontWeight: 400 }}>Weekly equiv.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{months.map(({ year, month }) => {
|
||||
const key = monthKey(year, month)
|
||||
const current = budgets[key]
|
||||
const isEditing = key in editing
|
||||
const dim = daysInMonth(year, month)
|
||||
const weekly = current != null ? (current * 7 / dim) : null
|
||||
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td style={{ fontWeight: 500 }}>
|
||||
{MONTH_LABELS[month - 1]} {year}
|
||||
</td>
|
||||
<td className="right" style={{ width: 160 }}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
ref={el => { inputRefs.current[key] = el }}
|
||||
value={editing[key]}
|
||||
onChange={e => handleChange(key, e.target.value)}
|
||||
onBlur={() => handleSave(key)}
|
||||
onKeyDown={e => handleKeyDown(key, e)}
|
||||
style={{ width: 140, textAlign: 'right' }}
|
||||
autoFocus
|
||||
min={0}
|
||||
step={100}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => handleFocus(key)}
|
||||
style={{
|
||||
cursor: 'text',
|
||||
display: 'inline-block',
|
||||
minWidth: 100,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px dashed var(--border)',
|
||||
textAlign: 'right',
|
||||
color: current != null ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{saving[key] ? 'Saving…' : current != null ? `£${current.toLocaleString('en-GB')}` : 'Click to set'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="right" style={{ color: 'var(--text-muted)' }}>
|
||||
{weekly != null ? `£${Math.round(weekly).toLocaleString('en-GB')}` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
274
frontend/src/pages/Monthly.tsx
Normal file
274
frontend/src/pages/Monthly.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Download } from 'lucide-react'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, Cell,
|
||||
} from 'recharts'
|
||||
import { getActuals, getScheduled, getNetSales, getBudgets, downloadExport } from '../api'
|
||||
import type { DeptActuals, WageBudget } from '../types'
|
||||
|
||||
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
|
||||
function addDays(d: Date, n: number): Date { const r = new Date(d); r.setDate(r.getDate() + n); return r }
|
||||
function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() }
|
||||
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
|
||||
function pctClass(pct: number): string { return pct <= 100 ? 'pct-green' : pct <= 110 ? 'pct-amber' : 'pct-red' }
|
||||
|
||||
const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
|
||||
|
||||
export default function Monthly() {
|
||||
const today = new Date()
|
||||
const [year, setYear] = useState(today.getFullYear())
|
||||
const [month, setMonth] = useState(today.getMonth() + 1) // 1-based
|
||||
|
||||
const [depts, setDepts] = useState<DeptActuals[]>([])
|
||||
const [scheduled, setScheduled] = useState<Record<string, Record<string, number>>>({}) // dept_id → date → cost
|
||||
const [netSales, setNetSales] = useState(0)
|
||||
const [budget, setBudget] = useState<number | null>(null)
|
||||
const [showOncosts, setShowOncosts] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const dim = daysInMonth(year, month)
|
||||
const monthStr = `${year}-${String(month).padStart(2, '0')}`
|
||||
const fromStr = `${monthStr}-01`
|
||||
const toStr = `${monthStr}-${String(dim).padStart(2, '0')}`
|
||||
const todayStr = fmt(today)
|
||||
const isCurrentMonth = year === today.getFullYear() && month === today.getMonth() + 1
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const [actRes, schRes, salesRes, budRes] = await Promise.all([
|
||||
getActuals(fromStr, toStr),
|
||||
getScheduled(todayStr, toStr),
|
||||
getNetSales(fromStr, todayStr),
|
||||
getBudgets(),
|
||||
])
|
||||
setDepts(actRes.departments)
|
||||
setShowOncosts(actRes.show_oncosts)
|
||||
|
||||
// Build scheduled map
|
||||
const schMap: Record<string, Record<string, number>> = {}
|
||||
for (const dep of schRes.departments) {
|
||||
schMap[dep.department_id] = {}
|
||||
for (const [date, val] of Object.entries(dep.days)) {
|
||||
schMap[dep.department_id][date] = val.cost
|
||||
}
|
||||
}
|
||||
setScheduled(schMap)
|
||||
|
||||
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
|
||||
|
||||
const bRow = budRes.budgets.find(b => b.month === `${fromStr}`)
|
||||
setBudget(bRow ? bRow.budget_amount : null)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [fromStr, toStr, todayStr])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const prev = () => { if (month === 1) { setYear(y => y - 1); setMonth(12) } else { setMonth(m => m - 1) } }
|
||||
const next = () => { if (month === 12) { setYear(y => y + 1); setMonth(1) } else { setMonth(m => m + 1) } }
|
||||
|
||||
// Build dept summary: actual MTD + forecast EOM
|
||||
const deptSummary = depts.map((dep, idx) => {
|
||||
const actualMTD = Object.entries(dep.days)
|
||||
.filter(([d]) => d <= todayStr)
|
||||
.reduce((s, [, v]) => s + v.cost, 0)
|
||||
|
||||
// Forecast remaining days
|
||||
let forecastRem = 0
|
||||
for (let day = 1; day <= dim; day++) {
|
||||
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
|
||||
if (dateStr <= todayStr) continue
|
||||
|
||||
// Priority: rota → prior week same DoW actual
|
||||
const rotaCost = schMap(dep.department_id, dateStr)
|
||||
if (rotaCost != null) {
|
||||
forecastRem += rotaCost
|
||||
continue
|
||||
}
|
||||
const priorDate = addDays(new Date(dateStr + 'T00:00:00'), -7)
|
||||
const priorStr = fmt(priorDate)
|
||||
const priorCost = dep.days[priorStr]?.cost
|
||||
if (priorCost != null) forecastRem += priorCost
|
||||
}
|
||||
|
||||
return {
|
||||
department_id: dep.department_id,
|
||||
department_name: dep.department_name,
|
||||
actual_mtd: actualMTD,
|
||||
forecast_eom: actualMTD + forecastRem,
|
||||
color: DEPT_COLORS[idx % DEPT_COLORS.length],
|
||||
}
|
||||
}).sort((a, b) => b.forecast_eom - a.forecast_eom)
|
||||
|
||||
function schMap(deptId: string, date: string): number | null {
|
||||
return scheduled[deptId]?.[date] ?? null
|
||||
}
|
||||
|
||||
const totalActual = deptSummary.reduce((s, d) => s + d.actual_mtd, 0)
|
||||
const totalForecast = deptSummary.reduce((s, d) => s + d.forecast_eom, 0)
|
||||
const pctBudget = budget != null && budget > 0 ? (totalForecast / budget) * 100 : null
|
||||
const pctSales = netSales > 0 ? (totalActual / netSales) * 100 : null
|
||||
const variance = budget != null ? totalForecast - budget : null
|
||||
|
||||
// Build chart data: group by week
|
||||
const weeks: { label: string; actual: number; forecast: number; isPast: boolean }[] = []
|
||||
for (let w = 0; w * 7 < dim; w++) {
|
||||
const wStart = w * 7 + 1
|
||||
const wEnd = Math.min(wStart + 6, dim)
|
||||
const wEndDate = new Date(`${monthStr}-${String(wEnd).padStart(2, '0')}T00:00:00`)
|
||||
const isPast = wEndDate < today
|
||||
|
||||
let actual = 0, forecast = 0
|
||||
for (let day = wStart; day <= wEnd; day++) {
|
||||
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
|
||||
const isActual = dateStr <= todayStr
|
||||
const total = deptSummary.reduce((s, dep) => {
|
||||
if (isActual) return s + (depts.find(d => d.department_id === dep.department_id)?.days[dateStr]?.cost ?? 0)
|
||||
const rota = schMap(dep.department_id, dateStr)
|
||||
if (rota != null) return s + rota
|
||||
const prior = depts.find(d => d.department_id === dep.department_id)?.days[fmt(addDays(new Date(dateStr + 'T00:00:00'), -7))]?.cost ?? 0
|
||||
return s + prior
|
||||
}, 0)
|
||||
if (isActual) actual += total; else forecast += total
|
||||
}
|
||||
|
||||
weeks.push({ label: `W${w + 1}`, actual, forecast, isPast })
|
||||
}
|
||||
|
||||
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Monthly View</h1>
|
||||
<button className="btn btn-secondary" onClick={() => downloadExport('monthly', fromStr, toStr)}>
|
||||
<Download size={14} strokeWidth={1.75} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="period-nav" style={{ marginBottom: 20 }}>
|
||||
<button className="btn btn-secondary" onClick={prev}><ChevronLeft size={16} strokeWidth={1.75} /></button>
|
||||
<span className="period-label">{monthLabel}</span>
|
||||
<button className="btn btn-secondary" onClick={next} disabled={isCurrentMonth}><ChevronRight size={16} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
|
||||
<div className="summary-grid">
|
||||
<div className="summary-card">
|
||||
<div className="label">Actual MTD</div>
|
||||
<div className="value">{fmtMoney(totalActual)}</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">Forecast EOM</div>
|
||||
<div className="value">{fmtMoney(totalForecast)}</div>
|
||||
<div className="sub">rota + prior-week actual</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">Monthly Budget</div>
|
||||
<div className="value">{budget != null ? fmtMoney(budget) : '—'}</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">% Budget (Forecast)</div>
|
||||
<div className="value">
|
||||
{pctBudget != null
|
||||
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
|
||||
: '—'}
|
||||
</div>
|
||||
{variance != null && (
|
||||
<div className={`sub ${variance > 0 ? 'variance-over' : 'variance-under'}`}>
|
||||
{variance > 0 ? `+${fmtMoney(variance)} over` : `${fmtMoney(Math.abs(variance))} under`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">Net Sales MTD</div>
|
||||
<div className="value">{fmtMoney(netSales)}</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">% Net Sales</div>
|
||||
<div className="value">{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div className="state-center">Loading…</div>}
|
||||
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* Stacked bar chart */}
|
||||
<div className="card">
|
||||
<div className="card-title">Weekly Breakdown</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={weeks} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
||||
<YAxis tickFormatter={v => `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} />
|
||||
<Tooltip formatter={(v: number) => fmtMoney(v)} />
|
||||
<Legend />
|
||||
{deptSummary.map(dep => (
|
||||
<Bar key={dep.department_id} dataKey={dep.department_name} stackId="a" fill={dep.color}>
|
||||
{weeks.map((w, i) => (
|
||||
<Cell key={i} fill={dep.color} opacity={w.isPast ? 1 : 0.4} />
|
||||
))}
|
||||
</Bar>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Dept breakdown table */}
|
||||
<div className="card">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Department</th>
|
||||
<th className="right">Actual MTD</th>
|
||||
<th className="right">Forecast → EOM</th>
|
||||
<th className="right">Budget</th>
|
||||
<th className="right">% Budget</th>
|
||||
<th className="right">Variance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deptSummary.map(dep => {
|
||||
const dp = budget != null && budget > 0 ? (dep.forecast_eom / budget) * 100 : null
|
||||
const dv = budget != null ? dep.forecast_eom - budget : null
|
||||
return (
|
||||
<tr key={dep.department_id}>
|
||||
<td><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: dep.color, marginRight: 8 }} />{dep.department_name}</td>
|
||||
<td className="right">{fmtMoney(dep.actual_mtd)}</td>
|
||||
<td className="right">{fmtMoney(dep.forecast_eom)}</td>
|
||||
<td className="right">—</td>
|
||||
<td className="right">
|
||||
{dp != null ? <span className={`pct-badge ${pctClass(dp)}`}>{dp.toFixed(1)}%</span> : '—'}
|
||||
</td>
|
||||
<td className="right">
|
||||
{dv != null && <span className={dv > 0 ? 'variance-over' : 'variance-under'}>{dv > 0 ? '+' : ''}{fmtMoney(dv)}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
<tr className="total-row">
|
||||
<td>Total</td>
|
||||
<td className="right">{fmtMoney(totalActual)}</td>
|
||||
<td className="right">{fmtMoney(totalForecast)}</td>
|
||||
<td className="right">{budget != null ? fmtMoney(budget) : '—'}</td>
|
||||
<td className="right">
|
||||
{pctBudget != null ? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span> : '—'}
|
||||
</td>
|
||||
<td className="right">
|
||||
{variance != null && <span className={variance > 0 ? 'variance-over' : 'variance-under'}>{variance > 0 ? '+' : ''}{fmtMoney(variance)}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{showOncosts && <p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
198
frontend/src/pages/Rolling12Months.tsx
Normal file
198
frontend/src/pages/Rolling12Months.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Download } from 'lucide-react'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
|
||||
import type { WageBudget } from '../types'
|
||||
|
||||
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
|
||||
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
|
||||
function pctClass(p: number): string { return p <= 100 ? 'pct-green' : p <= 110 ? 'pct-amber' : 'pct-red' }
|
||||
function daysInMonth(y: number, m: number): number { return new Date(y, m, 0).getDate() }
|
||||
|
||||
const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
|
||||
const MONTH_LABELS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
||||
|
||||
export default function Rolling12Months() {
|
||||
const [rows, setRows] = useState<{ label: string; wages: number; budget: number | null; sales: number; pySales: number; partial: boolean }[]>([])
|
||||
const [deptCols, setDeptCols] = useState<{ id: string; name: string; color: string }[]>([])
|
||||
const [chartData, setChartData] = useState<Record<string, number | string>[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const today = new Date()
|
||||
const curY = today.getFullYear()
|
||||
const curM = today.getMonth() + 1 // 1-based
|
||||
|
||||
// 13 months: 12 complete + current partial
|
||||
const months: { year: number; month: number }[] = []
|
||||
for (let i = 12; i >= 0; i--) {
|
||||
let m = curM - i
|
||||
let y = curY
|
||||
while (m <= 0) { m += 12; y-- }
|
||||
months.push({ year: y, month: m })
|
||||
}
|
||||
|
||||
const rangeFrom = `${months[0].year}-${String(months[0].month).padStart(2, '0')}-01`
|
||||
const lastMon = months[months.length - 1]
|
||||
const lastDim = daysInMonth(lastMon.year, lastMon.month)
|
||||
const rangeTo = `${lastMon.year}-${String(lastMon.month).padStart(2, '0')}-${String(lastDim).padStart(2, '0')}`
|
||||
|
||||
const [actRes, salesRes, budRes] = await Promise.all([
|
||||
getActuals(rangeFrom, rangeTo),
|
||||
getNetSales(rangeFrom, rangeTo),
|
||||
getBudgets(),
|
||||
])
|
||||
|
||||
const salesByDate: Record<string, { sales: number; py: number }> = {}
|
||||
for (const d of salesRes.days) salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales }
|
||||
|
||||
const budgetMap: Record<string, number> = {}
|
||||
for (const b of budRes.budgets as WageBudget[]) budgetMap[b.month] = b.budget_amount
|
||||
|
||||
const depts = actRes.departments
|
||||
const cols = depts.map((d, i) => ({ id: d.department_id, name: d.department_name, color: DEPT_COLORS[i % DEPT_COLORS.length] }))
|
||||
setDeptCols(cols)
|
||||
|
||||
const tableRows: typeof rows = []
|
||||
const cData: Record<string, number | string>[] = []
|
||||
const todayStr = fmt(today)
|
||||
|
||||
for (const { year, month } of months) {
|
||||
const dim = daysInMonth(year, month)
|
||||
const monthStr = `${year}-${String(month).padStart(2, '0')}`
|
||||
const monthFrom = `${monthStr}-01`
|
||||
const monthTo = `${monthStr}-${String(dim).padStart(2, '0')}`
|
||||
const isCurrentMonth = year === curY && month === curM
|
||||
const effectiveTo = isCurrentMonth ? todayStr : monthTo
|
||||
|
||||
let wages = 0
|
||||
const deptWages: Record<string, number> = {}
|
||||
for (const dep of depts) {
|
||||
let dCost = 0
|
||||
for (const [date, val] of Object.entries(dep.days)) {
|
||||
if (date >= monthFrom && date <= effectiveTo) dCost += val.cost
|
||||
}
|
||||
wages += dCost
|
||||
deptWages[dep.department_id] = dCost
|
||||
}
|
||||
|
||||
let sales = 0, pySales = 0
|
||||
for (const [date, val] of Object.entries(salesByDate)) {
|
||||
if (date >= monthFrom && date <= effectiveTo) { sales += val.sales; pySales += val.py }
|
||||
}
|
||||
|
||||
const monKey = `${monthFrom}`
|
||||
const budget = budgetMap[monKey] ?? null
|
||||
const label = `${MONTH_LABELS[month - 1]}-${String(year).slice(2)}`
|
||||
|
||||
tableRows.push({ label, wages, budget, sales, pySales, partial: isCurrentMonth })
|
||||
|
||||
const cdRow: Record<string, number | string> = { label }
|
||||
for (const dep of depts) cdRow[dep.department_name] = deptWages[dep.department_id] ?? 0
|
||||
cData.push(cdRow)
|
||||
}
|
||||
|
||||
setRows(tableRows)
|
||||
setChartData(cData)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const today = new Date()
|
||||
const curY = today.getFullYear()
|
||||
const curM = today.getMonth() + 1
|
||||
let fromY = curY, fromM = curM - 12
|
||||
while (fromM <= 0) { fromM += 12; fromY-- }
|
||||
const rangeFrom = `${fromY}-${String(fromM).padStart(2, '0')}-01`
|
||||
const rangeTo = `${curY}-${String(curM).padStart(2, '0')}-${String(daysInMonth(curY, curM)).padStart(2, '0')}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Rolling 12 Months</h1>
|
||||
<button className="btn btn-secondary" onClick={() => downloadExport('rolling-months', rangeFrom, rangeTo)}>
|
||||
<Download size={14} strokeWidth={1.75} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="state-center">Loading…</div>}
|
||||
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-title">Wages by Department (monthly)</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 10 }} />
|
||||
<YAxis tickFormatter={v => `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} />
|
||||
<Tooltip formatter={(v: number) => fmtMoney(v)} />
|
||||
<Legend />
|
||||
{deptCols.map(dep => (
|
||||
<Bar key={dep.id} dataKey={dep.name} stackId="a" fill={dep.color} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th className="right">Total Wages</th>
|
||||
<th className="right">Budget</th>
|
||||
<th className="right">Var vs Budget</th>
|
||||
<th className="right">% Budget</th>
|
||||
<th className="right">Net Sales</th>
|
||||
<th className="right">% Net Sales</th>
|
||||
<th className="right">PY Net Sales</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => {
|
||||
const pctB = r.budget != null && r.budget > 0 ? (r.wages / r.budget) * 100 : null
|
||||
const pctS = r.sales > 0 ? (r.wages / r.sales) * 100 : null
|
||||
const vari = r.budget != null ? r.wages - r.budget : null
|
||||
return (
|
||||
<tr key={i} style={r.partial ? { opacity: 0.7 } : {}}>
|
||||
<td>
|
||||
{r.label}
|
||||
{r.partial && <span className="partial-badge">current</span>}
|
||||
</td>
|
||||
<td className="right">{fmtMoney(r.wages)}</td>
|
||||
<td className="right">{r.budget != null ? fmtMoney(r.budget) : '—'}</td>
|
||||
<td className="right">
|
||||
{vari != null && (
|
||||
<span className={vari > 0 ? 'variance-over' : 'variance-under'}>
|
||||
{vari > 0 ? '+' : ''}{fmtMoney(vari)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="right">
|
||||
{pctB != null ? <span className={`pct-badge ${pctClass(pctB)}`}>{pctB.toFixed(1)}%</span> : '—'}
|
||||
</td>
|
||||
<td className="right">{r.sales > 0 ? fmtMoney(r.sales) : '—'}</td>
|
||||
<td className="right">{pctS != null ? `${pctS.toFixed(1)}%` : '—'}</td>
|
||||
<td className="right">{r.pySales > 0 ? fmtMoney(r.pySales) : '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
frontend/src/pages/Rolling12Weeks.tsx
Normal file
205
frontend/src/pages/Rolling12Weeks.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Download } from 'lucide-react'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer,
|
||||
LineChart, Line, CartesianGrid, ReferenceLine,
|
||||
} from 'recharts'
|
||||
import { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
|
||||
import type { WageBudget } from '../types'
|
||||
|
||||
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
|
||||
function addDays(d: Date, n: number): Date { const r = new Date(d); r.setDate(r.getDate() + n); return r }
|
||||
function startOfWeek(d: Date): Date {
|
||||
const day = d.getDay()
|
||||
const r = new Date(d)
|
||||
r.setDate(d.getDate() + (day === 0 ? -6 : 1 - day))
|
||||
r.setHours(0, 0, 0, 0)
|
||||
return r
|
||||
}
|
||||
function daysInMonth(d: Date): number { return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate() }
|
||||
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
|
||||
function pctClass(p: number): string { return p <= 100 ? 'pct-green' : p <= 110 ? 'pct-amber' : 'pct-red' }
|
||||
|
||||
const DEPT_COLORS = ['#065f46','#059669','#0891b2','#7c3aed','#c2410c','#b45309','#0f766e','#4338ca','#be185d','#15803d']
|
||||
|
||||
export default function Rolling12Weeks() {
|
||||
const [rows, setRows] = useState<{ label: string; wages: number; budget: number | null; sales: number; pySales: number; partial: boolean }[]>([])
|
||||
const [deptCols, setDeptCols] = useState<{ id: string; name: string; color: string }[]>([])
|
||||
const [chartData, setChartData] = useState<Record<string, number | string>[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const today = new Date()
|
||||
const thisMonday = startOfWeek(today)
|
||||
|
||||
// 13 weeks back from Monday = 12 complete weeks + current (partial)
|
||||
const rangeStart = addDays(thisMonday, -12 * 7)
|
||||
const rangeEnd = addDays(thisMonday, 6) // end of current week
|
||||
|
||||
const [actRes, salesRes, budRes] = await Promise.all([
|
||||
getActuals(fmt(rangeStart), fmt(rangeEnd)),
|
||||
getNetSales(fmt(rangeStart), fmt(rangeEnd)),
|
||||
getBudgets(),
|
||||
])
|
||||
|
||||
const salesByDate: Record<string, { sales: number; py: number }> = {}
|
||||
for (const d of salesRes.days) {
|
||||
salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales }
|
||||
}
|
||||
|
||||
const budgetMap: Record<string, number> = {}
|
||||
for (const b of budRes.budgets as WageBudget[]) {
|
||||
budgetMap[b.month] = b.budget_amount
|
||||
}
|
||||
|
||||
// Dept lookup
|
||||
const depts = actRes.departments
|
||||
const cols = depts.map((d, i) => ({ id: d.department_id, name: d.department_name, color: DEPT_COLORS[i % DEPT_COLORS.length] }))
|
||||
setDeptCols(cols)
|
||||
|
||||
const tableRows: typeof rows = []
|
||||
const cData: Record<string, number | string>[] = []
|
||||
|
||||
for (let w = 0; w < 13; w++) {
|
||||
const wStart = addDays(rangeStart, w * 7)
|
||||
const wEnd = addDays(wStart, 6)
|
||||
const isPartial = wStart.toDateString() === thisMonday.toDateString()
|
||||
const effectiveEnd = isPartial ? today : wEnd
|
||||
|
||||
let wages = 0
|
||||
const deptWages: Record<string, number> = {}
|
||||
|
||||
for (const dep of depts) {
|
||||
let dCost = 0
|
||||
for (let i = 0; i <= 6; i++) {
|
||||
const d = addDays(wStart, i)
|
||||
if (d > effectiveEnd) break
|
||||
const ds = fmt(d)
|
||||
dCost += dep.days[ds]?.cost ?? 0
|
||||
}
|
||||
wages += dCost
|
||||
deptWages[dep.department_id] = dCost
|
||||
}
|
||||
|
||||
let sales = 0, pySales = 0
|
||||
for (let i = 0; i <= 6; i++) {
|
||||
const ds = fmt(addDays(wStart, i))
|
||||
sales += salesByDate[ds]?.sales ?? 0
|
||||
pySales += salesByDate[ds]?.py ?? 0
|
||||
}
|
||||
|
||||
// Pro-rata budget
|
||||
const monStr = `${wStart.getFullYear()}-${String(wStart.getMonth() + 1).padStart(2, '0')}-01`
|
||||
const monthBudget = budgetMap[monStr]
|
||||
const budget = monthBudget != null
|
||||
? monthBudget * (isPartial ? (Math.ceil((today.getTime() - wStart.getTime()) / 86_400_000) + 1) : 7) / daysInMonth(wStart)
|
||||
: null
|
||||
|
||||
const label = `w/e ${wEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}`
|
||||
tableRows.push({ label, wages, budget, sales, pySales, partial: isPartial })
|
||||
|
||||
const cdRow: Record<string, number | string> = { label }
|
||||
for (const dep of depts) cdRow[dep.department_name] = deptWages[dep.department_id] ?? 0
|
||||
cdRow._wages = wages
|
||||
cdRow._budget = budget ?? 0
|
||||
cData.push(cdRow)
|
||||
}
|
||||
|
||||
setRows(tableRows)
|
||||
setChartData(cData)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const today = new Date()
|
||||
const rangeStart = addDays(startOfWeek(today), -12 * 7)
|
||||
const rangeEnd = addDays(startOfWeek(today), 6)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Rolling 12 Weeks</h1>
|
||||
<button className="btn btn-secondary" onClick={() => downloadExport('rolling-weeks', fmt(rangeStart), fmt(rangeEnd))}>
|
||||
<Download size={14} strokeWidth={1.75} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="state-center">Loading…</div>}
|
||||
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-title">Wages by Department (weekly)</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 16, left: 16, bottom: 0 }}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 10 }} />
|
||||
<YAxis tickFormatter={v => `£${Math.round(v / 1000)}k`} tick={{ fontSize: 11 }} width={55} />
|
||||
<Tooltip formatter={(v: number) => fmtMoney(v)} />
|
||||
<Legend />
|
||||
{deptCols.map(dep => (
|
||||
<Bar key={dep.id} dataKey={dep.name} stackId="a" fill={dep.color} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Week</th>
|
||||
<th className="right">Total Wages</th>
|
||||
<th className="right">Budget</th>
|
||||
<th className="right">Var vs Budget</th>
|
||||
<th className="right">% Budget</th>
|
||||
<th className="right">Net Sales</th>
|
||||
<th className="right">% Net Sales</th>
|
||||
<th className="right">PY Net Sales</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => {
|
||||
const pctB = r.budget != null && r.budget > 0 ? (r.wages / r.budget) * 100 : null
|
||||
const pctS = r.sales > 0 ? (r.wages / r.sales) * 100 : null
|
||||
const vari = r.budget != null ? r.wages - r.budget : null
|
||||
return (
|
||||
<tr key={i} style={r.partial ? { opacity: 0.7 } : {}}>
|
||||
<td>
|
||||
{r.label}
|
||||
{r.partial && <span className="partial-badge">current</span>}
|
||||
</td>
|
||||
<td className="right">{fmtMoney(r.wages)}</td>
|
||||
<td className="right">{r.budget != null ? fmtMoney(r.budget) : '—'}</td>
|
||||
<td className="right">
|
||||
{vari != null && (
|
||||
<span className={vari > 0 ? 'variance-over' : 'variance-under'}>
|
||||
{vari > 0 ? '+' : ''}{fmtMoney(vari)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="right">
|
||||
{pctB != null ? <span className={`pct-badge ${pctClass(pctB)}`}>{pctB.toFixed(1)}%</span> : '—'}
|
||||
</td>
|
||||
<td className="right">{r.sales > 0 ? fmtMoney(r.sales) : '—'}</td>
|
||||
<td className="right">{pctS != null ? `${pctS.toFixed(1)}%` : '—'}</td>
|
||||
<td className="right">{r.pySales > 0 ? fmtMoney(r.pySales) : '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
275
frontend/src/pages/Settings.tsx
Normal file
275
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { RefreshCw, Download, X, CheckSquare, Square } from 'lucide-react'
|
||||
import { getSettings, saveSettings, getDepartments, triggerSync, getSyncStatus, cancelBackfill } from '../api'
|
||||
import type { AppSetting, Department } from '../types'
|
||||
|
||||
function fmtDate(iso: string | null): string {
|
||||
if (!iso) return 'Never'
|
||||
return new Date(iso).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<Record<string, string>>({})
|
||||
const [depts, setDepts] = useState<Department[]>([])
|
||||
const [syncStatus, setSyncStatus] = useState<{ sync_last_at: string | null; backfill_last_at: string | null; backfill_running: boolean } | null>(null)
|
||||
const [backfillProg, setBackfillProg] = useState<{ processed: number; total: number; current: string } | null>(null)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fetchingDepts, setFetchingDepts] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getSettings(), getSyncStatus()])
|
||||
.then(([settRes, statusRes]) => {
|
||||
const map: Record<string, string> = {}
|
||||
for (const s of settRes.settings as AppSetting[]) map[s.key] = s.value
|
||||
setSettings(map)
|
||||
setSyncStatus(statusRes)
|
||||
|
||||
// Parse saved departments if present
|
||||
if (map.departments) {
|
||||
try { setDepts(JSON.parse(map.departments)) } catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleChange = (key: string, value: string) => {
|
||||
setSettings(s => ({ ...s, [key]: value }))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const deptsJson = depts.length > 0 ? JSON.stringify(depts) : ''
|
||||
await saveSettings([
|
||||
{ key: 'forecasting_url', value: settings.forecasting_url ?? '' },
|
||||
{ key: 'forecasting_api_key', value: settings.forecasting_api_key ?? '' },
|
||||
{ key: 'show_oncosts', value: settings.show_oncosts ?? 'true' },
|
||||
{ key: 'departments', value: deptsJson },
|
||||
])
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Save failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFetchDepts = async () => {
|
||||
setFetchingDepts(true); setError(null)
|
||||
try {
|
||||
const res = await getDepartments()
|
||||
// Merge with existing enabled state
|
||||
const existing = Object.fromEntries(depts.map(d => [d.id, d.enabled]))
|
||||
const merged = res.departments.map(d => ({
|
||||
...d,
|
||||
enabled: existing[d.id] ?? true,
|
||||
}))
|
||||
setDepts(merged)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to fetch departments')
|
||||
} finally {
|
||||
setFetchingDepts(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDept = (id: string) => {
|
||||
setDepts(ds => ds.map(d => d.id === id ? { ...d, enabled: d.enabled === false } : d))
|
||||
}
|
||||
|
||||
const toggleAll = (enabled: boolean) => {
|
||||
setDepts(ds => ds.map(d => ({ ...d, enabled })))
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true); setError(null)
|
||||
try {
|
||||
const res = await triggerSync()
|
||||
setSyncStatus(s => s ? { ...s, sync_last_at: new Date().toISOString() } : s)
|
||||
alert(`Sync complete — ${res.actual_rows} actual rows, ${res.scheduled_rows} scheduled rows`)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Sync failed')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackfill = async () => {
|
||||
if (!confirm('Start deep backfill? This will fetch ~13 months of Workforce data and may take a few minutes.')) return
|
||||
setBackfillProg({ processed: 0, total: 1, current: '…' })
|
||||
setError(null)
|
||||
|
||||
const es = new EventSource('/wages/api/sync/backfill', { withCredentials: true })
|
||||
|
||||
const doPost = () => {
|
||||
fetch('/wages/api/sync/backfill', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).catch(() => {})
|
||||
}
|
||||
doPost()
|
||||
|
||||
es.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
if (data.done) {
|
||||
es.close()
|
||||
setBackfillProg(null)
|
||||
setSyncStatus(s => s ? { ...s, backfill_last_at: new Date().toISOString() } : s)
|
||||
} else if (data.error) {
|
||||
es.close()
|
||||
setError(data.error)
|
||||
setBackfillProg(null)
|
||||
} else {
|
||||
setBackfillProg(data)
|
||||
}
|
||||
}
|
||||
es.onerror = () => { es.close(); setBackfillProg(null) }
|
||||
}
|
||||
|
||||
const handleCancelBackfill = async () => {
|
||||
await cancelBackfill()
|
||||
setBackfillProg(null)
|
||||
}
|
||||
|
||||
if (loading) return <div className="state-center">Loading…</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Settings</h1>
|
||||
<button className="btn btn-primary" onClick={handleSave}>
|
||||
{saved ? 'Saved!' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: '#dc2626', marginBottom: 16, padding: '8px 12px', background: '#fee2e2', borderRadius: 6 }}>{error}</div>}
|
||||
|
||||
{/* Forecasting API */}
|
||||
<div className="card">
|
||||
<div className="card-title">Net Sales — Forecasting API</div>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 4, color: 'var(--text-muted)' }}>
|
||||
Forecasting URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.forecasting_url ?? ''}
|
||||
onChange={e => handleChange('forecasting_url', e.target.value)}
|
||||
placeholder="http://10.10.10.113:3080"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 4, color: 'var(--text-muted)' }}>
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.forecasting_api_key ?? ''}
|
||||
onChange={e => handleChange('forecasting_api_key', e.target.value)}
|
||||
placeholder="fk_…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* On-costs toggle */}
|
||||
<div className="card">
|
||||
<div className="card-title">Cost Display</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 13 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.show_oncosts !== 'false'}
|
||||
onChange={e => handleChange('show_oncosts', e.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
Include estimated employer on-costs (NI) in displayed figures
|
||||
</label>
|
||||
<p style={{ margin: '8px 0 0', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
When enabled, all wage figures include Workforce-estimated employer contributions. Final payroll is in Sage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Department filter */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<div className="card-title" style={{ margin: 0 }}>Department Filter</div>
|
||||
<button className="btn btn-secondary" onClick={handleFetchDepts} disabled={fetchingDepts}>
|
||||
<RefreshCw size={14} strokeWidth={1.75} />
|
||||
{fetchingDepts ? 'Fetching…' : 'Fetch from Workforce'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{depts.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
Click "Fetch from Workforce" to load departments. All will be enabled by default.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<button className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => toggleAll(true)}>Select all</button>
|
||||
<button className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => toggleAll(false)}>Deselect all</button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 6 }}>
|
||||
{depts.map(d => (
|
||||
<label key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13, padding: '6px 8px', borderRadius: 6, background: d.enabled !== false ? 'var(--body-bg)' : 'transparent' }}>
|
||||
<span onClick={() => toggleDept(d.id)}>
|
||||
{d.enabled !== false
|
||||
? <CheckSquare size={16} strokeWidth={1.75} color="var(--app-primary)" />
|
||||
: <Square size={16} strokeWidth={1.75} color="var(--text-muted)" />}
|
||||
</span>
|
||||
{d.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p style={{ marginTop: 10, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Unticked departments are excluded from all reports and sync. Save Settings to apply.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sync */}
|
||||
<div className="card">
|
||||
<div className="card-title">Data Sync</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<button className="btn btn-primary" onClick={handleSync} disabled={syncing || backfillProg != null}>
|
||||
<RefreshCw size={14} strokeWidth={1.75} />
|
||||
{syncing ? 'Syncing…' : 'Sync Now (35 days)'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleBackfill} disabled={syncing || backfillProg != null}>
|
||||
<Download size={14} strokeWidth={1.75} />
|
||||
Deep Backfill (13 months)
|
||||
</button>
|
||||
{backfillProg && (
|
||||
<button className="btn btn-secondary" onClick={handleCancelBackfill}>
|
||||
<X size={14} strokeWidth={1.75} /> Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{backfillProg && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||||
<span>Fetching {backfillProg.current}…</span>
|
||||
<span>{backfillProg.processed} / {backfillProg.total} days</span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: 'var(--border)', borderRadius: 3 }}>
|
||||
<div style={{ height: '100%', width: `${Math.min(100, (backfillProg.processed / backfillProg.total) * 100)}%`, background: 'var(--app-primary)', borderRadius: 3, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', display: 'grid', gap: 4 }}>
|
||||
<div>Last sync: <strong>{fmtDate(syncStatus?.sync_last_at ?? null)}</strong></div>
|
||||
<div>Last backfill: <strong>{fmtDate(syncStatus?.backfill_last_at ?? null)}</strong></div>
|
||||
</div>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Sync Now pulls the last 35 days of timesheets + next 14 days of schedules. Auto-sync runs every hour.
|
||||
Deep Backfill fetches the full 13-month history at 250ms per week to avoid rate limits.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
206
frontend/src/pages/Weekly.tsx
Normal file
206
frontend/src/pages/Weekly.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Download } from 'lucide-react'
|
||||
import { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
|
||||
import type { DeptActuals, WageBudget } from '../types'
|
||||
|
||||
function startOfWeek(d: Date): Date {
|
||||
const day = d.getDay()
|
||||
const diff = (day === 0 ? -6 : 1 - day) // Mon = start
|
||||
const r = new Date(d)
|
||||
r.setDate(d.getDate() + diff)
|
||||
r.setHours(0, 0, 0, 0)
|
||||
return r
|
||||
}
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d)
|
||||
r.setDate(r.getDate() + n)
|
||||
return r
|
||||
}
|
||||
|
||||
function fmt(d: Date): string { return d.toISOString().slice(0, 10) }
|
||||
function fmtMoney(n: number): string { return `£${n.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}` }
|
||||
function daysInMonth(date: Date): number { return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate() }
|
||||
|
||||
function pctClass(pct: number | null): string {
|
||||
if (pct == null) return ''
|
||||
if (pct <= 100) return 'pct-green'
|
||||
if (pct <= 110) return 'pct-amber'
|
||||
return 'pct-red'
|
||||
}
|
||||
|
||||
export default function Weekly() {
|
||||
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date()))
|
||||
const [depts, setDepts] = useState<DeptActuals[]>([])
|
||||
const [netSales, setNetSales] = useState(0)
|
||||
const [pySales, setPySales] = useState(0)
|
||||
const [budget, setBudget] = useState<number | null>(null)
|
||||
const [showOncosts, setShowOncosts] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const weekEnd = addDays(weekStart, 6)
|
||||
const fromStr = fmt(weekStart)
|
||||
const toStr = fmt(weekEnd)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const [actRes, salesRes, budgetRes] = await Promise.all([
|
||||
getActuals(fromStr, toStr),
|
||||
getNetSales(fromStr, toStr),
|
||||
getBudgets(),
|
||||
])
|
||||
setDepts(actRes.departments)
|
||||
setShowOncosts(actRes.show_oncosts)
|
||||
|
||||
const totalSales = salesRes.days.reduce((s, d) => s + d.net_sales, 0)
|
||||
const totalPY = salesRes.days.reduce((s, d) => s + d.py_sales, 0)
|
||||
setNetSales(totalSales)
|
||||
setPySales(totalPY)
|
||||
|
||||
// Find budget for the month of weekStart
|
||||
const monthKey = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-01`
|
||||
const bRow = (budgetRes.budgets as WageBudget[]).find(b => b.month === monthKey)
|
||||
if (bRow) {
|
||||
const dim = daysInMonth(weekStart)
|
||||
// Pro-rata: days in the selected week ÷ days in month
|
||||
const today = new Date()
|
||||
let weekDays = 7
|
||||
if (weekStart <= today && today <= weekEnd) {
|
||||
weekDays = Math.ceil((today.getTime() - weekStart.getTime()) / 86_400_000) + 1
|
||||
}
|
||||
setBudget(bRow.budget_amount * (weekDays / dim))
|
||||
} else {
|
||||
setBudget(null)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [fromStr, toStr, weekStart, weekEnd])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const prev = () => setWeekStart(d => addDays(d, -7))
|
||||
const next = () => setWeekStart(d => addDays(d, 7))
|
||||
const isCurrentWeek = fmt(startOfWeek(new Date())) === fmt(weekStart)
|
||||
|
||||
// Totals
|
||||
const deptTotals = depts.map(dep => {
|
||||
const cost = Object.values(dep.days).reduce((s, d) => s + d.cost, 0)
|
||||
return { department_name: dep.department_name, cost }
|
||||
}).sort((a, b) => b.cost - a.cost)
|
||||
|
||||
const totalWages = deptTotals.reduce((s, d) => s + d.cost, 0)
|
||||
const pctBudget = budget != null && budget > 0 ? (totalWages / budget) * 100 : null
|
||||
const pctSales = netSales > 0 ? (totalWages / netSales) * 100 : null
|
||||
|
||||
const weekLabel = `${weekStart.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} – ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Weekly Wages</h1>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button className="btn btn-secondary" onClick={() => downloadExport('weekly', fromStr, toStr)}>
|
||||
<Download size={14} strokeWidth={1.75} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="period-nav" style={{ marginBottom: 20 }}>
|
||||
<button className="btn btn-secondary" onClick={prev}><ChevronLeft size={16} strokeWidth={1.75} /></button>
|
||||
<span className="period-label">{weekLabel}</span>
|
||||
<button className="btn btn-secondary" onClick={next} disabled={isCurrentWeek}><ChevronRight size={16} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="summary-grid">
|
||||
<div className="summary-card">
|
||||
<div className="label">Total Wages</div>
|
||||
<div className="value">{fmtMoney(totalWages)}</div>
|
||||
<div className="sub">{showOncosts ? 'incl. on-costs' : 'base cost'}</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">Pro-rata Budget</div>
|
||||
<div className="value">{budget != null ? fmtMoney(budget) : '—'}</div>
|
||||
<div className="sub">proportion of monthly</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">% vs Budget</div>
|
||||
<div className="value">
|
||||
{pctBudget != null
|
||||
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">Net Sales</div>
|
||||
<div className="value">{fmtMoney(netSales)}</div>
|
||||
{pySales > 0 && <div className="sub">PY {fmtMoney(pySales)}</div>}
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<div className="label">% of Net Sales</div>
|
||||
<div className="value">{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div className="state-center">Loading…</div>}
|
||||
{error && <div className="state-center" style={{ color: '#dc2626' }}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="card">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Department</th>
|
||||
<th className="right">Wages</th>
|
||||
<th className="right">Budget (pro-rata)</th>
|
||||
<th className="right">% Budget</th>
|
||||
<th className="right">Net Sales</th>
|
||||
<th className="right">% Net Sales</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deptTotals.map(dep => {
|
||||
const depPct = budget != null && budget > 0 ? (dep.cost / budget) * 100 : null
|
||||
const depSPct = netSales > 0 ? (dep.cost / netSales) * 100 : null
|
||||
return (
|
||||
<tr key={dep.department_name}>
|
||||
<td>{dep.department_name}</td>
|
||||
<td className="right">{fmtMoney(dep.cost)}</td>
|
||||
<td className="right">—</td>
|
||||
<td className="right">
|
||||
{depPct != null
|
||||
? <span className={`pct-badge ${pctClass(depPct)}`}>{depPct.toFixed(1)}%</span>
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="right">{fmtMoney(netSales)}</td>
|
||||
<td className="right">{depSPct != null ? `${depSPct.toFixed(1)}%` : '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
<tr className="total-row">
|
||||
<td>Total</td>
|
||||
<td className="right">{fmtMoney(totalWages)}</td>
|
||||
<td className="right">{budget != null ? fmtMoney(budget) : '—'}</td>
|
||||
<td className="right">
|
||||
{pctBudget != null
|
||||
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{pctBudget.toFixed(1)}%</span>
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="right">{fmtMoney(netSales)}</td>
|
||||
<td className="right">{pctSales != null ? `${pctSales.toFixed(1)}%` : '—'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{showOncosts && (
|
||||
<p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
frontend/src/types.ts
Normal file
49
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export interface User {
|
||||
email: string
|
||||
name: string
|
||||
is_admin: boolean
|
||||
caps: string[]
|
||||
}
|
||||
|
||||
export function can(user: User, cap: string): boolean {
|
||||
return user.is_admin || user.caps.includes(cap)
|
||||
}
|
||||
|
||||
export interface DeptActuals {
|
||||
department_id: string
|
||||
department_name: string
|
||||
days: Record<string, { base_cost: number; total_cost: number; cost: number; shift_count: number }>
|
||||
}
|
||||
|
||||
export interface DeptScheduled {
|
||||
department_id: string
|
||||
department_name: string
|
||||
days: Record<string, { cost: number; shift_count: number }>
|
||||
}
|
||||
|
||||
export interface NetSalesDay {
|
||||
date: string
|
||||
net_sales: number
|
||||
py_sales: number
|
||||
accom: number
|
||||
dry: number
|
||||
wet: number
|
||||
is_past: boolean
|
||||
}
|
||||
|
||||
export interface WageBudget {
|
||||
month: string // 'YYYY-MM-DD' (first of month)
|
||||
budget_amount: number
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
id: string
|
||||
name: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface AppSetting {
|
||||
key: string
|
||||
value: string
|
||||
updated_at: string
|
||||
}
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
31
frontend/vite.config.ts
Normal file
31
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/wages/',
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'Wage Costs',
|
||||
short_name: 'Wages',
|
||||
start_url: '/wages/',
|
||||
scope: '/wages/',
|
||||
display: 'standalone',
|
||||
theme_color: '#065f46',
|
||||
background_color: '#065f46',
|
||||
icons: [
|
||||
{ src: '/wages/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/wages/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/wages/index.html',
|
||||
navigateFallbackDenylist: [/\/api\//],
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
45
seed-app.js
Normal file
45
seed-app.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Run against the auth DB to register the wages app and its capabilities.
|
||||
// Usage: DATABASE_URL=postgresql://... node seed-app.js
|
||||
|
||||
import pg from 'pg'
|
||||
const { Pool } = pg
|
||||
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
|
||||
VALUES (
|
||||
'wages',
|
||||
'Wage Costs',
|
||||
'Live wage cost reporting — weekly, monthly, and rolling history vs budget and net sales',
|
||||
'/wages',
|
||||
'DollarSign',
|
||||
'#065f46',
|
||||
'Finance',
|
||||
'10.10.10.124',
|
||||
3080
|
||||
)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
base_path = EXCLUDED.base_path,
|
||||
icon = EXCLUDED.icon,
|
||||
theme_color = EXCLUDED.theme_color,
|
||||
category = EXCLUDED.category,
|
||||
internal_host = EXCLUDED.internal_host,
|
||||
internal_port = EXCLUDED.internal_port;
|
||||
|
||||
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
|
||||
SELECT a.id, c.slug, c.name, c.description, c.sort_order
|
||||
FROM apps a, (VALUES
|
||||
('view', 'View Reports', 'View all wage cost reports (weekly, monthly, rolling)', 1),
|
||||
('budget', 'Edit Budgets', 'Set monthly wage budget targets', 2),
|
||||
('sync', 'Manual Sync', 'Trigger a Workforce API data sync or backfill', 3),
|
||||
('settings', 'Settings', 'App settings, API configuration and department filter', 4)
|
||||
) AS c(slug, name, description, sort_order)
|
||||
WHERE a.slug = 'wages'
|
||||
ON CONFLICT (app_id, slug) DO NOTHING;
|
||||
`)
|
||||
|
||||
console.log('wages seeded')
|
||||
await pool.end()
|
||||
Loading…
Add table
Add a link
Reference in a new issue