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() {
|
||||
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 { 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) {
|
||||
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
|
||||
// (e.g. utilities app not deployed, or key not configured) must not break
|
||||
// the rest of the report.
|
||||
let utilities = { costs: null, estimate: null, error: null }
|
||||
let utilities = { costs: null, estimate: null, trend: null, error: null }
|
||||
try {
|
||||
const period = `${year}-${String(month).padStart(2, '0')}`
|
||||
const now = new Date()
|
||||
const isCurrentPeriod = year === now.getFullYear() && month === now.getMonth() + 1
|
||||
|
||||
const [costs, estimate] = await Promise.all([
|
||||
const [costs, estimate, trend] = await Promise.all([
|
||||
getUtilityCosts(period),
|
||||
isCurrentPeriod ? getUtilityEstimate() : Promise.resolve(null),
|
||||
getUtilityTrend(12, period),
|
||||
])
|
||||
utilities = { costs, estimate, error: null }
|
||||
utilities = { costs, estimate, trend, error: null }
|
||||
} catch (err) {
|
||||
utilities = { costs: null, estimate: null, error: err.message }
|
||||
utilities = { costs: null, estimate: null, trend: null, error: err.message }
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
CartesianGrid,
|
||||
} from 'recharts'
|
||||
import { getWeeklyActual } from '../api'
|
||||
import type { WeeklyActualData, WeekSummaryRow, MonthSplitEntry, MonthDailyEntry } from '../types'
|
||||
import type { WeeklyActualData, WeekSummaryRow, MonthSplitEntry, MonthDailyEntry, UtilityConsumptionBasis, UtilityTrend } from '../types'
|
||||
|
||||
// ── Formatters ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -475,8 +475,75 @@ function SalesHistorySection({
|
|||
|
||||
// ── 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'] }) {
|
||||
const { costs, estimate, error } = utilities
|
||||
const { costs, estimate, trend, error } = utilities
|
||||
|
||||
if (error || !costs) {
|
||||
return (
|
||||
|
|
@ -486,55 +553,60 @@ function UtilitiesSection({ utilities }: { utilities: WeeklyActualData['utilitie
|
|||
)
|
||||
}
|
||||
|
||||
if (costs.categories.length === 0) {
|
||||
if (costs.meters.length === 0) {
|
||||
return (
|
||||
<div className="wa-utilities-placeholder">
|
||||
<p>No utility categories configured.</p>
|
||||
<p>No meters configured.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const estimateByMeter = new Map((estimate?.meters ?? []).map(e => [e.meter_id, e]))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="wa-table-wrap">
|
||||
<table className="wa-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Meter</th>
|
||||
<th>Consumption</th>
|
||||
<th>Usage</th>
|
||||
<th>Standing</th>
|
||||
<th>CCL</th>
|
||||
<th>RAB Levy</th>
|
||||
<th>Fixed Extras</th>
|
||||
<th>Extras</th>
|
||||
<th>VAT</th>
|
||||
<th>Total</th>
|
||||
<th>Basis</th>
|
||||
<th>Period Estimate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{costs.categories.map(c => (
|
||||
<tr key={c.category}>
|
||||
<td className="wa-row-label">{c.category_name}</td>
|
||||
<td className="wa-num">{fmtUnits(c.consumption, c.unit_label)}</td>
|
||||
<td className="wa-num">{fmtCcy(c.usage_cost_pence / 100)}</td>
|
||||
<td className="wa-num">{fmtCcy(c.standing_cost_pence / 100)}</td>
|
||||
<td className="wa-num">{fmtCcy(c.ccl_cost_pence / 100)}</td>
|
||||
<td className="wa-num">{fmtCcy(c.rab_levy_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(c.vat_pence / 100)}</td>
|
||||
<td className="wa-num">{fmtCcy(c.total_pence / 100)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{costs.meters.map(m => {
|
||||
const est = estimateByMeter.get(m.meter_id)
|
||||
return (
|
||||
<tr key={m.meter_id}>
|
||||
<td className="wa-row-label">{m.meter_name}</td>
|
||||
<td className="wa-num">{fmtUnits(m.consumption, m.unit_label)}</td>
|
||||
<td className="wa-num">{fmtCcy(m.usage_cost_pence / 100)}</td>
|
||||
<td className="wa-num">{fmtCcy(m.standing_cost_pence / 100)}</td>
|
||||
<td className="wa-num">{fmtCcy(extrasPence(m) / 100)}</td>
|
||||
<td className="wa-num">{fmtCcy(m.vat_pence / 100)}</td>
|
||||
<td className="wa-num">{fmtCcy(m.total_pence / 100)}</td>
|
||||
<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">
|
||||
<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.standing_cost_pence / 100)}</td>
|
||||
<td className="wa-num">{fmtCcy(costs.totals.ccl_cost_pence / 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(extrasPence(costs.totals) / 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></td>
|
||||
<td className="wa-num">{estimate ? fmtCcy(estimate.totals.total_pence / 100) : '—'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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)}
|
||||
</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
|
||||
}
|
||||
|
||||
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 {
|
||||
period: { start: string; end: string; days_in_period: number }
|
||||
categories: UtilityCategoryCost[]
|
||||
meters: UtilityMeterCost[]
|
||||
totals: UtilityCostTotals
|
||||
}
|
||||
|
||||
export interface UtilityEstimate {
|
||||
period: { year: number; month: number; start: string; end: string; remaining_days: number }
|
||||
meters: UtilityMeterEstimate[]
|
||||
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 {
|
||||
costs: UtilityCosts | null
|
||||
estimate: UtilityEstimate | null
|
||||
trend: UtilityTrend | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue