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:
parent
7a4627520c
commit
a41b1171d3
5 changed files with 164 additions and 59 deletions
|
|
@ -105,6 +105,25 @@ async function interpolatedValueAtDate(meterId, date) {
|
||||||
return { value, before, after }
|
return { value, before, after }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Classifies how a period's consumption figure was actually derived, for
|
||||||
|
// display next to a report row:
|
||||||
|
// - 'no_data' — no readings cover the period at all
|
||||||
|
// - 'partial' — real data only reaches as_of_date, short of periodEnd
|
||||||
|
// (the normal case for a still-open current period)
|
||||||
|
// - 'complete' — readings land exactly on both boundaries, no interpolation
|
||||||
|
// needed — either a lucky manual coincidence or, once a
|
||||||
|
// device feeds daily readings, the normal case
|
||||||
|
// - 'distributed' — full period covered, but one or both boundaries needed
|
||||||
|
// interpolating across a sparse-reading gap
|
||||||
|
function classifyBasis(hasData, periodStart, periodEnd, first, last) {
|
||||||
|
if (!hasData) return { status: 'no_data', as_of_date: null }
|
||||||
|
const lastDate = toISODate(last.reading_date)
|
||||||
|
if (lastDate < periodEnd) return { status: 'partial', as_of_date: lastDate }
|
||||||
|
const firstDate = toISODate(first.reading_date)
|
||||||
|
if (firstDate === periodStart && lastDate === periodEnd) return { status: 'complete', as_of_date: null }
|
||||||
|
return { status: 'distributed', as_of_date: null }
|
||||||
|
}
|
||||||
|
|
||||||
// Consumption for a meter over [periodStart, periodEnd] (inclusive). Interpolates
|
// Consumption for a meter over [periodStart, periodEnd] (inclusive). Interpolates
|
||||||
// the meter's value at each boundary between the real readings bracketing it,
|
// the meter's value at each boundary between the real readings bracketing it,
|
||||||
// then takes the difference — so a period's consumption is prorated by days
|
// then takes the difference — so a period's consumption is prorated by days
|
||||||
|
|
@ -119,17 +138,21 @@ export async function getPeriodConsumption(meterId, periodStart, periodEnd) {
|
||||||
if (!startPoint || !endPoint) {
|
if (!startPoint || !endPoint) {
|
||||||
const first = (await readingOnOrAfter(meterId, periodStart)) || (await readingOnOrBefore(meterId, periodStart))
|
const first = (await readingOnOrAfter(meterId, periodStart)) || (await readingOnOrBefore(meterId, periodStart))
|
||||||
const last = await readingOnOrBefore(meterId, periodEnd)
|
const last = await readingOnOrBefore(meterId, periodEnd)
|
||||||
if (!first || !last || last.reading_date <= first.reading_date) {
|
const hasData = !!(first && last && last.reading_date > first.reading_date)
|
||||||
return { consumption: null, first, last, has_data: false }
|
const basis = classifyBasis(hasData, periodStart, periodEnd, first, last)
|
||||||
|
if (!hasData) {
|
||||||
|
return { consumption: null, first, last, has_data: false, ...basis }
|
||||||
}
|
}
|
||||||
const rawConsumption = Number(last.reading_value) - Number(first.reading_value)
|
const rawConsumption = Number(last.reading_value) - Number(first.reading_value)
|
||||||
const consumption = await convertMeterConsumption(meterId, rawConsumption)
|
const consumption = await convertMeterConsumption(meterId, rawConsumption)
|
||||||
return { consumption, first, last, has_data: true }
|
return { consumption, first, last, has_data: true, ...basis }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const first = startPoint.before
|
||||||
|
const last = endPoint.after
|
||||||
const rawConsumption = endPoint.value - startPoint.value
|
const rawConsumption = endPoint.value - startPoint.value
|
||||||
const consumption = await convertMeterConsumption(meterId, rawConsumption)
|
const consumption = await convertMeterConsumption(meterId, rawConsumption)
|
||||||
return { consumption, first: startPoint.before, last: endPoint.after, has_data: true }
|
return { consumption, first, last, has_data: true, ...classifyBasis(true, periodStart, periodEnd, first, last) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trailing average daily consumption as of the meter's latest reading,
|
// Trailing average daily consumption as of the meter's latest reading,
|
||||||
|
|
@ -391,10 +414,13 @@ async function splitCostAcrossSegments(meterId, rangeStart, rangeEnd, consumptio
|
||||||
// or backdated after the fact) that falls inside the period. `asOfDate` is only
|
// or backdated after the fact) that falls inside the period. `asOfDate` is only
|
||||||
// used as a fallback when the meter has no tariff-assignment history at all.
|
// used as a fallback when the meter has no tariff-assignment history at all.
|
||||||
export async function getMeterCostForPeriod(meterId, periodStart, periodEnd, asOfDate) {
|
export async function getMeterCostForPeriod(meterId, periodStart, periodEnd, asOfDate) {
|
||||||
const { consumption, first, last, has_data } = await getPeriodConsumption(meterId, periodStart, periodEnd)
|
const { consumption, first, last, has_data, status, as_of_date } = await getPeriodConsumption(meterId, periodStart, periodEnd)
|
||||||
const daysInPeriod = daysInclusive(periodStart, periodEnd)
|
const daysInPeriod = daysInclusive(periodStart, periodEnd)
|
||||||
const costed = await splitCostAcrossSegments(meterId, periodStart, periodEnd, consumption, asOfDate || periodEnd)
|
const costed = await splitCostAcrossSegments(meterId, periodStart, periodEnd, consumption, asOfDate || periodEnd)
|
||||||
return { consumption, has_data, first_reading: first, last_reading: last, days_in_period: daysInPeriod, ...costed }
|
return {
|
||||||
|
consumption, has_data, status, as_of_date,
|
||||||
|
first_reading: first, last_reading: last, days_in_period: daysInPeriod, ...costed,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estimate for the remainder of an open period: actual consumption to date +
|
// Estimate for the remainder of an open period: actual consumption to date +
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,7 @@ table.data tr.clickable { cursor: pointer; }
|
||||||
table.data tr.clickable:hover td { background: var(--body-bg); }
|
table.data tr.clickable:hover td { background: var(--body-bg); }
|
||||||
table.data td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
table.data td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
table.data tr.total-row td { font-weight: 700; background: var(--body-bg); border-top: 2px solid var(--card-border); }
|
table.data tr.total-row td { font-weight: 700; background: var(--body-bg); border-top: 2px solid var(--card-border); }
|
||||||
|
table.data tr.subtotal-row td { font-weight: 600; background: var(--body-bg); border-top: 1px solid var(--card-border); }
|
||||||
table.data tr.anomaly-row td { background: var(--danger-bg); }
|
table.data tr.anomaly-row td { background: var(--danger-bg); }
|
||||||
|
|
||||||
/* ── Stats strip ───────────────────────────────────────────── */
|
/* ── Stats strip ───────────────────────────────────────────── */
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { Fragment, useCallback, useEffect, useState } from 'react'
|
||||||
import { useAuth } from '../components/AuthGate'
|
import { useAuth } from '../components/AuthGate'
|
||||||
import { can, formatMoney, formatUnits } from '../types'
|
import { can, formatMoney, formatUnits, groupByCategory } from '../types'
|
||||||
import type { Category, EstimateReport } from '../types'
|
import type { Category, EstimateReport, EstimateRow } from '../types'
|
||||||
import * as api from '../api'
|
import * as api from '../api'
|
||||||
|
|
||||||
const WINDOW_OPTIONS = [7, 14, 30]
|
const WINDOW_OPTIONS = [7, 14, 30]
|
||||||
|
|
||||||
|
function sumEstimateCosts(rows: EstimateRow[]) {
|
||||||
|
return rows.reduce((acc, r) => ({
|
||||||
|
projected_consumption: acc.projected_consumption + (r.projected_consumption || 0),
|
||||||
|
total_pence: acc.total_pence + r.total_pence,
|
||||||
|
}), { projected_consumption: 0, total_pence: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
export default function Estimates() {
|
export default function Estimates() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const canEdit = can(user, 'estimates')
|
const canEdit = can(user, 'estimates')
|
||||||
|
|
@ -84,17 +91,20 @@ export default function Estimates() {
|
||||||
<table className="data">
|
<table className="data">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Meter</th><th>Category</th><th className="num">Trailing window</th>
|
<th>Meter</th><th className="num">Trailing window</th>
|
||||||
<th className="num">Daily rate</th><th className="num">Actual to date</th>
|
<th className="num">Daily rate</th><th className="num">Actual to date</th>
|
||||||
<th className="num">Remaining days</th><th className="num">Projected consumption</th>
|
<th className="num">Remaining days</th><th className="num">Projected consumption</th>
|
||||||
<th className="num">Projected cost</th>
|
<th className="num">Projected cost</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{report.meters.map(m => (
|
{groupByCategory(report.meters).map(group => {
|
||||||
|
const subtotal = sumEstimateCosts(group.rows)
|
||||||
|
return (
|
||||||
|
<Fragment key={group.category_id}>
|
||||||
|
{group.rows.map(m => (
|
||||||
<tr key={m.meter_id}>
|
<tr key={m.meter_id}>
|
||||||
<td>{m.meter_name}</td>
|
<td>{m.meter_name}</td>
|
||||||
<td>{m.category_name}</td>
|
|
||||||
<td className="num">{m.trailing_window_days}d</td>
|
<td className="num">{m.trailing_window_days}d</td>
|
||||||
<td className="num">{m.daily_rate != null ? formatUnits(m.daily_rate, `${m.unit_label}/day`) : '—'}</td>
|
<td className="num">{m.daily_rate != null ? formatUnits(m.daily_rate, `${m.unit_label}/day`) : '—'}</td>
|
||||||
<td className="num">{formatUnits(m.actual_to_date, m.unit_label)}</td>
|
<td className="num">{formatUnits(m.actual_to_date, m.unit_label)}</td>
|
||||||
|
|
@ -103,6 +113,15 @@ export default function Estimates() {
|
||||||
<td className="num">{formatMoney(m.total_pence)}</td>
|
<td className="num">{formatMoney(m.total_pence)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
<tr className="subtotal-row">
|
||||||
|
<td>{group.category_name} subtotal</td>
|
||||||
|
<td></td><td></td><td></td><td></td>
|
||||||
|
<td className="num">{formatUnits(subtotal.projected_consumption, group.unit_label)}</td>
|
||||||
|
<td className="num">{formatMoney(subtotal.total_pence)}</td>
|
||||||
|
</tr>
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,24 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { Fragment, useCallback, useEffect, useState } from 'react'
|
||||||
import { AlertTriangle } from 'lucide-react'
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import { formatMoney, formatUnits } from '../types'
|
import { formatMoney, formatUnits, groupByCategory, formatBasis } from '../types'
|
||||||
import type { Category, ConsumptionCostReport, RollupReport } from '../types'
|
import type { Category, ConsumptionCostReport, RollupReport, MeterCostRow } from '../types'
|
||||||
import * as api from '../api'
|
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 {
|
function currentPeriod(): string {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
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.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.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.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(extrasFor(report.totals))}</div><div className="stat-label">Extras</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(report.totals.vat_pence)}</div><div className="stat-label">VAT</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>
|
||||||
|
|
||||||
|
|
@ -70,45 +83,57 @@ export default function Reports() {
|
||||||
<table className="data">
|
<table className="data">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Meter</th><th>Category</th><th className="num">Consumption</th>
|
<th>Meter</th><th className="num">Consumption</th>
|
||||||
<th className="num">Usage</th><th className="num">Standing</th><th className="num">CCL</th>
|
<th className="num">Usage</th><th className="num">Standing</th><th className="num">Extras</th>
|
||||||
<th className="num">RAB Levy</th><th className="num">Fixed extras</th>
|
<th className="num">VAT</th><th className="num">Total</th><th>Basis</th>
|
||||||
<th className="num">VAT</th><th className="num">Total</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{report.meters.map(m => (
|
{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}>
|
<tr key={m.meter_id}>
|
||||||
<td>
|
<td>
|
||||||
{m.meter_name}
|
{m.meter_name}
|
||||||
{!m.has_data && <span className="badge badge-outline" style={{ marginLeft: 6 }}>no data</span>}
|
|
||||||
{m.rate_changed_mid_period && (
|
{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(', ')}>
|
<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
|
rate changed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{m.category_name}</td>
|
|
||||||
<td className="num">{formatUnits(m.consumption, m.unit_label)}</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.usage_cost_pence)}</td>
|
||||||
<td className="num">{formatMoney(m.standing_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(extrasFor(m))}</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.vat_pence)}</td>
|
||||||
<td className="num">{formatMoney(m.total_pence)}</td>
|
<td className="num">{formatMoney(m.total_pence)}</td>
|
||||||
|
<td>{formatBasis(m.status, m.as_of_date)}</td>
|
||||||
</tr>
|
</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">
|
<tr className="total-row">
|
||||||
<td colSpan={2}>Total</td>
|
<td>Total</td>
|
||||||
<td className="num">{report.totals.consumption.toLocaleString(undefined, { maximumFractionDigits: 1 })}</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.usage_cost_pence)}</td>
|
||||||
<td className="num">{formatMoney(report.totals.standing_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(extrasFor(report.totals))}</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(report.totals.vat_pence)}</td>
|
<td className="num">{formatMoney(report.totals.vat_pence)}</td>
|
||||||
<td className="num">{formatMoney(report.totals.total_pence)}</td>
|
<td className="num">{formatMoney(report.totals.total_pence)}</td>
|
||||||
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,11 @@ export interface CostBreakdown {
|
||||||
rate_changed_mid_period: boolean
|
rate_changed_mid_period: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How a period's consumption figure was actually derived — see cost-calc.js's
|
||||||
|
// classifyBasis(). 'partial' covers the normal "still-open current period"
|
||||||
|
// case; as_of_date is only set then.
|
||||||
|
export type ConsumptionBasis = 'no_data' | 'partial' | 'complete' | 'distributed'
|
||||||
|
|
||||||
export interface MeterCostRow extends CostBreakdown {
|
export interface MeterCostRow extends CostBreakdown {
|
||||||
meter_id: number
|
meter_id: number
|
||||||
meter_name: string
|
meter_name: string
|
||||||
|
|
@ -164,10 +169,39 @@ export interface MeterCostRow extends CostBreakdown {
|
||||||
unit_label: string
|
unit_label: string
|
||||||
consumption: number | null
|
consumption: number | null
|
||||||
has_data: boolean
|
has_data: boolean
|
||||||
|
status: ConsumptionBasis
|
||||||
|
as_of_date: string | null
|
||||||
days_in_period: number
|
days_in_period: number
|
||||||
tariff: Tariff | null
|
tariff: Tariff | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Groups rows sharing a category_id together, preserving first-seen order
|
||||||
|
// (meters already arrive sorted by category from the backend).
|
||||||
|
export function groupByCategory<T extends { category_id: number; category_name: string; unit_label: string }>(
|
||||||
|
rows: T[]
|
||||||
|
): { category_id: number; category_name: string; unit_label: string; rows: T[] }[] {
|
||||||
|
const order: number[] = []
|
||||||
|
const map = new Map<number, { category_id: number; category_name: string; unit_label: string; rows: T[] }>()
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!map.has(r.category_id)) {
|
||||||
|
order.push(r.category_id)
|
||||||
|
map.set(r.category_id, { category_id: r.category_id, category_name: r.category_name, unit_label: r.unit_label, rows: [] })
|
||||||
|
}
|
||||||
|
map.get(r.category_id)!.rows.push(r)
|
||||||
|
}
|
||||||
|
return order.map(id => map.get(id)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Complete/Distributed/Up to date/As of [date]/No data" — see ConsumptionBasis.
|
||||||
|
export function formatBasis(status: ConsumptionBasis, asOfDate: string | null): string {
|
||||||
|
if (status === 'no_data') return 'No data'
|
||||||
|
if (status === 'complete') return 'Complete'
|
||||||
|
if (status === 'distributed') return 'Distributed'
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
if (asOfDate === today) return 'Up to date'
|
||||||
|
return asOfDate ? `As of ${new Date(asOfDate).toLocaleDateString('en-GB')}` : 'Partial'
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConsumptionCostReport {
|
export interface ConsumptionCostReport {
|
||||||
period: { start: string; end: string; isCurrent: boolean }
|
period: { start: string; end: string; isCurrent: boolean }
|
||||||
meters: MeterCostRow[]
|
meters: MeterCostRow[]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue