utilities/frontend/src/pages/Reports.tsx
jtricerolph d223592aec Exclude sub-meters from category subtotals in Estimates/Reports/internal API
Water Softener is configured as a submeter (parent_meter_id) of Main Hotel
Water, but estimates.js, reports.js, and internal.js's cost + estimate
routes summed every active meter flatly regardless of parent/child, double-
counting the softener's consumption on top of the main meter's own reading.

Sub-meter rows are still shown individually (useful for diagnostics), just
excluded from the category subtotal/total sums, with a 'submeter' badge on
the row explaining why. Fixes the water figures on Estimates, Reports, and
downstream consumers of internal.js (Directors report, Weekly Actuals).
2026-08-17 09:56:02 +00:00

183 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment, useCallback, useEffect, useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { formatMoney, formatUnits, groupByCategory, formatBasis } from '../types'
import type { Category, ConsumptionCostReport, RollupReport, MeterCostRow } from '../types'
import * as api from '../api'
function extrasFor(m: { ccl_cost_pence: number; rab_levy_cost_pence: number; metering_cost_pence: number; other_charges_cost_pence: number }): number {
return m.ccl_cost_pence + m.rab_levy_cost_pence + m.metering_cost_pence + m.other_charges_cost_pence
}
// Sub-meters (parent_meter_id set) read a subset of their parent's own
// reading (e.g. the water softener sits inline on the main supply) — their
// row is still shown for visibility, but excluding them here avoids the
// subtotal double-counting water/energy that's already in the parent's figure.
function sumMeterCosts(rows: MeterCostRow[]) {
return rows.filter(r => !r.parent_meter_id).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,
extras_cost_pence: acc.extras_cost_pence + extrasFor(r),
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, extras_cost_pence: 0, vat_pence: 0, total_pence: 0 })
}
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(extrasFor(report.totals))}</div><div className="stat-label">Extras</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 className="num">Consumption</th>
<th className="num">Usage</th><th className="num">Standing</th><th className="num">Extras</th>
<th className="num">VAT</th><th className="num">Total</th><th>Basis</th>
</tr>
</thead>
<tbody>
{groupByCategory(report.meters).map(group => {
const subtotal = sumMeterCosts(group.rows)
return (
<Fragment key={group.category_id}>
{group.rows.map(m => (
<tr key={m.meter_id}>
<td>
{m.meter_name}
{m.parent_meter_id && (
<span className="badge" style={{ marginLeft: 6 }} title="Sub-meter — reads a subset of its parent meter, excluded from the subtotal below to avoid double-counting">
submeter
</span>
)}
{m.rate_changed_mid_period && (
<span className="badge badge-tou" style={{ marginLeft: 6 }} title={m.segments.map(s => `${s.tariff_name}: ${s.seg_start} ${s.seg_end}`).join(', ')}>
rate changed
</span>
)}
</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(extrasFor(m))}</td>
<td className="num">{formatMoney(m.vat_pence)}</td>
<td className="num">{formatMoney(m.total_pence)}</td>
<td>{formatBasis(m.status, m.as_of_date)}</td>
</tr>
))}
<tr className="subtotal-row">
<td>{group.category_name} subtotal</td>
<td className="num">{formatUnits(subtotal.consumption, group.unit_label)}</td>
<td className="num">{formatMoney(subtotal.usage_cost_pence)}</td>
<td className="num">{formatMoney(subtotal.standing_cost_pence)}</td>
<td className="num">{formatMoney(subtotal.extras_cost_pence)}</td>
<td className="num">{formatMoney(subtotal.vat_pence)}</td>
<td className="num">{formatMoney(subtotal.total_pence)}</td>
<td></td>
</tr>
</Fragment>
)
})}
<tr className="total-row">
<td>Total</td>
<td className="num" title="Not summed — mixes units across fuel types"></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(extrasFor(report.totals))}</td>
<td className="num">{formatMoney(report.totals.vat_pence)}</td>
<td className="num">{formatMoney(report.totals.total_pence)}</td>
<td></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>
)
}