Switch Utilities table to per-meter rows, add 12-month trend charts
Table now matches the format used elsewhere in the app: one row per meter (Consumption/Usage/Standing/Extras/VAT/Total/Basis/Period Estimate) instead of a category rollup, mirroring the utilities app's own Reports/Estimates pages (basis text, merged extras column). Adds two charts below the table: total utility cost (this year vs prior year) and one small per-category consumption chart (this year vs prior year), both fed by the new /api/internal/trend endpoint, aligned to the report's own month so paging to an older week shows the right trailing-12-month window rather than today's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0afecc2e24
commit
1efcc941bb
4 changed files with 180 additions and 30 deletions
|
|
@ -39,3 +39,12 @@ export async function getUtilityCosts(period) {
|
||||||
export async function getUtilityEstimate() {
|
export async function getUtilityEstimate() {
|
||||||
return utilFetch(`/api/internal/estimate?period=current`)
|
return utilFetch(`/api/internal/estimate?period=current`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/internal/trend?months=12&end=YYYY-MM — per-category cost/consumption
|
||||||
|
// for the last N months (ending at `end`, defaulting to the current month)
|
||||||
|
// plus the same N months a year earlier
|
||||||
|
export async function getUtilityTrend(months = 12, end) {
|
||||||
|
const qs = new URLSearchParams({ months: String(months) })
|
||||||
|
if (end) qs.set('end', end)
|
||||||
|
return utilFetch(`/api/internal/trend?${qs.toString()}`)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { requireAuth } from '../auth.js'
|
import { requireAuth } from '../auth.js'
|
||||||
import { getSetting } from '../db.js'
|
import { getSetting } from '../db.js'
|
||||||
import { getUtilityCosts, getUtilityEstimate } from '../lib/utilities-client.js'
|
import { getUtilityCosts, getUtilityEstimate, getUtilityTrend } from '../lib/utilities-client.js'
|
||||||
|
|
||||||
async function fcFetch(path) {
|
async function fcFetch(path) {
|
||||||
const apiKey = await getSetting('forecasting_api_key') || process.env.FORECASTING_API_KEY
|
const apiKey = await getSetting('forecasting_api_key') || process.env.FORECASTING_API_KEY
|
||||||
|
|
@ -294,19 +294,20 @@ export async function weeklyActualRoutes(fastify) {
|
||||||
// Fetched best-effort for the report's calendar month — a failure here
|
// Fetched best-effort for the report's calendar month — a failure here
|
||||||
// (e.g. utilities app not deployed, or key not configured) must not break
|
// (e.g. utilities app not deployed, or key not configured) must not break
|
||||||
// the rest of the report.
|
// the rest of the report.
|
||||||
let utilities = { costs: null, estimate: null, error: null }
|
let utilities = { costs: null, estimate: null, trend: null, error: null }
|
||||||
try {
|
try {
|
||||||
const period = `${year}-${String(month).padStart(2, '0')}`
|
const period = `${year}-${String(month).padStart(2, '0')}`
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const isCurrentPeriod = year === now.getFullYear() && month === now.getMonth() + 1
|
const isCurrentPeriod = year === now.getFullYear() && month === now.getMonth() + 1
|
||||||
|
|
||||||
const [costs, estimate] = await Promise.all([
|
const [costs, estimate, trend] = await Promise.all([
|
||||||
getUtilityCosts(period),
|
getUtilityCosts(period),
|
||||||
isCurrentPeriod ? getUtilityEstimate() : Promise.resolve(null),
|
isCurrentPeriod ? getUtilityEstimate() : Promise.resolve(null),
|
||||||
|
getUtilityTrend(12, period),
|
||||||
])
|
])
|
||||||
utilities = { costs, estimate, error: null }
|
utilities = { costs, estimate, trend, error: null }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
utilities = { costs: null, estimate: null, error: err.message }
|
utilities = { costs: null, estimate: null, trend: null, error: err.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { getWeeklyActual } from '../api'
|
import { getWeeklyActual } from '../api'
|
||||||
import type { WeeklyActualData, WeekSummaryRow, MonthSplitEntry, MonthDailyEntry } from '../types'
|
import type { WeeklyActualData, WeekSummaryRow, MonthSplitEntry, MonthDailyEntry, UtilityConsumptionBasis, UtilityTrend } from '../types'
|
||||||
|
|
||||||
// ── Formatters ─────────────────────────────────────────────────────────────
|
// ── Formatters ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -475,8 +475,75 @@ function SalesHistorySection({
|
||||||
|
|
||||||
// ── Section: Utilities ──────────────────────────────────────────────────────
|
// ── Section: Utilities ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extrasPence(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 fmtBasis(status: UtilityConsumptionBasis, asOfDate: string | null): string {
|
||||||
|
if (status === 'no_data') return 'No data'
|
||||||
|
if (status === 'complete') return 'Complete'
|
||||||
|
if (status === 'distributed') return 'Distributed'
|
||||||
|
if (asOfDate === localDateStr(new Date())) return 'Up to date'
|
||||||
|
return asOfDate ? `As of ${fmtShortDate(asOfDate)}` : 'Partial'
|
||||||
|
}
|
||||||
|
|
||||||
|
function UtilitiesCostTrendChart({ trend }: { trend: UtilityTrend }) {
|
||||||
|
const chartData = trend.this_year.map((ty, i) => ({
|
||||||
|
label: ty.label,
|
||||||
|
'This Year': ty.total_pence / 100,
|
||||||
|
'Last Year': (trend.last_year[i]?.total_pence ?? 0) / 100,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wa-chart-card">
|
||||||
|
<h4>Total Utility Cost — Last 12 Months vs Prior Year</h4>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 9 }} />
|
||||||
|
<YAxis tickFormatter={v => `£${(v / 1000).toFixed(1)}k`} tick={{ fontSize: 10 }} width={52} />
|
||||||
|
<Tooltip formatter={fmtChartCcy} />
|
||||||
|
<Legend iconSize={10} wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
<Line type="monotone" dataKey="This Year" stroke={CHART_COLORS.thisWeek} strokeWidth={2.5} dot={false} />
|
||||||
|
<Line type="monotone" dataKey="Last Year" stroke={CHART_COLORS.budget} strokeWidth={2} dot={false} strokeDasharray="5 3" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UtilitiesConsumptionCharts({ trend }: { trend: UtilityTrend }) {
|
||||||
|
return (
|
||||||
|
<div className="wa-charts-row">
|
||||||
|
{trend.categories.map(cat => {
|
||||||
|
const chartData = trend.this_year.map((ty, i) => ({
|
||||||
|
label: ty.label,
|
||||||
|
'This Year': ty.by_category[cat.category]?.consumption ?? 0,
|
||||||
|
'Last Year': trend.last_year[i]?.by_category[cat.category]?.consumption ?? 0,
|
||||||
|
}))
|
||||||
|
return (
|
||||||
|
<div className="wa-chart-card" key={cat.category}>
|
||||||
|
<h4>{cat.category_name} Consumption ({cat.unit_label}) — Last 12 Months vs Prior Year</h4>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 9 }} />
|
||||||
|
<YAxis tick={{ fontSize: 10 }} width={52} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend iconSize={10} wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
<Line type="monotone" dataKey="This Year" stroke={CHART_COLORS.rooms} strokeWidth={2.5} dot={false} />
|
||||||
|
<Line type="monotone" dataKey="Last Year" stroke={CHART_COLORS.budget} strokeWidth={2} dot={false} strokeDasharray="5 3" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function UtilitiesSection({ utilities }: { utilities: WeeklyActualData['utilities'] }) {
|
function UtilitiesSection({ utilities }: { utilities: WeeklyActualData['utilities'] }) {
|
||||||
const { costs, estimate, error } = utilities
|
const { costs, estimate, trend, error } = utilities
|
||||||
|
|
||||||
if (error || !costs) {
|
if (error || !costs) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -486,55 +553,60 @@ function UtilitiesSection({ utilities }: { utilities: WeeklyActualData['utilitie
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (costs.categories.length === 0) {
|
if (costs.meters.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="wa-utilities-placeholder">
|
<div className="wa-utilities-placeholder">
|
||||||
<p>No utility categories configured.</p>
|
<p>No meters configured.</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const estimateByMeter = new Map((estimate?.meters ?? []).map(e => [e.meter_id, e]))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="wa-table-wrap">
|
<div className="wa-table-wrap">
|
||||||
<table className="wa-table">
|
<table className="wa-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th>Meter</th>
|
||||||
<th>Consumption</th>
|
<th>Consumption</th>
|
||||||
<th>Usage</th>
|
<th>Usage</th>
|
||||||
<th>Standing</th>
|
<th>Standing</th>
|
||||||
<th>CCL</th>
|
<th>Extras</th>
|
||||||
<th>RAB Levy</th>
|
|
||||||
<th>Fixed Extras</th>
|
|
||||||
<th>VAT</th>
|
<th>VAT</th>
|
||||||
<th>Total</th>
|
<th>Total</th>
|
||||||
|
<th>Basis</th>
|
||||||
|
<th>Period Estimate</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{costs.categories.map(c => (
|
{costs.meters.map(m => {
|
||||||
<tr key={c.category}>
|
const est = estimateByMeter.get(m.meter_id)
|
||||||
<td className="wa-row-label">{c.category_name}</td>
|
return (
|
||||||
<td className="wa-num">{fmtUnits(c.consumption, c.unit_label)}</td>
|
<tr key={m.meter_id}>
|
||||||
<td className="wa-num">{fmtCcy(c.usage_cost_pence / 100)}</td>
|
<td className="wa-row-label">{m.meter_name}</td>
|
||||||
<td className="wa-num">{fmtCcy(c.standing_cost_pence / 100)}</td>
|
<td className="wa-num">{fmtUnits(m.consumption, m.unit_label)}</td>
|
||||||
<td className="wa-num">{fmtCcy(c.ccl_cost_pence / 100)}</td>
|
<td className="wa-num">{fmtCcy(m.usage_cost_pence / 100)}</td>
|
||||||
<td className="wa-num">{fmtCcy(c.rab_levy_cost_pence / 100)}</td>
|
<td className="wa-num">{fmtCcy(m.standing_cost_pence / 100)}</td>
|
||||||
<td className="wa-num">{fmtCcy((c.metering_cost_pence + c.other_charges_cost_pence) / 100)}</td>
|
<td className="wa-num">{fmtCcy(extrasPence(m) / 100)}</td>
|
||||||
<td className="wa-num">{fmtCcy(c.vat_pence / 100)}</td>
|
<td className="wa-num">{fmtCcy(m.vat_pence / 100)}</td>
|
||||||
<td className="wa-num">{fmtCcy(c.total_pence / 100)}</td>
|
<td className="wa-num">{fmtCcy(m.total_pence / 100)}</td>
|
||||||
</tr>
|
<td>{fmtBasis(m.status, m.as_of_date)}</td>
|
||||||
))}
|
<td className="wa-num">{est ? fmtCcy(est.total_pence / 100) : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<tr className="wa-total-row">
|
<tr className="wa-total-row">
|
||||||
<td className="wa-row-label">Total</td>
|
<td className="wa-row-label">Total</td>
|
||||||
<td className="wa-num">{costs.totals.consumption.toLocaleString('en-GB', { maximumFractionDigits: 1 })}</td>
|
<td className="wa-num" title="Not summed — mixes units across fuel types">—</td>
|
||||||
<td className="wa-num">{fmtCcy(costs.totals.usage_cost_pence / 100)}</td>
|
<td className="wa-num">{fmtCcy(costs.totals.usage_cost_pence / 100)}</td>
|
||||||
<td className="wa-num">{fmtCcy(costs.totals.standing_cost_pence / 100)}</td>
|
<td className="wa-num">{fmtCcy(costs.totals.standing_cost_pence / 100)}</td>
|
||||||
<td className="wa-num">{fmtCcy(costs.totals.ccl_cost_pence / 100)}</td>
|
<td className="wa-num">{fmtCcy(extrasPence(costs.totals) / 100)}</td>
|
||||||
<td className="wa-num">{fmtCcy(costs.totals.rab_levy_cost_pence / 100)}</td>
|
|
||||||
<td className="wa-num">{fmtCcy((costs.totals.metering_cost_pence + costs.totals.other_charges_cost_pence) / 100)}</td>
|
|
||||||
<td className="wa-num">{fmtCcy(costs.totals.vat_pence / 100)}</td>
|
<td className="wa-num">{fmtCcy(costs.totals.vat_pence / 100)}</td>
|
||||||
<td className="wa-num">{fmtCcy(costs.totals.total_pence / 100)}</td>
|
<td className="wa-num">{fmtCcy(costs.totals.total_pence / 100)}</td>
|
||||||
|
<td></td>
|
||||||
|
<td className="wa-num">{estimate ? fmtCcy(estimate.totals.total_pence / 100) : '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -544,6 +616,12 @@ function UtilitiesSection({ utilities }: { utilities: WeeklyActualData['utilitie
|
||||||
Projected total for the month ({estimate.period.remaining_days} day{estimate.period.remaining_days === 1 ? '' : 's'} remaining to estimate): {fmtCcy(estimate.totals.total_pence / 100)}
|
Projected total for the month ({estimate.period.remaining_days} day{estimate.period.remaining_days === 1 ? '' : 's'} remaining to estimate): {fmtCcy(estimate.totals.total_pence / 100)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{trend && trend.this_year.length > 0 && (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<UtilitiesCostTrendChart trend={trend} />
|
||||||
|
<UtilitiesConsumptionCharts trend={trend} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,20 +152,82 @@ export interface UtilityCostTotals {
|
||||||
total_pence: number
|
total_pence: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UtilityConsumptionBasis = 'no_data' | 'partial' | 'complete' | 'distributed'
|
||||||
|
|
||||||
|
export interface UtilityMeterCost {
|
||||||
|
meter_id: number
|
||||||
|
meter_name: string
|
||||||
|
category_id: number
|
||||||
|
category: string
|
||||||
|
category_name: string
|
||||||
|
unit_label: string
|
||||||
|
consumption: number | null
|
||||||
|
has_data: boolean
|
||||||
|
status: UtilityConsumptionBasis
|
||||||
|
as_of_date: string | null
|
||||||
|
usage_cost_pence: number
|
||||||
|
standing_cost_pence: number
|
||||||
|
ccl_cost_pence: number
|
||||||
|
rab_levy_cost_pence: number
|
||||||
|
metering_cost_pence: number
|
||||||
|
other_charges_cost_pence: number
|
||||||
|
vat_pence: number
|
||||||
|
total_pence: number
|
||||||
|
rate_changed_mid_period: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UtilityMeterEstimate {
|
||||||
|
meter_id: number
|
||||||
|
meter_name: string
|
||||||
|
category_id: number
|
||||||
|
category: string
|
||||||
|
category_name: string
|
||||||
|
unit_label: string
|
||||||
|
trailing_window_days: number
|
||||||
|
daily_rate: number | null
|
||||||
|
actual_to_date: number | null
|
||||||
|
remaining_days: number
|
||||||
|
projected_consumption: number | null
|
||||||
|
total_pence: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface UtilityCosts {
|
export interface UtilityCosts {
|
||||||
period: { start: string; end: string; days_in_period: number }
|
period: { start: string; end: string; days_in_period: number }
|
||||||
categories: UtilityCategoryCost[]
|
categories: UtilityCategoryCost[]
|
||||||
|
meters: UtilityMeterCost[]
|
||||||
totals: UtilityCostTotals
|
totals: UtilityCostTotals
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UtilityEstimate {
|
export interface UtilityEstimate {
|
||||||
period: { year: number; month: number; start: string; end: string; remaining_days: number }
|
period: { year: number; month: number; start: string; end: string; remaining_days: number }
|
||||||
|
meters: UtilityMeterEstimate[]
|
||||||
totals: { total_pence: number; projected_consumption: number }
|
totals: { total_pence: number; projected_consumption: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UtilityTrendCategoryMeta {
|
||||||
|
category: string
|
||||||
|
category_name: string
|
||||||
|
unit_label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UtilityTrendMonth {
|
||||||
|
year: number
|
||||||
|
month: number
|
||||||
|
label: string
|
||||||
|
total_pence: number
|
||||||
|
by_category: Record<string, { consumption: number; total_pence: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UtilityTrend {
|
||||||
|
categories: UtilityTrendCategoryMeta[]
|
||||||
|
this_year: UtilityTrendMonth[]
|
||||||
|
last_year: UtilityTrendMonth[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface UtilitiesReport {
|
export interface UtilitiesReport {
|
||||||
costs: UtilityCosts | null
|
costs: UtilityCosts | null
|
||||||
estimate: UtilityEstimate | null
|
estimate: UtilityEstimate | null
|
||||||
|
trend: UtilityTrend | null
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue