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).
183 lines
9.5 KiB
TypeScript
183 lines
9.5 KiB
TypeScript
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 & 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>
|
||
)
|
||
}
|