Group Reports/Estimates by category, merge extras, add data-basis column

Reports and Estimates now group meters by category with a per-category
subtotal (in that category's own unit) instead of one flat list — the
grand-total row no longer sums consumption across categories, since
mixing kWh/m3/L is meaningless; money totals still sum fine.

On Reports: CCL, RAB levy and fixed extras collapse into one "Extras"
column/stat (still separately editable/stored, just merged for
display). New "Basis" column classifies how each meter's figure was
derived — Complete, Distributed (interpolated across a sparse-reading
gap), Up to date / As of [date] (real data short of period end), or
No data — via a new classifyBasis() in cost-calc.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-28 14:28:57 +00:00
parent 7a4627520c
commit a41b1171d3
5 changed files with 164 additions and 59 deletions

View file

@ -1,9 +1,24 @@
import { useCallback, useEffect, useState } from 'react'
import { Fragment, useCallback, useEffect, useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { formatMoney, formatUnits } from '../types'
import type { Category, ConsumptionCostReport, RollupReport } from '../types'
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
}
function sumMeterCosts(rows: MeterCostRow[]) {
return rows.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')}`
@ -56,9 +71,7 @@ export default function Reports() {
<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.rab_levy_cost_pence)}</div><div className="stat-label">RAB Levy</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.metering_cost_pence + report.totals.other_charges_cost_pence)}</div><div className="stat-label">Fixed extras</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>
@ -70,45 +83,57 @@ export default function Reports() {
<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">RAB Levy</th><th className="num">Fixed extras</th>
<th className="num">VAT</th><th className="num">Total</th>
<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>
{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>}
{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>{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.rab_levy_cost_pence)}</td>
<td className="num">{formatMoney(m.metering_cost_pence + m.other_charges_cost_pence)}</td>
<td className="num">{formatMoney(m.vat_pence)}</td>
<td className="num">{formatMoney(m.total_pence)}</td>
</tr>
))}
{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.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 colSpan={2}>Total</td>
<td className="num">{report.totals.consumption.toLocaleString(undefined, { maximumFractionDigits: 1 })}</td>
<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(report.totals.ccl_cost_pence)}</td>
<td className="num">{formatMoney(report.totals.rab_levy_cost_pence)}</td>
<td className="num">{formatMoney(report.totals.metering_cost_pence + report.totals.other_charges_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>