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

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