Initial commit — utilities app (meter readings, tariffs, cost tracking)

Fastify + pg backend, React/TS/Vite frontend. Categories (electric,
gas, oil, water), meters with sub-metering rollup, tariffs with
time-of-use rate windows, standing charges, Climate Change Levy and
VAT, manual reading entry, consumption/cost reports, period cost
estimates, and an API-key-gated /api/internal/* surface for the
reports app's Directors Report.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-26 17:33:42 +00:00
commit 4fc5230d79
44 changed files with 10249 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
.env
uploads/
*.log

8
backend/Dockerfile Normal file
View file

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

18
backend/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "hnf-utilities-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/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"pg": "^8.13.1"
}
}

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

@ -0,0 +1,57 @@
import { jwtVerify } from 'jose'
import { isOnsite } from './ip-check.js'
const APP_SLUG = process.env.APP_SLUG || 'utilities'
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 {
// Legacy token — grant all non-settings caps until re-login
caps = ['readings', 'meters', 'tariffs', 'reports', 'estimates']
}
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}` })
}
}
}

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

@ -0,0 +1,142 @@
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 config (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- electric / gas / oil / water extensible, seeded below
CREATE TABLE IF NOT EXISTS meter_categories (
id SERIAL PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
unit_label TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT 'Zap',
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
estimate_trailing_days INT, -- per-category override of the global trailing-avg window
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- parent_meter_id enables sub-metering rollups (children sum vs parent reading)
CREATE TABLE IF NOT EXISTS meters (
id SERIAL PRIMARY KEY,
category_id INT NOT NULL REFERENCES meter_categories(id),
parent_meter_id INT REFERENCES meters(id) ON DELETE SET NULL,
name TEXT NOT NULL,
location TEXT,
serial_number TEXT,
image_path TEXT,
install_date DATE,
active BOOLEAN NOT NULL DEFAULT TRUE,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS meters_category_idx ON meters (category_id);
CREATE INDEX IF NOT EXISTS meters_parent_idx ON meters (parent_meter_id);
-- fallback_split_pct e.g. {"day":60,"night":40} used to split one cumulative
-- reading across TOU rate windows when there's no device data (v1: manual only)
CREATE TABLE IF NOT EXISTS tariffs (
id SERIAL PRIMARY KEY,
category_id INT NOT NULL REFERENCES meter_categories(id),
name TEXT NOT NULL,
supplier TEXT,
effective_from DATE NOT NULL,
effective_to DATE,
standing_charge_pence_per_day NUMERIC(10,4) NOT NULL DEFAULT 0,
ccl_rate_pence_per_unit NUMERIC(10,4),
ccl_exempt BOOLEAN NOT NULL DEFAULT FALSE,
vat_rate_pct NUMERIC(5,2) NOT NULL DEFAULT 20,
is_time_of_use BOOLEAN NOT NULL DEFAULT FALSE,
fallback_split_pct JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS tariffs_category_idx ON tariffs (category_id);
-- non-TOU tariff = single window covering all days/hours
CREATE TABLE IF NOT EXISTS tariff_rate_windows (
id SERIAL PRIMARY KEY,
tariff_id INT NOT NULL REFERENCES tariffs(id) ON DELETE CASCADE,
label TEXT NOT NULL,
start_time TIME,
end_time TIME,
days_of_week INT[],
unit_rate_pence_per_unit NUMERIC(10,4) NOT NULL,
sort_order INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS rate_windows_tariff_idx ON tariff_rate_windows (tariff_id);
-- history of which tariff applied to a meter over time; one open row
-- (effective_to IS NULL) per meter at a time
CREATE TABLE IF NOT EXISTS meter_tariffs (
id SERIAL PRIMARY KEY,
meter_id INT NOT NULL REFERENCES meters(id) ON DELETE CASCADE,
tariff_id INT NOT NULL REFERENCES tariffs(id),
effective_from DATE NOT NULL,
effective_to DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS meter_tariffs_meter_idx ON meter_tariffs (meter_id);
-- one cumulative manual reading per meter (source='manual' in v1). Phase 2
-- ESPHome integration adds source='device' + interval_start/interval_end
-- deliberately left out of this migration until that integration is built.
CREATE TABLE IF NOT EXISTS readings (
id SERIAL PRIMARY KEY,
meter_id INT NOT NULL REFERENCES meters(id) ON DELETE CASCADE,
reading_value NUMERIC(14,3) NOT NULL,
reading_date DATE NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source TEXT NOT NULL DEFAULT 'manual',
recorded_by TEXT,
photo_path TEXT,
notes TEXT
);
CREATE INDEX IF NOT EXISTS readings_meter_date_idx ON readings (meter_id, reading_date DESC);
`)
await seedDefaults()
}
async function seedDefaults() {
const categories = [
{ key: 'electric', name: 'Electric', unit_label: 'kWh', icon: 'Zap', sort: 1 },
{ key: 'gas', name: 'Gas', unit_label: 'kWh', icon: 'Flame', sort: 2 },
{ key: 'oil', name: 'Oil', unit_label: 'L', icon: 'Droplet', sort: 3 },
{ key: 'water', name: 'Water', unit_label: 'm3', icon: 'Waves', sort: 4 },
]
for (const c of categories) {
await pool.query(
`INSERT INTO meter_categories (key, name, unit_label, icon, sort_order)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (key) DO NOTHING`,
[c.key, c.name, c.unit_label, c.icon, c.sort]
)
}
const defaults = {
estimate_trailing_days: 30, // global default trailing-average window (days), 7 | 14 | 30
}
for (const [key, value] of Object.entries(defaults)) {
await pool.query(
`INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
[key, JSON.stringify(value)]
)
}
}
export async function getConfig() {
const { rows } = await pool.query('SELECT key, value FROM config')
return Object.fromEntries(rows.map(r => [r.key, r.value]))
}

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

@ -0,0 +1,53 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import multipart from '@fastify/multipart'
import staticFiles from '@fastify/static'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { initDb } from './db.js'
import { categoryRoutes } from './routes/categories.js'
import { meterRoutes } from './routes/meters.js'
import { tariffRoutes } from './routes/tariffs.js'
import { readingRoutes } from './routes/readings.js'
import { reportRoutes } from './routes/reports.js'
import { estimateRoutes } from './routes/estimates.js'
import { internalRoutes } from './routes/internal.js'
import { settingsRoutes } from './routes/settings.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const UPLOADS_DIR = join(__dirname, '..', 'uploads')
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,
})
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } })
await app.register(staticFiles, {
root: UPLOADS_DIR,
prefix: '/api/uploads/',
decorateReply: false,
})
app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) }))
await app.register(categoryRoutes)
await app.register(meterRoutes, { uploadsDir: UPLOADS_DIR })
await app.register(tariffRoutes)
await app.register(readingRoutes, { uploadsDir: UPLOADS_DIR })
await app.register(reportRoutes)
await app.register(estimateRoutes)
await app.register(internalRoutes)
await app.register(settingsRoutes)
try {
await initDb()
await app.listen({ port: 3001, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}

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

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

View file

@ -0,0 +1,196 @@
// Shared cost-calculation helpers — single source of truth used by
// routes/reports.js, routes/estimates.js and routes/internal.js so the
// Directors' report and the in-app reports/estimates never disagree.
//
// All money values are handled in PENCE internally and only converted to
// pounds at the API boundary (frontend divides by 100 for display).
import { pool } from '../db.js'
export function daysBetween(a, b) {
return Math.round((new Date(b) - new Date(a)) / 86400000)
}
export function daysInclusive(start, end) {
return daysBetween(start, end) + 1
}
// Find the reading on/before `date` (closest before-or-on), and the reading
// on/after `date` (closest on-or-after) — used to bracket a period boundary
// when there isn't a reading taken on the exact day.
async function readingOnOrBefore(meterId, date) {
const { rows } = await pool.query(
`SELECT reading_value, reading_date FROM readings
WHERE meter_id = $1 AND reading_date <= $2
ORDER BY reading_date DESC, id DESC LIMIT 1`,
[meterId, date]
)
return rows[0] || null
}
async function readingOnOrAfter(meterId, date) {
const { rows } = await pool.query(
`SELECT reading_value, reading_date FROM readings
WHERE meter_id = $1 AND reading_date >= $2
ORDER BY reading_date ASC, id ASC LIMIT 1`,
[meterId, date]
)
return rows[0] || null
}
async function latestReading(meterId) {
const { rows } = await pool.query(
`SELECT reading_value, reading_date FROM readings
WHERE meter_id = $1 ORDER BY reading_date DESC, id DESC LIMIT 1`,
[meterId]
)
return rows[0] || null
}
// Consumption for a meter over [periodStart, periodEnd] (inclusive), bracketing
// the boundaries with the nearest available readings — meters get one manual
// cumulative reading, not necessarily one exactly on the period edge.
export async function getPeriodConsumption(meterId, periodStart, periodEnd) {
const first = (await readingOnOrAfter(meterId, periodStart)) || (await readingOnOrBefore(meterId, periodStart))
const last = await readingOnOrBefore(meterId, periodEnd)
if (!first || !last || last.reading_date <= first.reading_date) {
return { consumption: null, first, last, has_data: false }
}
const consumption = Number(last.reading_value) - Number(first.reading_value)
return { consumption, first, last, has_data: true }
}
// Trailing average daily consumption as of the meter's latest reading,
// looking back `windowDays` — used by the estimates page to project the
// remainder of the current period. Mirrors the "average of recent actuals"
// approach the Directors report uses for dry/wet forecasts.
export async function getTrailingDailyRate(meterId, windowDays) {
const latest = await latestReading(meterId)
if (!latest) return { daily_rate: null, latest }
const windowStart = new Date(latest.reading_date)
windowStart.setDate(windowStart.getDate() - windowDays)
const windowStartStr = windowStart.toISOString().slice(0, 10)
const before = await readingOnOrBefore(meterId, windowStartStr)
if (!before || before.reading_date >= latest.reading_date) {
return { daily_rate: null, latest }
}
const days = daysBetween(before.reading_date, latest.reading_date)
if (days <= 0) return { daily_rate: null, latest }
const consumption = Number(latest.reading_value) - Number(before.reading_value)
return { daily_rate: consumption / days, latest, window_start_reading: before }
}
// The tariff in force for a meter on a given date (falls back to the meter's
// currently-open tariff if `date` is in the future / no historical row matches).
export async function getTariffForMeter(meterId, date) {
const { rows } = await pool.query(
`SELECT t.* FROM meter_tariffs mt
JOIN tariffs t ON t.id = mt.tariff_id
WHERE mt.meter_id = $1
AND mt.effective_from <= $2
AND (mt.effective_to IS NULL OR mt.effective_to >= $2)
ORDER BY mt.effective_from DESC LIMIT 1`,
[meterId, date]
)
if (rows.length) return rows[0]
const { rows: open } = await pool.query(
`SELECT t.* FROM meter_tariffs mt
JOIN tariffs t ON t.id = mt.tariff_id
WHERE mt.meter_id = $1 AND mt.effective_to IS NULL
ORDER BY mt.effective_from DESC LIMIT 1`,
[meterId]
)
return open[0] || null
}
export async function getRateWindows(tariffId) {
const { rows } = await pool.query(
'SELECT * FROM tariff_rate_windows WHERE tariff_id = $1 ORDER BY sort_order, id',
[tariffId]
)
return rows
}
// Core layered calc: usage (split by fallback % across TOU windows, or single
// rate) + standing charge + CCL (electric/gas only, skipped if exempt) + VAT.
// Mirrors the usage + fixed costs + levy -> total layering already used by
// the Directors report's dry/wet forecast.
export function computeCost({ tariff, windows, consumption, daysInPeriod }) {
if (!tariff || consumption == null) {
return { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, subtotal_pence: 0, vat_pence: 0, total_pence: 0, split: null }
}
let usageCostPence = 0
let split = null
if (tariff.is_time_of_use && windows.length > 1 && tariff.fallback_split_pct && Object.keys(tariff.fallback_split_pct).length) {
split = {}
for (const [key, pct] of Object.entries(tariff.fallback_split_pct)) {
const win = windows.find(w => w.label.toLowerCase().includes(key.toLowerCase()))
if (!win) continue
const share = consumption * (Number(pct) / 100)
const cost = share * Number(win.unit_rate_pence_per_unit)
split[win.label] = { share, rate: Number(win.unit_rate_pence_per_unit), cost_pence: cost }
usageCostPence += cost
}
} else {
const rate = windows.length ? Number(windows[0].unit_rate_pence_per_unit) : 0
usageCostPence = consumption * rate
}
const standingCostPence = Number(tariff.standing_charge_pence_per_day) * daysInPeriod
const cclCostPence = (!tariff.ccl_exempt && tariff.ccl_rate_pence_per_unit)
? consumption * Number(tariff.ccl_rate_pence_per_unit)
: 0
const subtotalPence = usageCostPence + standingCostPence + cclCostPence
const vatPence = subtotalPence * (Number(tariff.vat_rate_pct) / 100)
const totalPence = subtotalPence + vatPence
return {
usage_cost_pence: usageCostPence,
standing_cost_pence: standingCostPence,
ccl_cost_pence: cclCostPence,
subtotal_pence: subtotalPence,
vat_pence: vatPence,
total_pence: totalPence,
split,
}
}
// Full cost breakdown for one meter over a period — fetches tariff + windows
// + consumption and runs computeCost. `asOfDate` picks which historical
// tariff applies (defaults to periodEnd).
export async function getMeterCostForPeriod(meterId, periodStart, periodEnd, asOfDate) {
const { consumption, first, last, has_data } = await getPeriodConsumption(meterId, periodStart, periodEnd)
const tariff = await getTariffForMeter(meterId, asOfDate || periodEnd)
const windows = tariff ? await getRateWindows(tariff.id) : []
const daysInPeriod = daysInclusive(periodStart, periodEnd)
const cost = computeCost({ tariff, windows, consumption, daysInPeriod })
return { consumption, has_data, first_reading: first, last_reading: last, tariff, windows, days_in_period: daysInPeriod, ...cost }
}
// [periodStart, periodEnd] for a YYYY-MM period string, or the current
// (open) calendar month when period === 'current'.
export function resolvePeriod(period) {
const now = new Date()
let year, month
if (!period || period === 'current') {
year = now.getFullYear()
month = now.getMonth() + 1
} else {
const [y, m] = period.split('-').map(Number)
year = y
month = m
}
const start = `${year}-${String(month).padStart(2, '0')}-01`
const endDate = new Date(year, month, 0) // last day of month
const end = endDate.toISOString().slice(0, 10)
const isCurrent = year === now.getFullYear() && month === now.getMonth() + 1
return { year, month, start, end, isCurrent }
}

View file

@ -0,0 +1,48 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function categoryRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/categories — reference data needed by most pages, no extra cap
app.get('/api/categories', async () => {
const { rows } = await pool.query('SELECT * FROM meter_categories ORDER BY sort_order, name')
return rows
})
app.post('/api/categories', { preHandler: requireCap('meters') }, async (req, reply) => {
const b = req.body || {}
if (!b.key || !b.name || !b.unit_label) return reply.status(400).send({ error: 'key, name and unit_label required' })
try {
const { rows } = await pool.query(
`INSERT INTO meter_categories (key, name, unit_label, icon, sort_order)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[b.key, b.name, b.unit_label, b.icon || 'Zap', b.sort_order || 0]
)
return rows[0]
} catch (err) {
if (err.code === '23505') return reply.status(409).send({ error: 'Category key already exists' })
throw err
}
})
app.patch('/api/categories/:id', { preHandler: requireCap('meters') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM meter_categories WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Category not found' })
const c = existing[0]
const b = req.body || {}
const { rows } = await pool.query(
`UPDATE meter_categories SET name = $1, unit_label = $2, icon = $3, sort_order = $4, active = $5
WHERE id = $6 RETURNING *`,
[
b.name ?? c.name,
b.unit_label ?? c.unit_label,
b.icon ?? c.icon,
b.sort_order ?? c.sort_order,
b.active ?? c.active,
req.params.id,
]
)
return rows[0]
})
}

View file

@ -0,0 +1,98 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, getConfig } from '../db.js'
import {
getPeriodConsumption, getTrailingDailyRate, getTariffForMeter, getRateWindows,
computeCost, resolvePeriod, daysInclusive, daysBetween,
} from '../lib/cost-calc.js'
const VALID_WINDOWS = [7, 14, 30]
async function estimateForMeter(meter, periodStart, periodEnd, windowDays) {
const today = new Date().toISOString().slice(0, 10)
const asOfDate = today < periodEnd ? today : periodEnd
const { daily_rate } = await getTrailingDailyRate(meter.id, windowDays)
const { consumption: actualToDate, has_data } = await getPeriodConsumption(meter.id, periodStart, asOfDate)
const daysInPeriod = daysInclusive(periodStart, periodEnd)
const remainingDays = Math.max(daysBetween(asOfDate, periodEnd), 0)
let projectedConsumption = null
if (daily_rate != null) {
const base = has_data ? actualToDate : 0
projectedConsumption = base + daily_rate * remainingDays
} else if (has_data) {
// No trailing rate available (too few readings) — fall back to actuals only
projectedConsumption = actualToDate
}
const tariff = await getTariffForMeter(meter.id, asOfDate)
const windows = tariff ? await getRateWindows(tariff.id) : []
const cost = computeCost({ tariff, windows, consumption: projectedConsumption, daysInPeriod })
return {
meter_id: meter.id, meter_name: meter.name,
category_id: meter.category_id, category_name: meter.category_name, unit_label: meter.unit_label,
trailing_window_days: windowDays,
daily_rate,
actual_to_date: has_data ? actualToDate : null,
remaining_days: remainingDays,
projected_consumption: projectedConsumption,
...cost,
}
}
export async function estimateRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('estimates'))
// GET /api/estimates?period=current&category_id= — projected cost for the open period
app.get('/api/estimates', async (req) => {
const { start, end, year, month } = resolvePeriod(req.query.period)
const { category_id } = req.query
const config = await getConfig()
const globalDefault = config.estimate_trailing_days || 30
const conditions = ['m.active = TRUE']
const params = []
if (category_id) { params.push(category_id); conditions.push(`m.category_id = $${params.length}`) }
params.push(globalDefault)
const { rows: meters } = await pool.query(
`SELECT m.id, m.name, m.category_id, c.name AS category_name, c.unit_label,
COALESCE(c.estimate_trailing_days, $${params.length}) AS trailing_days
FROM meters m JOIN meter_categories c ON c.id = m.category_id
WHERE ${conditions.join(' AND ')}`,
params
)
const results = []
for (const m of meters) {
const windowDays = VALID_WINDOWS.includes(m.trailing_days) ? m.trailing_days : globalDefault
results.push(await estimateForMeter(m, start, end, windowDays))
}
const totals = results.reduce((acc, r) => ({
total_pence: acc.total_pence + r.total_pence,
usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence,
standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence,
ccl_cost_pence: acc.ccl_cost_pence + r.ccl_cost_pence,
vat_pence: acc.vat_pence + r.vat_pence,
}), { total_pence: 0, usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0 })
return { period: { year, month, start, end }, global_default_window: globalDefault, meters: results, totals }
})
// PATCH /api/estimates/category/:id — set/clear the per-category trailing-window override
app.patch('/api/estimates/category/:id', async (req, reply) => {
const { estimate_trailing_days } = req.body || {}
if (estimate_trailing_days !== null && !VALID_WINDOWS.includes(Number(estimate_trailing_days))) {
return reply.status(400).send({ error: 'estimate_trailing_days must be 7, 14, 30 or null' })
}
const { rows } = await pool.query(
'UPDATE meter_categories SET estimate_trailing_days = $1 WHERE id = $2 RETURNING *',
[estimate_trailing_days, req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Category not found' })
return rows[0]
})
}

View file

@ -0,0 +1,135 @@
// API-key-gated routes for other stack apps (currently `reports`, for the
// Directors' report) — not JWT/cookie auth. Mirrors the forecasting <-> reports
// internal API pattern (X-API-Key header, static key from env).
import { pool } from '../db.js'
import {
getPeriodConsumption, getTariffForMeter, getRateWindows, computeCost,
getTrailingDailyRate, resolvePeriod, daysInclusive, daysBetween,
} from '../lib/cost-calc.js'
async function requireApiKey(req, reply) {
const key = req.headers['x-api-key']
if (!process.env.UTILITIES_API_KEY || key !== process.env.UTILITIES_API_KEY) {
return reply.status(401).send({ error: 'Invalid or missing API key' })
}
}
async function resolveCategoryFilter(category) {
if (!category) return null
const { rows } = await pool.query(
'SELECT id FROM meter_categories WHERE key = $1 OR id::text = $1',
[category]
)
return rows[0]?.id ?? null
}
export async function internalRoutes(app) {
app.addHook('preHandler', requireApiKey)
// GET /api/internal/readings?period=YYYY-MM&category= — raw reading summary
app.get('/api/internal/readings', async (req) => {
const { start, end } = resolvePeriod(req.query.period)
const categoryId = await resolveCategoryFilter(req.query.category)
const conditions = ['m.active = TRUE']
const params = []
if (categoryId) { params.push(categoryId); conditions.push(`m.category_id = $${params.length}`) }
const { rows: meters } = await pool.query(
`SELECT m.id, m.name, m.category_id, c.key AS category_key, c.name AS category_name, c.unit_label
FROM meters m JOIN meter_categories c ON c.id = m.category_id
WHERE ${conditions.join(' AND ')}
ORDER BY c.sort_order, m.name`,
params
)
const readings = []
for (const m of meters) {
const { consumption, first, last, has_data } = await getPeriodConsumption(m.id, start, end)
readings.push({
meter_id: m.id, meter_name: m.name,
category: m.category_key, category_name: m.category_name, unit_label: m.unit_label,
first_reading: first, last_reading: last, consumption, has_data,
})
}
return { period: { start, end }, readings }
})
// GET /api/internal/costs?period=YYYY-MM — cost breakdown by category
app.get('/api/internal/costs', async (req) => {
const { start, end } = resolvePeriod(req.query.period)
const daysInPeriod = daysInclusive(start, end)
const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order')
const breakdown = []
const totals = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0 }
for (const cat of categories) {
const { rows: meters } = await pool.query('SELECT id FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id])
const catTotals = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0 }
for (const m of meters) {
const { consumption, has_data } = await getPeriodConsumption(m.id, start, end)
const tariff = await getTariffForMeter(m.id, end)
const windows = tariff ? await getRateWindows(tariff.id) : []
const cost = computeCost({ tariff, windows, consumption, daysInPeriod })
if (has_data) catTotals.consumption += consumption
catTotals.usage_cost_pence += cost.usage_cost_pence
catTotals.standing_cost_pence += cost.standing_cost_pence
catTotals.ccl_cost_pence += cost.ccl_cost_pence
catTotals.vat_pence += cost.vat_pence
catTotals.total_pence += cost.total_pence
}
breakdown.push({ category: cat.key, category_name: cat.name, unit_label: cat.unit_label, ...catTotals })
for (const k of Object.keys(totals)) totals[k] += catTotals[k]
}
return { period: { start, end, days_in_period: daysInPeriod }, categories: breakdown, totals }
})
// GET /api/internal/estimate?period=current — projected cost for the open period
app.get('/api/internal/estimate', async (req) => {
const { start, end, year, month } = resolvePeriod(req.query.period)
const today = new Date().toISOString().slice(0, 10)
const asOfDate = today < end ? today : end
const daysInPeriod = daysInclusive(start, end)
const remainingDays = Math.max(daysBetween(asOfDate, end), 0)
const { rows: config } = await pool.query("SELECT value FROM config WHERE key = 'estimate_trailing_days'")
const globalDefault = config[0]?.value ?? 30
const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order')
const breakdown = []
const totals = { total_pence: 0, projected_consumption: 0 }
for (const cat of categories) {
const windowDays = cat.estimate_trailing_days || globalDefault
const { rows: meters } = await pool.query('SELECT id FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id])
let catConsumption = 0
let catTotalPence = 0
for (const m of meters) {
const { daily_rate } = await getTrailingDailyRate(m.id, windowDays)
const { consumption: actualToDate, has_data } = await getPeriodConsumption(m.id, start, asOfDate)
let projected = null
if (daily_rate != null) projected = (has_data ? actualToDate : 0) + daily_rate * remainingDays
else if (has_data) projected = actualToDate
const tariff = await getTariffForMeter(m.id, asOfDate)
const windows = tariff ? await getRateWindows(tariff.id) : []
const cost = computeCost({ tariff, windows, consumption: projected, daysInPeriod })
if (projected != null) catConsumption += projected
catTotalPence += cost.total_pence
}
breakdown.push({ category: cat.key, category_name: cat.name, unit_label: cat.unit_label, projected_consumption: catConsumption, total_pence: catTotalPence })
totals.projected_consumption += catConsumption
totals.total_pence += catTotalPence
}
return { period: { year, month, start, end, remaining_days: remainingDays }, categories: breakdown, totals }
})
}

View file

@ -0,0 +1,164 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { createWriteStream } from 'fs'
import { mkdir, unlink } from 'fs/promises'
import { randomUUID } from 'crypto'
import { extname, join } from 'path'
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
export async function meterRoutes(app, opts) {
const UPLOADS_DIR = opts.uploadsDir
app.addHook('preHandler', requireAuth)
// GET /api/meters — list with category + parent name + latest reading
app.get('/api/meters', async (req) => {
const { category_id, active } = req.query
const conditions = []
const params = []
if (category_id) { params.push(category_id); conditions.push(`m.category_id = $${params.length}`) }
if (active !== undefined) { params.push(active === 'true'); conditions.push(`m.active = $${params.length}`) }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
const { rows } = await pool.query(
`SELECT m.*, c.name AS category_name, c.key AS category_key, c.unit_label,
p.name AS parent_name,
lr.reading_value AS latest_reading_value, lr.reading_date AS latest_reading_date
FROM meters m
JOIN meter_categories c ON c.id = m.category_id
LEFT JOIN meters p ON p.id = m.parent_meter_id
LEFT JOIN LATERAL (
SELECT reading_value, reading_date FROM readings
WHERE meter_id = m.id ORDER BY reading_date DESC, id DESC LIMIT 1
) lr ON TRUE
${where}
ORDER BY c.sort_order, m.name`,
params
)
return rows
})
// GET /api/meters/:id — detail: meter + children + tariff history + recent readings
app.get('/api/meters/:id', async (req, reply) => {
const { rows } = await pool.query(
`SELECT m.*, c.name AS category_name, c.key AS category_key, c.unit_label, p.name AS parent_name
FROM meters m
JOIN meter_categories c ON c.id = m.category_id
LEFT JOIN meters p ON p.id = m.parent_meter_id
WHERE m.id = $1`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Meter not found' })
const { rows: children } = await pool.query(
`SELECT m.id, m.name,
lr.reading_value AS latest_reading_value, lr.reading_date AS latest_reading_date
FROM meters m
LEFT JOIN LATERAL (
SELECT reading_value, reading_date FROM readings
WHERE meter_id = m.id ORDER BY reading_date DESC, id DESC LIMIT 1
) lr ON TRUE
WHERE m.parent_meter_id = $1 ORDER BY m.name`,
[req.params.id]
)
const { rows: tariffHistory } = await pool.query(
`SELECT mt.id, mt.effective_from, mt.effective_to, t.id AS tariff_id, t.name AS tariff_name, t.supplier
FROM meter_tariffs mt JOIN tariffs t ON t.id = mt.tariff_id
WHERE mt.meter_id = $1 ORDER BY mt.effective_from DESC`,
[req.params.id]
)
const { rows: readings } = await pool.query(
`SELECT * FROM readings WHERE meter_id = $1 ORDER BY reading_date DESC, id DESC LIMIT 20`,
[req.params.id]
)
return { ...rows[0], children, tariff_history: tariffHistory, recent_readings: readings }
})
app.post('/api/meters', { preHandler: requireCap('meters') }, async (req, reply) => {
const b = req.body || {}
if (!b.name || !b.category_id) return reply.status(400).send({ error: 'name and category_id required' })
const { rows } = await pool.query(
`INSERT INTO meters (category_id, parent_meter_id, name, location, serial_number, install_date, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[b.category_id, b.parent_meter_id || null, b.name, b.location || null, b.serial_number || null, b.install_date || null, b.notes || null]
)
return rows[0]
})
app.patch('/api/meters/:id', { preHandler: requireCap('meters') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM meters WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Meter not found' })
const m = existing[0]
const b = req.body || {}
if (b.parent_meter_id && Number(b.parent_meter_id) === Number(req.params.id)) {
return reply.status(400).send({ error: 'A meter cannot be its own parent' })
}
const { rows } = await pool.query(
`UPDATE meters SET category_id = $1, parent_meter_id = $2, name = $3, location = $4,
serial_number = $5, install_date = $6, notes = $7, active = $8
WHERE id = $9 RETURNING *`,
[
b.category_id ?? m.category_id,
b.parent_meter_id !== undefined ? b.parent_meter_id : m.parent_meter_id,
b.name ?? m.name,
b.location !== undefined ? b.location : m.location,
b.serial_number !== undefined ? b.serial_number : m.serial_number,
b.install_date !== undefined ? b.install_date : m.install_date,
b.notes !== undefined ? b.notes : m.notes,
b.active ?? m.active,
req.params.id,
]
)
return rows[0]
})
// POST /api/meters/:id/image — multipart upload, cashup-style pattern
app.post('/api/meters/:id/image', { preHandler: requireCap('meters') }, async (req, reply) => {
const meterId = parseInt(req.params.id)
const { rows } = await pool.query('SELECT * FROM meters WHERE id = $1', [meterId])
if (!rows.length) return reply.status(404).send({ error: 'Meter not found' })
const fileData = await req.file()
if (!fileData) return reply.status(400).send({ error: 'No file uploaded' })
if (!ALLOWED_IMAGES.includes(fileData.mimetype)) {
return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' })
}
const ext = extname(fileData.filename) || '.jpg'
const filename = randomUUID() + ext
const dir = join(UPLOADS_DIR, 'meters', String(meterId))
await mkdir(dir, { recursive: true })
const dest = createWriteStream(join(dir, filename))
for await (const chunk of fileData.file) dest.write(chunk)
await new Promise(r => dest.end(r))
// Replace any previous image
if (rows[0].image_path) {
await unlink(join(UPLOADS_DIR, rows[0].image_path)).catch(() => {})
}
const filePath = `/meters/${meterId}/${filename}`
const { rows: updated } = await pool.query(
'UPDATE meters SET image_path = $1 WHERE id = $2 RETURNING *',
[filePath, meterId]
)
return updated[0]
})
app.delete('/api/meters/:id/image', { preHandler: requireCap('meters') }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM meters WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Meter not found' })
if (rows[0].image_path) {
await unlink(join(UPLOADS_DIR, rows[0].image_path)).catch(() => {})
}
const { rows: updated } = await pool.query(
'UPDATE meters SET image_path = NULL WHERE id = $1 RETURNING *',
[req.params.id]
)
return updated[0]
})
}

View file

@ -0,0 +1,111 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { createWriteStream } from 'fs'
import { mkdir, unlink } from 'fs/promises'
import { randomUUID } from 'crypto'
import { extname, join } from 'path'
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
export async function readingRoutes(app, opts) {
const UPLOADS_DIR = opts.uploadsDir
app.addHook('preHandler', requireAuth)
// GET /api/readings?meter_id=&from=&to=&limit=
app.get('/api/readings', { preHandler: requireCap('readings') }, async (req) => {
const { meter_id, from, to, limit } = req.query
const conditions = []
const params = []
if (meter_id) { params.push(meter_id); conditions.push(`r.meter_id = $${params.length}`) }
if (from) { params.push(from); conditions.push(`r.reading_date >= $${params.length}`) }
if (to) { params.push(to); conditions.push(`r.reading_date <= $${params.length}`) }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
params.push(Math.min(parseInt(limit) || 100, 500))
const { rows } = await pool.query(
`SELECT r.*, m.name AS meter_name, c.unit_label
FROM readings r
JOIN meters m ON m.id = r.meter_id
JOIN meter_categories c ON c.id = m.category_id
${where}
ORDER BY r.reading_date DESC, r.id DESC
LIMIT $${params.length}`,
params
)
return rows
})
app.post('/api/readings', { preHandler: requireCap('readings') }, async (req, reply) => {
const b = req.body || {}
if (!b.meter_id || b.reading_value === undefined || !b.reading_date) {
return reply.status(400).send({ error: 'meter_id, reading_value and reading_date required' })
}
const { rows: meter } = await pool.query('SELECT id FROM meters WHERE id = $1', [b.meter_id])
if (!meter.length) return reply.status(404).send({ error: 'Meter not found' })
const { rows: prev } = await pool.query(
`SELECT reading_value, reading_date FROM readings
WHERE meter_id = $1 ORDER BY reading_date DESC, id DESC LIMIT 1`,
[b.meter_id]
)
const rollback = prev.length && b.reading_date >= prev[0].reading_date && Number(b.reading_value) < Number(prev[0].reading_value)
const { rows } = await pool.query(
`INSERT INTO readings (meter_id, reading_value, reading_date, source, recorded_by, notes)
VALUES ($1,$2,$3,'manual',$4,$5) RETURNING *`,
[b.meter_id, b.reading_value, b.reading_date, req.user.email, b.notes || null]
)
return { ...rows[0], warning: rollback ? 'Reading is lower than the previous one — check for a meter rollover or entry error' : null }
})
app.patch('/api/readings/:id', { preHandler: requireCap('readings') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM readings WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Reading not found' })
const r = existing[0]
const b = req.body || {}
const { rows } = await pool.query(
`UPDATE readings SET reading_value = $1, reading_date = $2, notes = $3 WHERE id = $4 RETURNING *`,
[b.reading_value ?? r.reading_value, b.reading_date ?? r.reading_date, b.notes !== undefined ? b.notes : r.notes, req.params.id]
)
return rows[0]
})
app.delete('/api/readings/:id', { preHandler: requireCap('readings') }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM readings WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Reading not found' })
if (rows[0].photo_path) await unlink(join(UPLOADS_DIR, rows[0].photo_path)).catch(() => {})
await pool.query('DELETE FROM readings WHERE id = $1', [req.params.id])
return { ok: true }
})
// POST /api/readings/:id/photo — meter-dial photo evidence for a reading
app.post('/api/readings/:id/photo', { preHandler: requireCap('readings') }, async (req, reply) => {
const readingId = parseInt(req.params.id)
const { rows } = await pool.query('SELECT * FROM readings WHERE id = $1', [readingId])
if (!rows.length) return reply.status(404).send({ error: 'Reading not found' })
const fileData = await req.file()
if (!fileData) return reply.status(400).send({ error: 'No file uploaded' })
if (!ALLOWED_IMAGES.includes(fileData.mimetype)) {
return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' })
}
const ext = extname(fileData.filename) || '.jpg'
const filename = randomUUID() + ext
const dir = join(UPLOADS_DIR, 'readings', String(readingId))
await mkdir(dir, { recursive: true })
const dest = createWriteStream(join(dir, filename))
for await (const chunk of fileData.file) dest.write(chunk)
await new Promise(r => dest.end(r))
if (rows[0].photo_path) await unlink(join(UPLOADS_DIR, rows[0].photo_path)).catch(() => {})
const filePath = `/readings/${readingId}/${filename}`
const { rows: updated } = await pool.query(
'UPDATE readings SET photo_path = $1 WHERE id = $2 RETURNING *',
[filePath, readingId]
)
return updated[0]
})
}

View file

@ -0,0 +1,89 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { getMeterCostForPeriod, resolvePeriod } from '../lib/cost-calc.js'
export async function reportRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('reports'))
// GET /api/reports/consumption-cost?period=YYYY-MM&category_id= — per-meter
// consumption + full cost breakdown for the period (defaults to current month)
app.get('/api/reports/consumption-cost', async (req) => {
const { start, end, isCurrent } = resolvePeriod(req.query.period)
const { category_id } = req.query
const params = []
let where = ''
if (category_id) { params.push(category_id); where = 'WHERE m.category_id = $1' }
const { rows: meters } = await pool.query(
`SELECT m.id, m.name, m.category_id, c.name AS category_name, c.unit_label
FROM meters m JOIN meter_categories c ON c.id = m.category_id
${where} ORDER BY c.sort_order, m.name`,
params
)
const results = []
for (const m of meters) {
const cost = await getMeterCostForPeriod(m.id, start, end)
results.push({
meter_id: m.id, meter_name: m.name,
category_id: m.category_id, category_name: m.category_name, unit_label: m.unit_label,
...cost,
})
}
const totals = results.reduce((acc, r) => ({
consumption: acc.consumption + (r.consumption || 0),
usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence,
standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence,
ccl_cost_pence: acc.ccl_cost_pence + r.ccl_cost_pence,
vat_pence: acc.vat_pence + r.vat_pence,
total_pence: acc.total_pence + r.total_pence,
}), { consumption: 0, usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0 })
return { period: { start, end, isCurrent }, meters: results, totals }
})
// GET /api/reports/rollup?period=YYYY-MM — every parent meter vs sum of its
// children over the same period, flagged when children exceed the parent
app.get('/api/reports/rollup', async (req) => {
const { start, end } = resolvePeriod(req.query.period)
const { rows: parents } = await pool.query(
`SELECT DISTINCT m.id, m.name, c.unit_label
FROM meters m
JOIN meter_categories c ON c.id = m.category_id
WHERE EXISTS (SELECT 1 FROM meters child WHERE child.parent_meter_id = m.id)`
)
const results = []
for (const p of parents) {
const { rows: children } = await pool.query('SELECT id, name FROM meters WHERE parent_meter_id = $1', [p.id])
const parentCost = await getMeterCostForPeriod(p.id, start, end)
let childSum = 0
let childHasData = false
const childBreakdown = []
for (const c of children) {
const cc = await getMeterCostForPeriod(c.id, start, end)
if (cc.has_data) { childSum += cc.consumption; childHasData = true }
childBreakdown.push({ meter_id: c.id, meter_name: c.name, consumption: cc.consumption, has_data: cc.has_data })
}
const anomaly = parentCost.has_data && childHasData && childSum > parentCost.consumption
results.push({
parent_meter_id: p.id,
parent_meter_name: p.name,
unit_label: p.unit_label,
parent_consumption: parentCost.consumption,
parent_has_data: parentCost.has_data,
children: childBreakdown,
child_sum: childHasData ? childSum : null,
anomaly,
})
}
return { period: { start, end }, rollups: results }
})
}

View file

@ -0,0 +1,27 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function settingsRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/config — all config keys as a flat object (readable by anyone in
// the app since the estimates page needs the global default too)
app.get('/api/config', async () => {
const { rows } = await pool.query('SELECT key, value FROM config ORDER BY key')
return Object.fromEntries(rows.map(r => [r.key, r.value]))
})
// PUT /api/config/:key — update a single config key
app.put('/api/config/:key', { preHandler: requireCap('settings') }, async (req, reply) => {
const { key } = req.params
const { value } = req.body || {}
if (value === undefined) return reply.status(400).send({ error: 'value required' })
await pool.query(
`INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
[key, JSON.stringify(value)]
)
return { ok: true }
})
}

View file

@ -0,0 +1,175 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function tariffRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/tariffs — reference data needed by meters/readings/reports pages too
app.get('/api/tariffs', async (req) => {
const { category_id } = req.query
const params = []
let where = ''
if (category_id) { params.push(category_id); where = 'WHERE t.category_id = $1' }
const { rows } = await pool.query(
`SELECT t.*, c.name AS category_name,
(SELECT COUNT(*)::int FROM tariff_rate_windows w WHERE w.tariff_id = t.id) AS window_count
FROM tariffs t JOIN meter_categories c ON c.id = t.category_id
${where}
ORDER BY t.effective_from DESC`,
params
)
return rows
})
app.get('/api/tariffs/:id', async (req, reply) => {
const { rows } = await pool.query(
`SELECT t.*, c.name AS category_name FROM tariffs t
JOIN meter_categories c ON c.id = t.category_id WHERE t.id = $1`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Tariff not found' })
const { rows: windows } = await pool.query(
'SELECT * FROM tariff_rate_windows WHERE tariff_id = $1 ORDER BY sort_order, id',
[req.params.id]
)
return { ...rows[0], windows }
})
// POST /api/tariffs — create tariff + its rate windows in one call.
// Non-TOU tariff: send a single window (label 'Standard') covering all hours.
app.post('/api/tariffs', { preHandler: requireCap('tariffs') }, async (req, reply) => {
const b = req.body || {}
if (!b.category_id || !b.name || !b.effective_from) {
return reply.status(400).send({ error: 'category_id, name and effective_from required' })
}
if (!Array.isArray(b.windows) || b.windows.length === 0) {
return reply.status(400).send({ error: 'At least one rate window is required' })
}
const client = await pool.connect()
try {
await client.query('BEGIN')
const { rows } = await client.query(
`INSERT INTO tariffs (category_id, name, supplier, effective_from, effective_to,
standing_charge_pence_per_day, ccl_rate_pence_per_unit, ccl_exempt,
vat_rate_pct, is_time_of_use, fallback_split_pct)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
[
b.category_id, b.name, b.supplier || null, b.effective_from, b.effective_to || null,
b.standing_charge_pence_per_day || 0, b.ccl_rate_pence_per_unit || null, b.ccl_exempt === true,
b.vat_rate_pct ?? 20, b.is_time_of_use === true, JSON.stringify(b.fallback_split_pct || {}),
]
)
const tariff = rows[0]
for (const [i, w] of b.windows.entries()) {
await client.query(
`INSERT INTO tariff_rate_windows (tariff_id, label, start_time, end_time, days_of_week, unit_rate_pence_per_unit, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[tariff.id, w.label, w.start_time || null, w.end_time || null, w.days_of_week || null, w.unit_rate_pence_per_unit, w.sort_order ?? i]
)
}
await client.query('COMMIT')
return tariff
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
})
app.patch('/api/tariffs/:id', { preHandler: requireCap('tariffs') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM tariffs WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Tariff not found' })
const t = existing[0]
const b = req.body || {}
const { rows } = await pool.query(
`UPDATE tariffs SET name = $1, supplier = $2, effective_from = $3, effective_to = $4,
standing_charge_pence_per_day = $5, ccl_rate_pence_per_unit = $6, ccl_exempt = $7,
vat_rate_pct = $8, is_time_of_use = $9, fallback_split_pct = $10
WHERE id = $11 RETURNING *`,
[
b.name ?? t.name,
b.supplier !== undefined ? b.supplier : t.supplier,
b.effective_from ?? t.effective_from,
b.effective_to !== undefined ? b.effective_to : t.effective_to,
b.standing_charge_pence_per_day ?? t.standing_charge_pence_per_day,
b.ccl_rate_pence_per_unit !== undefined ? b.ccl_rate_pence_per_unit : t.ccl_rate_pence_per_unit,
b.ccl_exempt ?? t.ccl_exempt,
b.vat_rate_pct ?? t.vat_rate_pct,
b.is_time_of_use ?? t.is_time_of_use,
JSON.stringify(b.fallback_split_pct ?? t.fallback_split_pct),
req.params.id,
]
)
return rows[0]
})
// PUT /api/tariffs/:id/windows — replace the whole rate-window set (simpler
// than per-window CRUD for a "rate window editor" saved as one form)
app.put('/api/tariffs/:id/windows', { preHandler: requireCap('tariffs') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT id FROM tariffs WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Tariff not found' })
const windows = req.body?.windows
if (!Array.isArray(windows) || windows.length === 0) {
return reply.status(400).send({ error: 'At least one rate window is required' })
}
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query('DELETE FROM tariff_rate_windows WHERE tariff_id = $1', [req.params.id])
for (const [i, w] of windows.entries()) {
await client.query(
`INSERT INTO tariff_rate_windows (tariff_id, label, start_time, end_time, days_of_week, unit_rate_pence_per_unit, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[req.params.id, w.label, w.start_time || null, w.end_time || null, w.days_of_week || null, w.unit_rate_pence_per_unit, w.sort_order ?? i]
)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
const { rows } = await pool.query(
'SELECT * FROM tariff_rate_windows WHERE tariff_id = $1 ORDER BY sort_order, id',
[req.params.id]
)
return rows
})
// POST /api/meters/:id/assign-tariff — close the current open meter_tariffs
// row (if any) and open a new one from effective_from
app.post('/api/meters/:id/assign-tariff', { preHandler: requireCap('tariffs') }, async (req, reply) => {
const { tariff_id, effective_from } = req.body || {}
if (!tariff_id || !effective_from) return reply.status(400).send({ error: 'tariff_id and effective_from required' })
const { rows: meter } = await pool.query('SELECT id FROM meters WHERE id = $1', [req.params.id])
if (!meter.length) return reply.status(404).send({ error: 'Meter not found' })
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query(
`UPDATE meter_tariffs SET effective_to = ($1::date - INTERVAL '1 day')
WHERE meter_id = $2 AND effective_to IS NULL`,
[effective_from, req.params.id]
)
const { rows } = await client.query(
`INSERT INTO meter_tariffs (meter_id, tariff_id, effective_from) VALUES ($1,$2,$3) RETURNING *`,
[req.params.id, tariff_id, effective_from]
)
await client.query('COMMIT')
return rows[0]
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
})
}

39
docker-compose.yml Normal file
View file

@ -0,0 +1,39 @@
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=utilities
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
- UTILITIES_API_KEY=${UTILITIES_API_KEY}
volumes:
- uploads_data:/app/uploads
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: ./frontend
security_opt:
- apparmor=unconfined
ports:
- "${FRONTEND_PORT:-3080}:80"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
volumes:
uploads_data:
networks:
default:
driver: bridge

13
frontend/Dockerfile Normal file
View file

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

16
frontend/index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="theme-color" content="#1e6091" />
<title>Utilities</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

40
frontend/nginx.conf Normal file
View file

@ -0,0 +1,40 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
client_max_body_size 12m;
location /utilities/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 /utilities/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 /utilities/health {
proxy_pass http://backend:3001/health;
}
location ~* /utilities/.*\.(js|css|png|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location /utilities/ {
add_header Cache-Control "no-cache" always;
try_files $uri /utilities/index.html;
}
location = / {
return 301 /utilities/;
}
}

6223
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
frontend/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "hnf-utilities-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

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

@ -0,0 +1,38 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import AuthGate from './components/AuthGate'
import { UpdateBanner } from './components/UpdateBanner'
import { useVersionCheck } from './hooks/useVersionCheck'
import Layout from './components/Layout'
import Meters from './pages/Meters'
import MeterDetail from './pages/MeterDetail'
import Tariffs from './pages/Tariffs'
import Readings from './pages/Readings'
import Reports from './pages/Reports'
import Estimates from './pages/Estimates'
import Settings from './pages/Settings'
export default function App() {
const updateAvailable = useVersionCheck('/utilities/health')
return (
<>
<BrowserRouter basename="/utilities">
<AuthGate>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/meters" replace />} />
<Route path="/meters" element={<Meters />} />
<Route path="/meters/:id" element={<MeterDetail />} />
<Route path="/readings" element={<Readings />} />
<Route path="/tariffs" element={<Tariffs />} />
<Route path="/reports" element={<Reports />} />
<Route path="/estimates" element={<Estimates />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/meters" replace />} />
</Routes>
</Layout>
</AuthGate>
</BrowserRouter>
<UpdateBanner visible={updateAvailable} />
</>
)
}

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

@ -0,0 +1,152 @@
import type {
Category, Meter, MeterDetail, Tariff, Reading, ConsumptionCostReport, RollupReport,
EstimateReport, AppConfig,
} from './types'
const BASE = '/utilities/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.error || `Request failed: ${res.status}`)
}
return res.json()
}
// Categories
export function fetchCategories(): Promise<Category[]> {
return request('/categories')
}
export function createCategory(body: Partial<Category>): Promise<Category> {
return request('/categories', { method: 'POST', body: JSON.stringify(body) })
}
export function updateCategory(id: number, body: Partial<Category>): Promise<Category> {
return request(`/categories/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
// Meters
export function fetchMeters(filters: { category_id?: number; active?: boolean } = {}): Promise<Meter[]> {
const params = new URLSearchParams()
if (filters.category_id) params.set('category_id', String(filters.category_id))
if (filters.active !== undefined) params.set('active', String(filters.active))
const qs = params.toString()
return request(`/meters${qs ? `?${qs}` : ''}`)
}
export function fetchMeter(id: number): Promise<MeterDetail> {
return request(`/meters/${id}`)
}
export function createMeter(body: Record<string, unknown>): Promise<Meter> {
return request('/meters', { method: 'POST', body: JSON.stringify(body) })
}
export function updateMeter(id: number, body: Record<string, unknown>): Promise<Meter> {
return request(`/meters/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export async function uploadMeterImage(meterId: number, file: File): Promise<Meter> {
const form = new FormData()
form.append('file', file)
const res = await fetch(`${BASE}/meters/${meterId}/image`, { method: 'POST', credentials: 'include', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Upload failed: ${res.status}`)
}
return res.json()
}
export function deleteMeterImage(meterId: number): Promise<Meter> {
return request(`/meters/${meterId}/image`, { method: 'DELETE' })
}
export function uploadUrl(filePath: string): string {
return `${BASE}/uploads${filePath}`
}
// Tariffs
export function fetchTariffs(categoryId?: number): Promise<Tariff[]> {
return request(`/tariffs${categoryId ? `?category_id=${categoryId}` : ''}`)
}
export function fetchTariff(id: number): Promise<Tariff> {
return request(`/tariffs/${id}`)
}
export function createTariff(body: Record<string, unknown>): Promise<Tariff> {
return request('/tariffs', { method: 'POST', body: JSON.stringify(body) })
}
export function updateTariff(id: number, body: Record<string, unknown>): Promise<Tariff> {
return request(`/tariffs/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function replaceTariffWindows(id: number, windows: Record<string, unknown>[]): Promise<unknown> {
return request(`/tariffs/${id}/windows`, { method: 'PUT', body: JSON.stringify({ windows }) })
}
export function assignMeterTariff(meterId: number, tariffId: number, effectiveFrom: string): Promise<unknown> {
return request(`/meters/${meterId}/assign-tariff`, {
method: 'POST',
body: JSON.stringify({ tariff_id: tariffId, effective_from: effectiveFrom }),
})
}
// Readings
export function fetchReadings(filters: { meter_id?: number; from?: string; to?: string; limit?: number } = {}): Promise<Reading[]> {
const params = new URLSearchParams()
if (filters.meter_id) params.set('meter_id', String(filters.meter_id))
if (filters.from) params.set('from', filters.from)
if (filters.to) params.set('to', filters.to)
if (filters.limit) params.set('limit', String(filters.limit))
const qs = params.toString()
return request(`/readings${qs ? `?${qs}` : ''}`)
}
export function createReading(body: { meter_id: number; reading_value: number; reading_date: string; notes?: string }): Promise<Reading> {
return request('/readings', { method: 'POST', body: JSON.stringify(body) })
}
export function updateReading(id: number, body: Record<string, unknown>): Promise<Reading> {
return request(`/readings/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteReading(id: number): Promise<{ ok: boolean }> {
return request(`/readings/${id}`, { method: 'DELETE' })
}
export async function uploadReadingPhoto(readingId: number, file: File): Promise<Reading> {
const form = new FormData()
form.append('file', file)
const res = await fetch(`${BASE}/readings/${readingId}/photo`, { method: 'POST', credentials: 'include', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Upload failed: ${res.status}`)
}
return res.json()
}
// Reports
export function fetchConsumptionCostReport(period?: string, categoryId?: number): Promise<ConsumptionCostReport> {
const params = new URLSearchParams()
if (period) params.set('period', period)
if (categoryId) params.set('category_id', String(categoryId))
const qs = params.toString()
return request(`/reports/consumption-cost${qs ? `?${qs}` : ''}`)
}
export function fetchRollupReport(period?: string): Promise<RollupReport> {
return request(`/reports/rollup${period ? `?period=${period}` : ''}`)
}
// Estimates
export function fetchEstimates(categoryId?: number): Promise<EstimateReport> {
return request(`/estimates${categoryId ? `?category_id=${categoryId}` : ''}`)
}
export function setCategoryEstimateWindow(categoryId: number, days: number | null): Promise<Category> {
return request(`/estimates/category/${categoryId}`, {
method: 'PATCH',
body: JSON.stringify({ estimate_trailing_days: days }),
})
}
// Settings (global config)
export function fetchConfig(): Promise<AppConfig> {
return request('/config')
}
export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> {
return request(`/config/${key}`, { method: 'PUT', body: JSON.stringify({ value }) })
}

View file

@ -0,0 +1,164 @@
import { useEffect, useRef, useState, createContext, useContext } from 'react'
import type { User } from '../types'
function getInactivityMs(): number | null {
if (window.matchMedia('(display-mode: standalone)').matches) return null
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins='))
if (!c) return null
const mins = parseInt(c.split('=')[1])
return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000
}
// Only bounce to the central login when actually embedded in the portal shell.
// A standalone PWA or a directly-opened browser tab must never navigate away
// from its own start_url/scope — otherwise it loses its installed-app context.
function isEmbedded() {
return window.top !== window
}
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 [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
const [user, setUser] = useState<User | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
fetch('/api/auth/verify?app=utilities', { credentials: 'include' })
.then(async r => {
if (r.ok) {
setUser(await r.json())
setState('authed')
} else if (isEmbedded()) {
window.top!.location.href = `/login?from=${encodeURIComponent('/app/utilities')}`
} else {
setState('login')
}
})
.catch(() => { if (!isEmbedded()) setState('login') })
}, [])
// Inactivity auto-logout — disabled for installed PWAs; configurable per
// device (Admin Settings → Device) for shared/front-desk browser sessions.
useEffect(() => {
const ms = getInactivityMs()
if (state !== 'authed' || !ms) return
const timeoutMs: number = ms
async function forceLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
setUser(null)
setState('login')
}
function reset() {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(forceLogout, timeoutMs)
}
const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const
events.forEach(e => window.addEventListener(e, reset, { passive: true }))
reset()
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
events.forEach(e => window.removeEventListener(e, reset))
}
}, [state])
async function login(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await fetch('/api/auth/login', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) { setError('Invalid email or password'); return }
const verify = await fetch('/api/auth/verify?app=utilities', { credentials: 'include' })
if (verify.ok) {
setUser(await verify.json())
setState('authed')
} else {
setError("You don't have access to this app.")
}
} catch {
setError('Connection error — please try again')
} finally {
setLoading(false)
}
}
if (state === 'checking') {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
}}>
Loading
</div>
)
}
if (state === 'login') {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
background: 'var(--navy-dark)',
}}>
<div style={{
background: 'var(--navy)', borderRadius: 'var(--radius)',
padding: '2rem', width: '100%', maxWidth: '360px',
border: '1px solid var(--surface-2)',
}}>
<h1 style={{ fontSize: '1.4rem', marginBottom: '1.5rem', color: 'var(--gold)' }}>
Utilities
</h1>
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<input
type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required autoComplete="email"
style={inputStyle}
/>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required autoComplete="current-password"
style={inputStyle}
/>
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
<button type="submit" disabled={loading} style={{
background: loading ? 'var(--surface-2)' : 'var(--gold)',
color: loading ? 'var(--text-muted)' : 'var(--navy-dark)',
border: 'none', borderRadius: '6px', padding: '0.625rem',
fontSize: '1rem', fontWeight: 600, marginTop: '0.25rem',
}}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}
return <Ctx.Provider value={{ user: user! }}>{children}</Ctx.Provider>
}
const inputStyle: React.CSSProperties = {
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem',
fontSize: '1rem', width: '100%', outline: 'none',
}

View file

@ -0,0 +1,75 @@
import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Zap, Gauge, Receipt, BarChart3, TrendingUp, Settings, Menu, LogOut } from 'lucide-react'
import { useAuth } from './AuthGate'
import { can } from '../types'
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
const NAV = [
{ to: '/meters', label: 'Meters', icon: Gauge, cap: 'meters' },
{ to: '/readings', label: 'Readings', icon: Zap, cap: 'readings' },
{ to: '/tariffs', label: 'Tariffs', icon: Receipt, cap: 'tariffs' },
{ to: '/reports', label: 'Reports', icon: BarChart3, cap: 'reports' },
{ to: '/estimates', label: 'Estimates', icon: TrendingUp, cap: 'estimates' },
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' },
]
export default function Layout({ children }: { children: React.ReactNode }) {
const { user } = useAuth()
const items = NAV.filter(n => can(user, n.cap))
const location = useLocation()
const [menuOpen, setMenuOpen] = useState(false)
async function logout() {
await fetch('/utilities/api/auth/logout', { method: 'POST', credentials: 'include' })
window.location.reload()
}
useEffect(() => { setMenuOpen(false) }, [location.pathname])
return (
<div className={`app-shell${menuOpen ? ' menu-open' : ''}`}>
<aside className="sidebar">
<div className="sidebar-logo">
<Zap size={18} strokeWidth={1.75} />
Utilities
</div>
<nav className="sidebar-nav">
{items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
<Icon {...ICON_PROPS} />
{label}
</NavLink>
))}
</nav>
<div className="sidebar-user" style={{ whiteSpace: 'normal' }}>
<div style={{ fontWeight: 600, color: 'var(--text)', fontSize: '12px', marginBottom: '2px' }}>{user.name}</div>
<div style={{ fontSize: '11px', marginBottom: '8px' }}>{user.email}</div>
<button onClick={logout} style={{
display: 'flex', alignItems: 'center', gap: '6px',
background: 'none', border: 'none', color: 'inherit',
fontSize: '12px', padding: 0, cursor: 'pointer',
}}>
<LogOut size={13} strokeWidth={1.75} />
Sign out
</button>
</div>
</aside>
{menuOpen && <div className="menu-backdrop" onClick={() => setMenuOpen(false)} />}
<header className="top-bar">
<button className="top-bar-burger" onClick={() => setMenuOpen(o => !o)}>
<Menu size={20} strokeWidth={1.75} />
</button>
<Zap size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">Utilities</span>
</header>
<main className="page-content">
{children}
</main>
</div>
)
}

View file

@ -0,0 +1,44 @@
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(--navy)',
color: 'var(--text)',
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(--gold)',
color: 'var(--navy)',
border: 'none',
borderRadius: '4px',
padding: '6px 14px',
fontWeight: 600,
cursor: 'pointer',
fontSize: '13px',
}}
>
<RefreshCw size={14} strokeWidth={1.75} />
Reload
</button>
</div>
)
}

View file

@ -0,0 +1,43 @@
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 {
// network error — skip silently
}
}
check()
const interval = setInterval(check, POLL_MS)
function onVisible() {
if (document.visibilityState === 'visible') check()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
clearInterval(interval)
document.removeEventListener('visibilitychange', onVisible)
}
}, [healthUrl])
return updateAvailable
}

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

@ -0,0 +1,407 @@
/* Stack design system tokens — include verbatim in every app */
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--surface: rgba(255,255,255,0.07);
--surface-2: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.48);
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--card-border: #e4e8ee;
--text-dark: #1e293b;
--text-mid: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--danger: #dc2626;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body { background: var(--body-bg); color: var(--text-dark); font-family: var(--font); }
/* App theme + semantic tokens */
:root {
--app-primary: #1e6091;
--app-primary-light: #2c7fb8;
/* Meter category colour coding (content only — buttons stay gold) */
--cat-electric: #d97706;
--cat-gas: #dc2626;
--cat-oil: #78716c;
--cat-water: #0284c7;
--danger-bg: #fef2f2;
--warn-bg: #fffbeb;
--ok-bg: #f0fdf4;
--sidebar-w: 240px;
--topbar-h: 56px;
}
*, *::before, *::after { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; font-size: 14px; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 2px; }
/* ── App shell ─────────────────────────────────────────────── */
.app-shell { display: flex; height: 100vh; overflow: hidden; }
.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: .05em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-logo svg { opacity: .8; }
.sidebar-nav { flex: 1; padding: 8px 0; }
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: background .15s, color .15s;
}
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
.sidebar-user {
padding: 12px 16px;
border-top: 1px solid var(--surface-2);
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-bar {
display: none;
height: var(--topbar-h);
background: var(--navy);
color: var(--text);
align-items: center;
padding: 0 12px;
gap: 10px;
flex-shrink: 0;
}
.top-bar-title { flex: 1; font-size: 15px; font-weight: 600; color: var(--gold); }
.top-bar-nav { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; }
.top-bar-nav::-webkit-scrollbar { display: none; }
.top-bar-nav a {
color: var(--text-muted);
padding: 6px 8px;
border-radius: 6px;
text-decoration: none;
font-size: 12px;
white-space: nowrap;
}
.top-bar-nav a.active { color: var(--gold); }
.page-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 200;
transform: translateX(calc(-1 * var(--sidebar-w)));
transition: transform 0.25s ease;
}
.app-shell.menu-open .sidebar { transform: translateX(0); }
.top-bar { display: flex; }
.app-shell { flex-direction: column; }
.field-row { flex-direction: column; }
}
.menu-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 199;
}
.top-bar-burger {
background: none;
border: none;
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
padding: 4px;
flex-shrink: 0;
}
/* ── Page chrome ───────────────────────────────────────────── */
.page { padding: 20px; max-width: 1200px; width: 100%; margin: 0 auto; }
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.page-header h1 { font-size: 18px; margin: 0; flex: 1; }
/* ── Buttons ───────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--card-border);
background: var(--card-bg);
color: var(--text-dark);
border-radius: var(--radius);
padding: 7px 14px;
font-size: 13px;
cursor: pointer;
font-family: var(--font);
transition: background .12s, border-color .12s;
}
.btn:hover { border-color: var(--text-mid); }
.btn:disabled { opacity: .5; cursor: default; }
.btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
.btn-primary:hover { background: var(--gold-light); border-color: var(--gold-light); }
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 8px; }
/* ── Forms ─────────────────────────────────────────────────── */
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; }
.field input[type="text"], .field input[type="email"], .field input[type="date"], .field input[type="time"],
.field input[type="number"], .field select, .field textarea {
width: 100%;
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13.5px;
font-family: var(--font);
color: var(--text-dark);
background: var(--card-bg);
}
.field textarea { min-height: 72px; resize: vertical; }
.field-row { display: flex; gap: 12px; }
.field-row > .field { flex: 1; }
.field-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; }
.field-check input { width: 16px; height: 16px; accent-color: var(--gold); }
.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; }
/* ── Cards & lists ─────────────────────────────────────────── */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 14px 16px;
margin-bottom: 10px;
}
.meter-card {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
transition: box-shadow .12s;
}
.meter-card:hover { box-shadow: var(--shadow-md); }
.meter-card.inactive { opacity: .55; }
.meter-thumb {
width: 48px; height: 48px; border-radius: 8px; object-fit: cover;
border: 1px solid var(--card-border); flex-shrink: 0; background: var(--body-bg);
}
.meter-thumb-placeholder {
width: 48px; height: 48px; border-radius: 8px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: var(--body-bg); color: var(--text-mid);
}
.meter-card-main { flex: 1; min-width: 0; }
.meter-card-title { font-weight: 600; font-size: 14px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.meter-card-meta { font-size: 12px; color: var(--text-mid); display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.meter-card-side { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex-shrink: 0; }
.meter-reading-val { font-weight: 700; font-size: 14px; }
.meter-reading-date { font-size: 11px; color: var(--text-mid); }
/* ── Category tabs ─────────────────────────────────────────── */
.tab-bar { display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap; }
.tab-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 20px; border: 1px solid var(--card-border);
background: var(--card-bg); cursor: pointer; font-size: 13px; color: var(--text-mid);
font-family: var(--font); transition: all .12s;
}
.tab-btn:hover { border-color: var(--gold); color: var(--text-dark); }
.tab-btn.active { background: var(--navy); border-color: var(--navy); color: var(--gold); font-weight: 600; }
/* Category colour dots (content coding, not buttons) */
.cat-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.cat-electric { background: var(--cat-electric); }
.cat-gas { background: var(--cat-gas); }
.cat-oil { background: var(--cat-oil); }
.cat-water { background: var(--cat-water); }
/* ── Badges ────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 20px;
padding: 2px 9px;
font-size: 11px;
font-weight: 600;
color: #fff;
white-space: nowrap;
}
.badge-outline {
background: transparent;
border: 1px solid var(--card-border);
color: var(--text-mid);
font-weight: 500;
}
.badge-anomaly { background: var(--danger); }
.badge-tou { background: var(--app-primary); }
/* ── Chips ─────────────────────────────────────────────────── */
.chip-bar { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 4px; align-items: center; }
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border-radius: 20px;
border: 1px solid var(--card-border);
background: var(--card-bg);
cursor: pointer;
font-size: 12px;
color: var(--text-mid);
user-select: none;
font-family: var(--font);
transition: all .12s;
}
.chip:hover { border-color: var(--gold); color: var(--text-dark); }
.chip.active { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
/* ── Modal ─────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15,15,32,.55);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px 12px;
z-index: 100;
overflow-y: auto;
}
.modal {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
width: 100%;
max-width: 680px;
padding: 20px;
margin: auto 0;
}
.modal-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; }
.modal-header h2 { font-size: 16px; margin: 0; flex: 1; }
.modal-close {
background: none;
border: none;
cursor: pointer;
color: var(--text-mid);
padding: 2px;
display: flex;
}
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
/* ── Tables ────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
table.data th {
text-align: left;
padding: 9px 12px;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-mid);
border-bottom: 1px solid var(--card-border);
white-space: nowrap;
}
table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border); vertical-align: top; }
table.data tr:last-child td { border-bottom: none; }
table.data tr.clickable { cursor: pointer; }
table.data tr.clickable:hover td { background: var(--body-bg); }
table.data td.num { text-align: right; font-variant-numeric: tabular-nums; }
table.data tr.total-row td { font-weight: 700; background: var(--body-bg); border-top: 2px solid var(--card-border); }
table.data tr.anomaly-row td { background: var(--danger-bg); }
/* ── Stats strip ───────────────────────────────────────────── */
.stats-strip { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.stat-box {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 10px 16px;
min-width: 110px;
}
.stat-box .stat-value { font-size: 18px; font-weight: 700; }
.stat-box .stat-label { font-size: 11px; color: var(--text-mid); text-transform: uppercase; letter-spacing: .04em; }
/* ── Settings ──────────────────────────────────────────────── */
.settings-section { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); margin-bottom: 20px; overflow: hidden; }
.settings-section-header { padding: 14px 20px; border-bottom: 1px solid var(--card-border); font-weight: 600; font-size: 15px; }
.settings-section-body { padding: 16px 20px; }
.settings-row { display: flex; align-items: center; gap: 16px; padding: 8px 0; border-bottom: 1px solid var(--card-border); }
.settings-row:last-child { border-bottom: none; }
.settings-label { flex: 1; font-size: 13px; }
/* ── Misc ──────────────────────────────────────────────────── */
.empty-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; }
.error-banner {
background: var(--danger-bg);
border: 1px solid var(--danger);
color: var(--danger);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
}
.info-banner {
background: var(--warn-bg);
border: 1px solid var(--gold);
color: var(--text-dark);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
}
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; }
.muted { color: var(--text-mid); }
.loading-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; }
/* Sidebar scrollbar */
.nav-scroll::-webkit-scrollbar,
.sidebar::-webkit-scrollbar,
.sidebar-nav::-webkit-scrollbar { width: 4px; }
.nav-scroll::-webkit-scrollbar-track,
.sidebar::-webkit-scrollbar-track,
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.nav-scroll::-webkit-scrollbar-thumb,
.sidebar::-webkit-scrollbar-thumb,
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; }
.nav-scroll::-webkit-scrollbar-thumb:hover,
.sidebar::-webkit-scrollbar-thumb:hover,
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
.nav-scroll, .sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }

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

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

View file

@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from 'react'
import { useAuth } from '../components/AuthGate'
import { can, formatMoney, formatUnits } from '../types'
import type { Category, EstimateReport } from '../types'
import * as api from '../api'
const WINDOW_OPTIONS = [7, 14, 30]
export default function Estimates() {
const { user } = useAuth()
const canEdit = can(user, 'estimates')
const [categories, setCategories] = useState<Category[]>([])
const [categoryId, setCategoryId] = useState<number | ''>('')
const [report, setReport] = useState<EstimateReport | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [savingCat, setSavingCat] = useState<number | null>(null)
const loadCategories = useCallback(() => {
api.fetchCategories().then(setCategories).catch(err => setError(err.message))
}, [])
useEffect(() => { loadCategories() }, [loadCategories])
const load = useCallback(() => {
setLoading(true)
api.fetchEstimates(categoryId || undefined)
.then(setReport)
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [categoryId])
useEffect(() => { load() }, [load])
async function setWindow(catId: number, days: number | null) {
setSavingCat(catId)
try {
await api.setCategoryEstimateWindow(catId, days)
await loadCategories()
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save')
} finally {
setSavingCat(null)
}
}
return (
<div className="page">
<div className="page-header">
<h1>Estimates</h1>
<div className="field" style={{ marginBottom: 0 }}>
<select value={categoryId} onChange={e => setCategoryId(e.target.value ? parseInt(e.target.value) : '')}>
<option value="">All categories</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
{error && <div className="error-banner">{error}</div>}
{loading || !report ? (
<div className="loading-state">Loading</div>
) : (
<>
<div className="info-banner">
Projected cost for the current open period ({report.period.start} to {report.period.end}) trailing average
daily consumption × remaining days, plus standing charge, CCL and VAT for the full period.
</div>
<div className="stats-strip">
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.total_pence)}</div><div className="stat-label">Projected total</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.usage_cost_pence)}</div><div className="stat-label">Usage</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.standing_cost_pence)}</div><div className="stat-label">Standing</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.ccl_cost_pence)}</div><div className="stat-label">CCL</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.vat_pence)}</div><div className="stat-label">VAT</div></div>
</div>
{report.meters.length === 0 ? (
<div className="empty-state">No active meters to estimate.</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>Meter</th><th>Category</th><th className="num">Trailing window</th>
<th className="num">Daily rate</th><th className="num">Actual to date</th>
<th className="num">Remaining days</th><th className="num">Projected consumption</th>
<th className="num">Projected cost</th>
</tr>
</thead>
<tbody>
{report.meters.map(m => (
<tr key={m.meter_id}>
<td>{m.meter_name}</td>
<td>{m.category_name}</td>
<td className="num">{m.trailing_window_days}d</td>
<td className="num">{m.daily_rate != null ? formatUnits(m.daily_rate, `${m.unit_label}/day`) : '—'}</td>
<td className="num">{formatUnits(m.actual_to_date, m.unit_label)}</td>
<td className="num">{m.remaining_days}</td>
<td className="num">{formatUnits(m.projected_consumption, m.unit_label)}</td>
<td className="num">{formatMoney(m.total_pence)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="section-title">Trailing-window assumptions per category</div>
<div className="table-wrap">
<table className="data">
<thead><tr><th>Category</th><th>Window</th>{canEdit && <th></th>}</tr></thead>
<tbody>
{categories.map(c => (
<tr key={c.id}>
<td>{c.name}</td>
<td>
{c.estimate_trailing_days
? `${c.estimate_trailing_days} days (override)`
: `${report.global_default_window} days (global default)`}
</td>
{canEdit && (
<td style={{ display: 'flex', gap: 6 }}>
{WINDOW_OPTIONS.map(d => (
<button
key={d}
className={`chip${c.estimate_trailing_days === d ? ' active' : ''}`}
disabled={savingCat === c.id}
onClick={() => setWindow(c.id, d)}
>
{d}d
</button>
))}
{c.estimate_trailing_days && (
<button className="btn btn-sm" disabled={savingCat === c.id} onClick={() => setWindow(c.id, null)}>
Use default
</button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)
}

View file

@ -0,0 +1,198 @@
import { useCallback, useEffect, useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { ArrowLeft, Gauge } from 'lucide-react'
import { useAuth } from '../components/AuthGate'
import { can, formatUnits } from '../types'
import type { MeterDetail as MeterDetailType, Tariff } from '../types'
import * as api from '../api'
export default function MeterDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const canManageMeters = can(user, 'meters')
const canManageTariffs = can(user, 'tariffs')
const [meter, setMeter] = useState<MeterDetailType | null>(null)
const [tariffs, setTariffs] = useState<Tariff[]>([])
const [error, setError] = useState<string | null>(null)
const [assignTariffId, setAssignTariffId] = useState<number | ''>('')
const [assignFrom, setAssignFrom] = useState(new Date().toISOString().slice(0, 10))
const [imageFile, setImageFile] = useState<File | null>(null)
const load = useCallback(() => {
if (!id) return
api.fetchMeter(parseInt(id)).then(setMeter).catch(err => setError(err.message))
}, [id])
useEffect(() => { load() }, [load])
useEffect(() => {
if (meter) api.fetchTariffs(meter.category_id).then(setTariffs).catch(() => {})
}, [meter?.category_id])
if (error) return <div className="page"><div className="error-banner">{error}</div></div>
if (!meter) return <div className="page"><div className="loading-state">Loading</div></div>
const currentTariff = meter.tariff_history.find(t => !t.effective_to)
async function uploadImage() {
if (!imageFile || !meter) return
try {
await api.uploadMeterImage(meter.id, imageFile)
setImageFile(null)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed')
}
}
async function removeImage() {
if (!meter) return
await api.deleteMeterImage(meter.id).catch(err => setError(err.message))
load()
}
async function assignTariff() {
if (!meter || !assignTariffId) return
try {
await api.assignMeterTariff(meter.id, assignTariffId, assignFrom)
setAssignTariffId('')
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to assign tariff')
}
}
return (
<div className="page">
<div className="page-header">
<button className="btn btn-sm" onClick={() => navigate('/meters')}>
<ArrowLeft size={14} strokeWidth={1.75} /> Back
</button>
<h1>{meter.name}</h1>
{!meter.active && <span className="badge badge-outline">inactive</span>}
</div>
{error && <div className="error-banner">{error}</div>}
<div className="card">
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<div style={{ flexShrink: 0 }}>
{meter.image_path ? (
<img src={api.uploadUrl(meter.image_path)} alt={meter.name} style={{ width: 140, height: 140, objectFit: 'cover', borderRadius: 8, border: '1px solid var(--card-border)' }} />
) : (
<div style={{ width: 140, height: 140, borderRadius: 8, background: 'var(--body-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-mid)' }}>
<Gauge size={32} strokeWidth={1.5} />
</div>
)}
{canManageMeters && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<input type="file" accept="image/*" onChange={e => setImageFile(e.target.files?.[0] || null)} style={{ fontSize: 11 }} />
{imageFile && <button className="btn btn-sm" onClick={uploadImage}>Upload</button>}
{meter.image_path && <button className="btn btn-sm btn-danger" onClick={removeImage}>Remove photo</button>}
</div>
)}
</div>
<div style={{ flex: 1, minWidth: 200 }}>
<div className="stats-strip">
<div className="stat-box"><div className="stat-value">{meter.category_name}</div><div className="stat-label">Category</div></div>
<div className="stat-box">
<div className="stat-value">{formatUnits(meter.latest_reading_value ? Number(meter.latest_reading_value) : null, meter.unit_label)}</div>
<div className="stat-label">Latest reading</div>
</div>
<div className="stat-box"><div className="stat-value">{currentTariff?.tariff_name || '—'}</div><div className="stat-label">Current tariff</div></div>
</div>
<div className="field-row">
<div className="field"><label>Location</label><div>{meter.location || '—'}</div></div>
<div className="field"><label>Serial number</label><div>{meter.serial_number || '—'}</div></div>
</div>
<div className="field-row">
<div className="field"><label>Parent meter</label><div>{meter.parent_name || '—'}</div></div>
<div className="field"><label>Install date</label><div>{meter.install_date ? new Date(meter.install_date).toLocaleDateString('en-GB') : '—'}</div></div>
</div>
{meter.notes && <div className="field"><label>Notes</label><div>{meter.notes}</div></div>}
</div>
</div>
</div>
{meter.children.length > 0 && (
<>
<div className="section-title">Sub-meters</div>
<div className="table-wrap">
<table className="data">
<thead><tr><th>Name</th><th>Latest reading</th><th></th></tr></thead>
<tbody>
{meter.children.map(c => (
<tr key={c.id} className="clickable" onClick={() => navigate(`/meters/${c.id}`)}>
<td>{c.name}</td>
<td>{c.latest_reading_value ? `${Number(c.latest_reading_value).toLocaleString()} (${new Date(c.latest_reading_date!).toLocaleDateString('en-GB')})` : 'no readings'}</td>
<td><Link to={`/meters/${c.id}`}>View</Link></td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
<div className="section-title">Tariff history</div>
{canManageTariffs && (
<div className="card">
<div className="field-row" style={{ alignItems: 'flex-end' }}>
<div className="field" style={{ marginBottom: 0 }}>
<label>Assign tariff</label>
<select value={assignTariffId} onChange={e => setAssignTariffId(e.target.value ? parseInt(e.target.value) : '')}>
<option value="">Select</option>
{tariffs.map(t => <option key={t.id} value={t.id}>{t.name} ({t.supplier || 'no supplier'})</option>)}
</select>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Effective from</label>
<input type="date" value={assignFrom} onChange={e => setAssignFrom(e.target.value)} />
</div>
<button className="btn btn-primary" onClick={assignTariff} style={{ marginBottom: 1 }}>Assign</button>
</div>
</div>
)}
{meter.tariff_history.length === 0 ? (
<div className="empty-state">No tariff has been assigned to this meter yet.</div>
) : (
<div className="table-wrap">
<table className="data">
<thead><tr><th>Tariff</th><th>Supplier</th><th>From</th><th>To</th></tr></thead>
<tbody>
{meter.tariff_history.map(t => (
<tr key={t.id}>
<td>{t.tariff_name}</td>
<td>{t.supplier || '—'}</td>
<td>{new Date(t.effective_from).toLocaleDateString('en-GB')}</td>
<td>{t.effective_to ? new Date(t.effective_to).toLocaleDateString('en-GB') : <span className="badge badge-outline">current</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="section-title">Recent readings</div>
{meter.recent_readings.length === 0 ? (
<div className="empty-state">No readings recorded yet.</div>
) : (
<div className="table-wrap">
<table className="data">
<thead><tr><th>Date</th><th className="num">Value</th><th>Recorded by</th><th>Notes</th></tr></thead>
<tbody>
{meter.recent_readings.map(r => (
<tr key={r.id}>
<td>{new Date(r.reading_date).toLocaleDateString('en-GB')}</td>
<td className="num">{Number(r.reading_value).toLocaleString()} {meter.unit_label}</td>
<td>{r.recorded_by || '—'}</td>
<td>{r.notes || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,229 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, X, Image as ImageIcon, Gauge } from 'lucide-react'
import { useAuth } from '../components/AuthGate'
import { can, CATEGORY_DOT_CLASS, formatUnits } from '../types'
import type { Category, Meter } from '../types'
import * as api from '../api'
const emptyForm = {
id: 0, category_id: 0, parent_meter_id: '' as number | '', name: '', location: '',
serial_number: '', install_date: '', notes: '', active: true,
}
export default function Meters() {
const { user } = useAuth()
const navigate = useNavigate()
const canManage = can(user, 'meters')
const [categories, setCategories] = useState<Category[]>([])
const [meters, setMeters] = useState<Meter[]>([])
const [activeCat, setActiveCat] = useState<number | null>(null)
const [showInactive, setShowInactive] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [form, setForm] = useState<typeof emptyForm | null>(null)
const [imageFile, setImageFile] = useState<File | null>(null)
const [saving, setSaving] = useState(false)
const load = useCallback(() => {
setLoading(true)
Promise.all([api.fetchCategories(), api.fetchMeters()])
.then(([cats, ms]) => { setCategories(cats); setMeters(ms) })
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
const filtered = meters.filter(m =>
(activeCat === null || m.category_id === activeCat) &&
(showInactive || m.active)
)
function openCreate() {
setForm({ ...emptyForm, category_id: activeCat || categories[0]?.id || 0 })
setImageFile(null)
}
function openEdit(m: Meter) {
setForm({
id: m.id, category_id: m.category_id, parent_meter_id: m.parent_meter_id || '',
name: m.name, location: m.location || '', serial_number: m.serial_number || '',
install_date: m.install_date ? m.install_date.slice(0, 10) : '', notes: m.notes || '',
active: m.active,
})
setImageFile(null)
}
async function save() {
if (!form) return
if (!form.name.trim() || !form.category_id) { setError('Name and category are required'); return }
setSaving(true)
setError(null)
try {
const body = {
category_id: form.category_id,
parent_meter_id: form.parent_meter_id || null,
name: form.name.trim(),
location: form.location || null,
serial_number: form.serial_number || null,
install_date: form.install_date || null,
notes: form.notes || null,
active: form.active,
}
const saved = form.id ? await api.updateMeter(form.id, body) : await api.createMeter(body)
if (imageFile) await api.uploadMeterImage(saved.id, imageFile)
setForm(null)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
const parentCandidates = form ? meters.filter(m => m.category_id === form.category_id && m.id !== form.id && !m.parent_meter_id) : []
return (
<div className="page">
<div className="page-header">
<h1>Meters</h1>
<label className="field-check" style={{ marginRight: 4 }}>
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} />
Show inactive
</label>
{canManage && (
<button className="btn btn-primary" onClick={openCreate}>
<Plus size={14} strokeWidth={1.75} /> Add Meter
</button>
)}
</div>
{error && <div className="error-banner">{error}</div>}
<div className="tab-bar">
<button className={`tab-btn${activeCat === null ? ' active' : ''}`} onClick={() => setActiveCat(null)}>
All
</button>
{categories.map(c => (
<button key={c.id} className={`tab-btn${activeCat === c.id ? ' active' : ''}`} onClick={() => setActiveCat(c.id)}>
<span className={`cat-dot ${CATEGORY_DOT_CLASS[c.key] || ''}`} />
{c.name}
</button>
))}
</div>
{loading ? (
<div className="loading-state">Loading</div>
) : filtered.length === 0 ? (
<div className="empty-state">No meters {activeCat !== null ? 'in this category' : ''} yet.</div>
) : (
filtered.map(m => (
<div key={m.id} className={`card meter-card${m.active ? '' : ' inactive'}`} onClick={() => navigate(`/meters/${m.id}`)}>
{m.image_path ? (
<img className="meter-thumb" src={api.uploadUrl(m.image_path)} alt={m.name} />
) : (
<div className="meter-thumb-placeholder"><Gauge size={20} strokeWidth={1.75} /></div>
)}
<div className="meter-card-main">
<div className="meter-card-title">
<span className={`cat-dot ${CATEGORY_DOT_CLASS[m.category_key] || ''}`} />
{m.name}
{m.parent_name && <span className="badge badge-outline">child of {m.parent_name}</span>}
{!m.active && <span className="badge badge-outline">inactive</span>}
</div>
<div className="meter-card-meta">
<span>{m.category_name}</span>
{m.location && <span>{m.location}</span>}
{m.serial_number && <span>S/N {m.serial_number}</span>}
</div>
</div>
<div className="meter-card-side">
<span className="meter-reading-val">{formatUnits(m.latest_reading_value ? Number(m.latest_reading_value) : null, m.unit_label)}</span>
<span className="meter-reading-date">{m.latest_reading_date ? new Date(m.latest_reading_date).toLocaleDateString('en-GB') : 'no readings'}</span>
</div>
{canManage && (
<button className="btn btn-sm" onClick={e => { e.stopPropagation(); openEdit(m) }}>Edit</button>
)}
</div>
))
)}
{form && (
<div className="modal-overlay" onClick={() => setForm(null)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{form.id ? 'Edit Meter' : 'New Meter'}</h2>
<button className="modal-close" onClick={() => setForm(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="field-row">
<div className="field">
<label>Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="e.g. Main Electric Incomer" />
</div>
<div className="field">
<label>Category</label>
<select value={form.category_id} onChange={e => setForm({ ...form, category_id: parseInt(e.target.value), parent_meter_id: '' })}>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
<div className="field-row">
<div className="field">
<label>Location</label>
<input type="text" value={form.location} onChange={e => setForm({ ...form, location: e.target.value })} placeholder="e.g. Basement plant room" />
</div>
<div className="field">
<label>Serial number</label>
<input type="text" value={form.serial_number} onChange={e => setForm({ ...form, serial_number: e.target.value })} />
</div>
</div>
<div className="field-row">
<div className="field">
<label>Parent meter (sub-metering)</label>
<select value={form.parent_meter_id} onChange={e => setForm({ ...form, parent_meter_id: e.target.value ? parseInt(e.target.value) : '' })}>
<option value="">None top-level meter</option>
{parentCandidates.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
<div className="field-hint">Reports flag when child meters' consumption exceeds the parent's.</div>
</div>
<div className="field">
<label>Install date</label>
<input type="date" value={form.install_date} onChange={e => setForm({ ...form, install_date: e.target.value })} />
</div>
</div>
<div className="field">
<label>Notes</label>
<textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</div>
<div className="field">
<label>Photo</label>
<input type="file" accept="image/*" onChange={e => setImageFile(e.target.files?.[0] || null)} />
</div>
{form.id > 0 && (
<label className="field-check">
<input type="checkbox" checked={form.active} onChange={e => setForm({ ...form, active: e.target.checked })} />
Active
</label>
)}
<div className="modal-actions">
<button className="btn" onClick={() => setForm(null)}>Cancel</button>
<button className="btn btn-primary" onClick={save} disabled={saving}>
<ImageIcon size={14} strokeWidth={1.75} style={{ display: imageFile ? 'inline' : 'none' }} />
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,155 @@
import { useCallback, useEffect, useState } from 'react'
import { Camera, Trash2 } from 'lucide-react'
import { useAuth } from '../components/AuthGate'
import { can } from '../types'
import type { Meter, Reading } from '../types'
import * as api from '../api'
export default function Readings() {
const { user } = useAuth()
const canEnter = can(user, 'readings')
const [meters, setMeters] = useState<Meter[]>([])
const [meterId, setMeterId] = useState<number | ''>('')
const [readings, setReadings] = useState<Reading[]>([])
const [value, setValue] = useState('')
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const [notes, setNotes] = useState('')
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [photoTargetId, setPhotoTargetId] = useState<number | null>(null)
const loadMeters = useCallback(() => {
api.fetchMeters({ active: true }).then(ms => {
setMeters(ms)
if (!meterId && ms.length) setMeterId(ms[0].id)
}).catch(err => setError(err.message))
}, [meterId])
useEffect(() => { loadMeters() }, []) // eslint-disable-line react-hooks/exhaustive-deps
const loadReadings = useCallback(() => {
api.fetchReadings({ meter_id: meterId ? Number(meterId) : undefined, limit: 50 })
.then(setReadings).catch(err => setError(err.message))
}, [meterId])
useEffect(() => { loadReadings() }, [loadReadings])
const selectedMeter = meters.find(m => m.id === meterId)
async function submit() {
if (!meterId || value === '') { setError('Meter and reading value are required'); return }
setSaving(true)
setError(null)
setInfo(null)
try {
const r = await api.createReading({ meter_id: Number(meterId), reading_value: parseFloat(value), reading_date: date, notes: notes || undefined })
setValue('')
setNotes('')
if (r.warning) setInfo(r.warning)
loadReadings()
loadMeters()
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
async function remove(id: number) {
if (!window.confirm('Delete this reading?')) return
await api.deleteReading(id).catch(err => setError(err.message))
loadReadings()
loadMeters()
}
async function uploadPhoto(id: number, file: File) {
try {
await api.uploadReadingPhoto(id, file)
loadReadings()
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed')
} finally {
setPhotoTargetId(null)
}
}
return (
<div className="page">
<div className="page-header"><h1>Readings</h1></div>
{error && <div className="error-banner">{error}</div>}
{info && <div className="info-banner">{info}</div>}
{canEnter && (
<div className="card">
<div className="field-row" style={{ alignItems: 'flex-end' }}>
<div className="field" style={{ marginBottom: 0 }}>
<label>Meter</label>
<select value={meterId} onChange={e => setMeterId(e.target.value ? parseInt(e.target.value) : '')}>
{meters.map(m => <option key={m.id} value={m.id}>{m.name} ({m.category_name})</option>)}
</select>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Reading value{selectedMeter ? ` (${selectedMeter.unit_label})` : ''}</label>
<input type="number" step="0.001" value={value} onChange={e => setValue(e.target.value)} placeholder="e.g. 45231.5" />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Date</label>
<input type="date" value={date} onChange={e => setDate(e.target.value)} />
</div>
<div className="field" style={{ marginBottom: 0, flex: 1.5 }}>
<label>Notes</label>
<input type="text" value={notes} onChange={e => setNotes(e.target.value)} placeholder="optional" />
</div>
<button className="btn btn-primary" onClick={submit} disabled={saving} style={{ marginBottom: 1 }}>
{saving ? 'Saving…' : 'Add reading'}
</button>
</div>
{selectedMeter?.latest_reading_value && (
<div className="field-hint" style={{ marginTop: 6 }}>
Previous reading: {Number(selectedMeter.latest_reading_value).toLocaleString()} {selectedMeter.unit_label} on {new Date(selectedMeter.latest_reading_date!).toLocaleDateString('en-GB')}
</div>
)}
</div>
)}
<div className="section-title">History</div>
{readings.length === 0 ? (
<div className="empty-state">No readings for this meter yet.</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr><th>Date</th><th>Meter</th><th className="num">Value</th><th>Recorded by</th><th>Notes</th><th>Photo</th>{canEnter && <th></th>}</tr>
</thead>
<tbody>
{readings.map(r => (
<tr key={r.id}>
<td>{new Date(r.reading_date).toLocaleDateString('en-GB')}</td>
<td>{r.meter_name}</td>
<td className="num">{Number(r.reading_value).toLocaleString()} {r.unit_label}</td>
<td>{r.recorded_by || '—'}</td>
<td>{r.notes || '—'}</td>
<td>
{r.photo_path ? (
<a href={api.uploadUrl(r.photo_path)} target="_blank" rel="noreferrer">View</a>
) : canEnter ? (
photoTargetId === r.id ? (
<input type="file" accept="image/*" autoFocus onChange={e => e.target.files?.[0] && uploadPhoto(r.id, e.target.files[0])} />
) : (
<button className="btn btn-sm" onClick={() => setPhotoTargetId(r.id)}><Camera size={13} strokeWidth={1.75} /></button>
)
) : '—'}
</td>
{canEnter && (
<td><button className="btn btn-sm btn-danger" onClick={() => remove(r.id)}><Trash2 size={13} strokeWidth={1.75} /></button></td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,134 @@
import { useCallback, useEffect, useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { formatMoney, formatUnits } from '../types'
import type { Category, ConsumptionCostReport, RollupReport } from '../types'
import * as api from '../api'
function currentPeriod(): string {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
export default function Reports() {
const [categories, setCategories] = useState<Category[]>([])
const [categoryId, setCategoryId] = useState<number | ''>('')
const [period, setPeriod] = useState(currentPeriod())
const [report, setReport] = useState<ConsumptionCostReport | null>(null)
const [rollup, setRollup] = useState<RollupReport | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => { api.fetchCategories().then(setCategories).catch(() => {}) }, [])
const load = useCallback(() => {
setLoading(true)
setError(null)
Promise.all([
api.fetchConsumptionCostReport(period, categoryId || undefined),
api.fetchRollupReport(period),
]).then(([r, ru]) => { setReport(r); setRollup(ru) })
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [period, categoryId])
useEffect(() => { load() }, [load])
return (
<div className="page">
<div className="page-header">
<h1>Reports</h1>
<div className="field" style={{ marginBottom: 0 }}>
<input type="month" value={period} onChange={e => setPeriod(e.target.value)} />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<select value={categoryId} onChange={e => setCategoryId(e.target.value ? parseInt(e.target.value) : '')}>
<option value="">All categories</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
{error && <div className="error-banner">{error}</div>}
{loading || !report ? (
<div className="loading-state">Loading</div>
) : (
<>
<div className="stats-strip">
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.total_pence)}</div><div className="stat-label">Total cost</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.usage_cost_pence)}</div><div className="stat-label">Usage</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.standing_cost_pence)}</div><div className="stat-label">Standing</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.ccl_cost_pence)}</div><div className="stat-label">CCL</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.vat_pence)}</div><div className="stat-label">VAT</div></div>
</div>
<div className="section-title">Consumption &amp; cost by meter</div>
{report.meters.length === 0 ? (
<div className="empty-state">No meters to report on.</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>Meter</th><th>Category</th><th className="num">Consumption</th>
<th className="num">Usage</th><th className="num">Standing</th><th className="num">CCL</th>
<th className="num">VAT</th><th className="num">Total</th>
</tr>
</thead>
<tbody>
{report.meters.map(m => (
<tr key={m.meter_id}>
<td>{m.meter_name}{!m.has_data && <span className="badge badge-outline" style={{ marginLeft: 6 }}>no data</span>}</td>
<td>{m.category_name}</td>
<td className="num">{formatUnits(m.consumption, m.unit_label)}</td>
<td className="num">{formatMoney(m.usage_cost_pence)}</td>
<td className="num">{formatMoney(m.standing_cost_pence)}</td>
<td className="num">{formatMoney(m.ccl_cost_pence)}</td>
<td className="num">{formatMoney(m.vat_pence)}</td>
<td className="num">{formatMoney(m.total_pence)}</td>
</tr>
))}
<tr className="total-row">
<td colSpan={2}>Total</td>
<td className="num">{report.totals.consumption.toLocaleString(undefined, { maximumFractionDigits: 1 })}</td>
<td className="num">{formatMoney(report.totals.usage_cost_pence)}</td>
<td className="num">{formatMoney(report.totals.standing_cost_pence)}</td>
<td className="num">{formatMoney(report.totals.ccl_cost_pence)}</td>
<td className="num">{formatMoney(report.totals.vat_pence)}</td>
<td className="num">{formatMoney(report.totals.total_pence)}</td>
</tr>
</tbody>
</table>
</div>
)}
<div className="section-title">Sub-metering rollup</div>
{!rollup || rollup.rollups.length === 0 ? (
<div className="empty-state">No parent/child meter relationships configured.</div>
) : (
<div className="table-wrap">
<table className="data">
<thead><tr><th>Parent meter</th><th className="num">Parent consumption</th><th className="num">Children sum</th><th>Children</th><th></th></tr></thead>
<tbody>
{rollup.rollups.map(r => (
<tr key={r.parent_meter_id} className={r.anomaly ? 'anomaly-row' : ''}>
<td>{r.parent_meter_name}</td>
<td className="num">{formatUnits(r.parent_consumption, r.unit_label)}</td>
<td className="num">{formatUnits(r.child_sum, r.unit_label)}</td>
<td>{r.children.map(c => c.meter_name).join(', ')}</td>
<td>
{r.anomaly && (
<span className="badge badge-anomaly">
<AlertTriangle size={11} strokeWidth={1.75} /> Children exceed parent
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
)
}

View file

@ -0,0 +1,93 @@
import { useEffect, useState } from 'react'
import { useAuth } from '../components/AuthGate'
import { can } from '../types'
import type { AppConfig } from '../types'
import * as api from '../api'
const WINDOW_OPTIONS = [7, 14, 30]
export default function Settings() {
const { user } = useAuth()
const hasCap = (cap: string) => can(user, cap)
const [config, setConfig] = useState<AppConfig | null>(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
api.fetchConfig().then(setConfig).catch(err => setError(err.message))
}, [])
if (!hasCap('settings')) {
return <div className="page"><div className="error-banner">You don't have permission to access settings.</div></div>
}
if (!config) {
return <div className="page"><div className="loading-state">Loading</div></div>
}
async function setDefaultWindow(days: number) {
setSaving(true)
setError(null)
try {
await api.updateConfig('estimate_trailing_days', days)
setConfig(c => c ? { ...c, estimate_trailing_days: days } : c)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
return (
<div className="page">
<div className="page-header"><h1>Settings</h1></div>
{error && <div className="error-banner">{error}</div>}
<div className="settings-section">
<div className="settings-section-header">Estimate defaults</div>
<div className="settings-section-body">
<div className="settings-row">
<div className="settings-label">
<div>Global trailing-average window</div>
<div className="field-hint">
Used to project the remaining days of the current billing period when a category
has no override set (see the Estimates page for per-category overrides).
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{WINDOW_OPTIONS.map(d => (
<button
key={d}
className={`chip${config.estimate_trailing_days === d ? ' active' : ''}`}
disabled={saving}
onClick={() => setDefaultWindow(d)}
>
{d} days
</button>
))}
</div>
</div>
{saved && <div className="field-hint" style={{ color: 'var(--gold)', marginTop: 6 }}>Saved.</div>}
</div>
</div>
<div className="settings-section">
<div className="settings-section-header">About</div>
<div className="settings-section-body">
<p className="muted" style={{ fontSize: 13, lineHeight: 1.6 }}>
Meter categories, meters and images are managed from the Meters page. Tariffs, rate
windows, standing charges and CCL are managed from the Tariffs page. This app also
exposes a read-only internal API (readings/costs/estimate summaries), gated by a static
API key, used by the Reports app to build the Directors' report no configuration is
needed here for that integration.
</p>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,281 @@
import { useCallback, useEffect, useState } from 'react'
import { Plus, X, Trash2 } from 'lucide-react'
import { useAuth } from '../components/AuthGate'
import { can, formatMoney } from '../types'
import type { Category, Tariff, RateWindow } from '../types'
import * as api from '../api'
interface WindowForm extends RateWindow {
split_pct: number
}
const emptyWindow = (label: string, sort: number): WindowForm => ({
label, start_time: null, end_time: null, days_of_week: null, unit_rate_pence_per_unit: 0, sort_order: sort, split_pct: 100,
})
const emptyForm = {
id: 0, category_id: 0, name: '', supplier: '', effective_from: new Date().toISOString().slice(0, 10),
effective_to: '', standing_charge_pence_per_day: 0, ccl_rate_pence_per_unit: '' as number | '', ccl_exempt: false,
vat_rate_pct: 20, is_time_of_use: false, windows: [emptyWindow('Standard', 0)],
}
export default function Tariffs() {
const { user } = useAuth()
const canManage = can(user, 'tariffs')
const [categories, setCategories] = useState<Category[]>([])
const [tariffs, setTariffs] = useState<Tariff[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [form, setForm] = useState<typeof emptyForm | null>(null)
const [saving, setSaving] = useState(false)
const load = useCallback(() => {
setLoading(true)
Promise.all([api.fetchCategories(), api.fetchTariffs()])
.then(([cats, ts]) => { setCategories(cats); setTariffs(ts) })
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
function openCreate() {
setForm({ ...emptyForm, category_id: categories[0]?.id || 0 })
}
async function openEdit(t: Tariff) {
try {
const full = await api.fetchTariff(t.id)
const windows: WindowForm[] = (full.windows || []).map(w => ({
...w,
split_pct: full.fallback_split_pct?.[w.label.toLowerCase()] ?? 0,
}))
setForm({
id: full.id, category_id: full.category_id, name: full.name, supplier: full.supplier || '',
effective_from: full.effective_from.slice(0, 10), effective_to: full.effective_to ? full.effective_to.slice(0, 10) : '',
standing_charge_pence_per_day: Number(full.standing_charge_pence_per_day),
ccl_rate_pence_per_unit: full.ccl_rate_pence_per_unit != null ? Number(full.ccl_rate_pence_per_unit) : '',
ccl_exempt: full.ccl_exempt, vat_rate_pct: Number(full.vat_rate_pct), is_time_of_use: full.is_time_of_use,
windows: windows.length ? windows : [emptyWindow('Standard', 0)],
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load tariff')
}
}
function toggleTou(on: boolean) {
if (!form) return
if (on && form.windows.length < 2) {
setForm({ ...form, is_time_of_use: true, windows: [emptyWindow('Day', 0), emptyWindow('Night', 1)] })
} else if (!on) {
setForm({ ...form, is_time_of_use: false, windows: [emptyWindow('Standard', 0)] })
} else {
setForm({ ...form, is_time_of_use: true })
}
}
function updateWindow(i: number, patch: Partial<WindowForm>) {
if (!form) return
const windows = form.windows.map((w, idx) => idx === i ? { ...w, ...patch } : w)
setForm({ ...form, windows })
}
function addWindow() {
if (!form) return
setForm({ ...form, windows: [...form.windows, emptyWindow('Weekend', form.windows.length)] })
}
function removeWindow(i: number) {
if (!form || form.windows.length <= 1) return
setForm({ ...form, windows: form.windows.filter((_, idx) => idx !== i) })
}
async function save() {
if (!form) return
if (!form.name.trim() || !form.category_id) { setError('Name and category are required'); return }
setSaving(true)
setError(null)
try {
const fallback_split_pct = form.is_time_of_use
? Object.fromEntries(form.windows.map(w => [w.label.toLowerCase(), w.split_pct]))
: {}
const windows = form.windows.map(({ split_pct: _split_pct, ...w }) => w)
const body = {
category_id: form.category_id, name: form.name.trim(), supplier: form.supplier || null,
effective_from: form.effective_from, effective_to: form.effective_to || null,
standing_charge_pence_per_day: form.standing_charge_pence_per_day,
ccl_rate_pence_per_unit: form.ccl_rate_pence_per_unit === '' ? null : form.ccl_rate_pence_per_unit,
ccl_exempt: form.ccl_exempt, vat_rate_pct: form.vat_rate_pct, is_time_of_use: form.is_time_of_use,
fallback_split_pct, windows,
}
if (form.id) {
await api.updateTariff(form.id, body)
await api.replaceTariffWindows(form.id, windows)
} else {
await api.createTariff(body)
}
setForm(null)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
const splitTotal = form ? form.windows.reduce((s, w) => s + (Number(w.split_pct) || 0), 0) : 0
return (
<div className="page">
<div className="page-header">
<h1>Tariffs</h1>
{canManage && (
<button className="btn btn-primary" onClick={openCreate}>
<Plus size={14} strokeWidth={1.75} /> Add Tariff
</button>
)}
</div>
{error && <div className="error-banner">{error}</div>}
{loading ? (
<div className="loading-state">Loading</div>
) : tariffs.length === 0 ? (
<div className="empty-state">No tariffs configured yet.</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr><th>Name</th><th>Category</th><th>Supplier</th><th>From</th><th>To</th><th>Type</th><th className="num">Standing/day</th><th></th></tr>
</thead>
<tbody>
{tariffs.map(t => (
<tr key={t.id} className="clickable" onClick={() => canManage && openEdit(t)}>
<td>{t.name}</td>
<td>{t.category_name}</td>
<td>{t.supplier || '—'}</td>
<td>{new Date(t.effective_from).toLocaleDateString('en-GB')}</td>
<td>{t.effective_to ? new Date(t.effective_to).toLocaleDateString('en-GB') : <span className="badge badge-outline">open</span>}</td>
<td>{t.is_time_of_use ? <span className="badge badge-tou">TOU</span> : 'Standard'}</td>
<td className="num">{formatMoney(Number(t.standing_charge_pence_per_day))}</td>
<td>{t.window_count} window{t.window_count === 1 ? '' : 's'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{form && (
<div className="modal-overlay" onClick={() => setForm(null)}>
<div className="modal" style={{ maxWidth: 760 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{form.id ? 'Edit Tariff' : 'New Tariff'}</h2>
<button className="modal-close" onClick={() => setForm(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="field-row">
<div className="field">
<label>Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="e.g. British Gas Commercial 2026" />
</div>
<div className="field">
<label>Category</label>
<select value={form.category_id} onChange={e => setForm({ ...form, category_id: parseInt(e.target.value) })}>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
<div className="field-row">
<div className="field">
<label>Supplier</label>
<input type="text" value={form.supplier} onChange={e => setForm({ ...form, supplier: e.target.value })} />
</div>
<div className="field">
<label>Effective from</label>
<input type="date" value={form.effective_from} onChange={e => setForm({ ...form, effective_from: e.target.value })} />
</div>
<div className="field">
<label>Effective to (optional)</label>
<input type="date" value={form.effective_to} onChange={e => setForm({ ...form, effective_to: e.target.value })} />
</div>
</div>
<div className="field-row">
<div className="field">
<label>Standing charge (pence/day)</label>
<input type="number" step="0.01" value={form.standing_charge_pence_per_day} onChange={e => setForm({ ...form, standing_charge_pence_per_day: parseFloat(e.target.value) || 0 })} />
</div>
<div className="field">
<label>VAT rate (%)</label>
<input type="number" step="0.1" value={form.vat_rate_pct} onChange={e => setForm({ ...form, vat_rate_pct: parseFloat(e.target.value) || 0 })} />
</div>
<div className="field">
<label>CCL rate (pence/unit)</label>
<input type="number" step="0.0001" disabled={form.ccl_exempt} value={form.ccl_rate_pence_per_unit}
onChange={e => setForm({ ...form, ccl_rate_pence_per_unit: e.target.value === '' ? '' : parseFloat(e.target.value) })} />
</div>
</div>
<label className="field-check" style={{ marginBottom: 14 }}>
<input type="checkbox" checked={form.ccl_exempt} onChange={e => setForm({ ...form, ccl_exempt: e.target.checked })} />
Climate Change Levy exempt
</label>
<label className="field-check" style={{ marginBottom: 14 }}>
<input type="checkbox" checked={form.is_time_of_use} onChange={e => toggleTou(e.target.checked)} />
Time-of-use tariff (split cost across rate windows using a fallback %)
</label>
<div className="section-title" style={{ margin: '4px 0 8px' }}>
Rate windows {form.is_time_of_use && <span className="muted"> fallback split must total 100%</span>}
</div>
{form.windows.map((w, i) => (
<div key={i} className="field-row" style={{ alignItems: 'flex-end', marginBottom: 8 }}>
<div className="field" style={{ marginBottom: 0, flex: 1.2 }}>
<label>Label</label>
<input type="text" value={w.label} onChange={e => updateWindow(i, { label: e.target.value })} />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Start</label>
<input type="time" value={w.start_time || ''} onChange={e => updateWindow(i, { start_time: e.target.value || null })} />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>End</label>
<input type="time" value={w.end_time || ''} onChange={e => updateWindow(i, { end_time: e.target.value || null })} />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Rate (p/unit)</label>
<input type="number" step="0.0001" value={w.unit_rate_pence_per_unit} onChange={e => updateWindow(i, { unit_rate_pence_per_unit: parseFloat(e.target.value) || 0 })} />
</div>
{form.is_time_of_use && (
<div className="field" style={{ marginBottom: 0, maxWidth: 90 }}>
<label>Split %</label>
<input type="number" step="1" value={w.split_pct} onChange={e => updateWindow(i, { split_pct: parseFloat(e.target.value) || 0 })} />
</div>
)}
{form.windows.length > 1 && (
<button className="btn btn-sm btn-danger" style={{ marginBottom: 1 }} onClick={() => removeWindow(i)}>
<Trash2 size={13} strokeWidth={1.75} />
</button>
)}
</div>
))}
{form.is_time_of_use && Math.round(splitTotal) !== 100 && (
<div className="error-banner">Fallback split currently totals {splitTotal}% it should total 100%.</div>
)}
<button className="btn btn-sm" onClick={addWindow} style={{ marginBottom: 14 }}>
<Plus size={13} strokeWidth={1.75} /> Add window
</button>
<div className="modal-actions">
<button className="btn" onClick={() => setForm(null)}>Cancel</button>
<button className="btn btn-primary" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div>
</div>
</div>
)}
</div>
)
}

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

@ -0,0 +1,218 @@
export interface User {
email: string
name: string
is_admin: boolean
caps: string[] // bare slugs — verify?app=utilities strips the prefix
}
export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap)
}
export type UtilCap = 'readings' | 'meters' | 'tariffs' | 'reports' | 'estimates' | 'settings'
export interface Category {
id: number
key: string
name: string
unit_label: string
icon: string
sort_order: number
active: boolean
estimate_trailing_days: number | null
created_at: string
}
// Maps a category key to the CSS content-colour dot class (index.css)
export const CATEGORY_DOT_CLASS: Record<string, string> = {
electric: 'cat-electric',
gas: 'cat-gas',
oil: 'cat-oil',
water: 'cat-water',
}
export interface Meter {
id: number
category_id: number
category_name: string
category_key: string
unit_label: string
parent_meter_id: number | null
parent_name: string | null
name: string
location: string | null
serial_number: string | null
image_path: string | null
install_date: string | null
active: boolean
notes: string | null
created_at: string
latest_reading_value: string | null
latest_reading_date: string | null
}
export interface MeterChild {
id: number
name: string
latest_reading_value: string | null
latest_reading_date: string | null
}
export interface MeterTariffHistoryRow {
id: number
effective_from: string
effective_to: string | null
tariff_id: number
tariff_name: string
supplier: string | null
}
export interface MeterDetail extends Meter {
children: MeterChild[]
tariff_history: MeterTariffHistoryRow[]
recent_readings: Reading[]
}
export interface RateWindow {
id?: number
tariff_id?: number
label: string
start_time: string | null
end_time: string | null
days_of_week: number[] | null
unit_rate_pence_per_unit: number
sort_order: number
}
export interface Tariff {
id: number
category_id: number
category_name: string
name: string
supplier: string | null
effective_from: string
effective_to: string | null
standing_charge_pence_per_day: number
ccl_rate_pence_per_unit: number | null
ccl_exempt: boolean
vat_rate_pct: number
is_time_of_use: boolean
fallback_split_pct: Record<string, number>
window_count?: number
windows?: RateWindow[]
created_at: string
}
export interface Reading {
id: number
meter_id: number
meter_name?: string
unit_label?: string
reading_value: string
reading_date: string
recorded_at: string
source: string
recorded_by: string | null
photo_path: string | null
notes: string | null
warning?: string | null
}
export interface CostBreakdown {
usage_cost_pence: number
standing_cost_pence: number
ccl_cost_pence: number
subtotal_pence: number
vat_pence: number
total_pence: number
split: Record<string, { share: number; rate: number; cost_pence: number }> | null
}
export interface MeterCostRow extends CostBreakdown {
meter_id: number
meter_name: string
category_id: number
category_name: string
unit_label: string
consumption: number | null
has_data: boolean
days_in_period: number
tariff: Tariff | null
}
export interface ConsumptionCostReport {
period: { start: string; end: string; isCurrent: boolean }
meters: MeterCostRow[]
totals: {
consumption: number
usage_cost_pence: number
standing_cost_pence: number
ccl_cost_pence: number
vat_pence: number
total_pence: number
}
}
export interface RollupChild {
meter_id: number
meter_name: string
consumption: number | null
has_data: boolean
}
export interface RollupRow {
parent_meter_id: number
parent_meter_name: string
unit_label: string
parent_consumption: number | null
parent_has_data: boolean
children: RollupChild[]
child_sum: number | null
anomaly: boolean
}
export interface RollupReport {
period: { start: string; end: string }
rollups: RollupRow[]
}
export interface EstimateRow extends CostBreakdown {
meter_id: number
meter_name: string
category_id: number
category_name: string
unit_label: string
trailing_window_days: number
daily_rate: number | null
actual_to_date: number | null
remaining_days: number
projected_consumption: number | null
}
export interface EstimateReport {
period: { year: number; month: number; start: string; end: string }
global_default_window: number
meters: EstimateRow[]
totals: {
total_pence: number
usage_cost_pence: number
standing_cost_pence: number
ccl_cost_pence: number
vat_pence: number
}
}
export interface AppConfig {
estimate_trailing_days: number
}
// Pence -> pounds, formatted for display
export function formatMoney(pence: number | null | undefined): string {
if (pence == null) return '—'
return `£${(pence / 100).toFixed(2)}`
}
export function formatUnits(value: number | null | undefined, unit: string): string {
if (value == null) return '—'
return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} ${unit}`
}

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

19
frontend/tsconfig.json Normal file
View file

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

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

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

45
seed-app.js Normal file
View file

@ -0,0 +1,45 @@
#!/usr/bin/env node
// Run against the auth DB to (re-)register the utilities app and its
// capabilities. This mirrors the seed block already added to auth/src/db.js —
// use this for a manual re-seed without restarting the auth service.
// 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 ('utilities', 'Utilities', 'Meter readings, tariffs and energy cost tracking', '/utilities', 'Zap', '#1e6091', 'Operations', '10.10.10.127', 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
`)
// Seed capabilities — no default Staff grants, admin assigns via portal
await pool.query(`
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
('readings', 'Enter Readings', 'Enter manual meter readings and view reading history', 1),
('meters', 'Manage Meters', 'Create/edit categories, meters, locations and images', 2),
('tariffs', 'Manage Tariffs', 'Create/edit tariffs, rate windows, standing charges and CCL', 3),
('reports', 'View Reports', 'View consumption and cost reports', 4),
('estimates', 'View Estimates', 'View and adjust period cost estimates', 5),
('settings', 'Settings', 'App settings', 6)
) AS c(slug, name, description, sort_order)
WHERE a.slug = 'utilities'
ON CONFLICT (app_id, slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
sort_order = EXCLUDED.sort_order
`)
console.log('utilities app seeded.')
await pool.end()