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:
commit
4fc5230d79
44 changed files with 10249 additions and 0 deletions
134
frontend/src/pages/Reports.tsx
Normal file
134
frontend/src/pages/Reports.tsx
Normal 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 & 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue