From dfd3a506d19b69b4ed497f377f913f7665b97edb Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 28 Jul 2026 14:32:48 +0000 Subject: [PATCH] Wire real utility cost data into Weekly Actuals report Replaces the Utilities placeholder with a cost-breakdown table by category (usage/standing/CCL/RAB levy/fixed extras/VAT/total) sourced from the utilities app's internal API, plus a projected-total note for the current month. Mirrors the existing forecasting integration pattern (best-effort fetch, doesn't break the rest of the report on failure). Also adds the missing UTILITIES_URL/UTILITIES_API_KEY env vars to docker-compose.yml, needed by both this and the pre-existing directors-forecast integration. Co-Authored-By: Claude Sonnet 5 --- backend/src/routes/weekly-actual.js | 21 ++++++++ docker-compose.yml | 2 + frontend/src/pages/WeeklyActual.tsx | 80 ++++++++++++++++++++++++++--- frontend/src/types.ts | 45 ++++++++++++++++ 4 files changed, 142 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/weekly-actual.js b/backend/src/routes/weekly-actual.js index a09db3a7..51d46bbb 100644 --- a/backend/src/routes/weekly-actual.js +++ b/backend/src/routes/weekly-actual.js @@ -1,5 +1,6 @@ import { requireAuth } from '../auth.js' import { getSetting } from '../db.js' +import { getUtilityCosts, getUtilityEstimate } from '../lib/utilities-client.js' async function fcFetch(path) { const apiKey = await getSetting('forecasting_api_key') || process.env.FORECASTING_API_KEY @@ -289,6 +290,25 @@ export async function weeklyActualRoutes(fastify) { }) } + // ── Utilities (cost breakdown/estimate from the `utilities` app) ────────── + // 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 } + 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([ + getUtilityCosts(period), + isCurrentPeriod ? getUtilityEstimate() : Promise.resolve(null), + ]) + utilities = { costs, estimate, error: null } + } catch (err) { + utilities = { costs: null, estimate: null, error: err.message } + } + return { week_ending: toDateStr(weekEndDate), week_start: toDateStr(weekStartDate), @@ -300,6 +320,7 @@ export async function weeklyActualRoutes(fastify) { month_daily, monthly_trend, monthly_split, + utilities, } }) } diff --git a/docker-compose.yml b/docker-compose.yml index b4b52bf5..2722e2ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: - DATABASE_URL=${DATABASE_URL} - FORECASTING_URL=${FORECASTING_URL:-http://10.10.10.113:3080} - FORECASTING_API_KEY=${FORECASTING_API_KEY:-} + - UTILITIES_URL=${UTILITIES_URL:-http://10.10.10.127:3080} + - UTILITIES_API_KEY=${UTILITIES_API_KEY:-} - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} - SETTINGS_URL=${SETTINGS_URL} - SETTINGS_SECRET=${SETTINGS_SECRET} diff --git a/frontend/src/pages/WeeklyActual.tsx b/frontend/src/pages/WeeklyActual.tsx index 18aa190e..0db2fe65 100644 --- a/frontend/src/pages/WeeklyActual.tsx +++ b/frontend/src/pages/WeeklyActual.tsx @@ -17,6 +17,9 @@ import type { WeeklyActualData, WeekSummaryRow, MonthSplitEntry, MonthDailyEntry const fmtCcy = (n: number | null | undefined, dp = 2) => n == null ? '—' : `£${n.toLocaleString('en-GB', { minimumFractionDigits: dp, maximumFractionDigits: dp })}` +const fmtUnits = (n: number | null | undefined, unit: string) => + n == null ? '—' : `${n.toLocaleString('en-GB', { maximumFractionDigits: 1 })} ${unit}` + const fmtPct = (n: number | null | undefined) => n == null ? '—' : `${n >= 0 ? '+' : ''}${n.toFixed(2)}%` @@ -470,13 +473,78 @@ function SalesHistorySection({ ) } -// ── Section: Utilities Placeholder ───────────────────────────────────────── +// ── Section: Utilities ────────────────────────────────────────────────────── + +function UtilitiesSection({ utilities }: { utilities: WeeklyActualData['utilities'] }) { + const { costs, estimate, error } = utilities + + if (error || !costs) { + return ( +
+

Utilities report unavailable{error ? ` — ${error}` : ''}.

+
+ ) + } + + if (costs.categories.length === 0) { + return ( +
+

No utility categories configured.

+
+ ) + } -function UtilitiesPlaceholder() { return ( -
-

Utilities report — pending meter readings app integration.

-
+ <> +
+ + + + + + + + + + + + + + + + {costs.categories.map(c => ( + + + + + + + + + + + + ))} + + + + + + + + + + + + +
ConsumptionUsageStandingCCLRAB LevyFixed ExtrasVATTotal
{c.category_name}{fmtUnits(c.consumption, c.unit_label)}{fmtCcy(c.usage_cost_pence / 100)}{fmtCcy(c.standing_cost_pence / 100)}{fmtCcy(c.ccl_cost_pence / 100)}{fmtCcy(c.rab_levy_cost_pence / 100)}{fmtCcy((c.metering_cost_pence + c.other_charges_cost_pence) / 100)}{fmtCcy(c.vat_pence / 100)}{fmtCcy(c.total_pence / 100)}
Total{costs.totals.consumption.toLocaleString('en-GB', { maximumFractionDigits: 1 })}{fmtCcy(costs.totals.usage_cost_pence / 100)}{fmtCcy(costs.totals.standing_cost_pence / 100)}{fmtCcy(costs.totals.ccl_cost_pence / 100)}{fmtCcy(costs.totals.rab_levy_cost_pence / 100)}{fmtCcy((costs.totals.metering_cost_pence + costs.totals.other_charges_cost_pence) / 100)}{fmtCcy(costs.totals.vat_pence / 100)}{fmtCcy(costs.totals.total_pence / 100)}
+
+ {estimate && ( +

+ 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)} +

+ )} + ) } @@ -580,7 +648,7 @@ export default function WeeklyActual() { {/* ── Utilities ────────────────────────────────────────────── */}

Utilities

- +
)} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 95a4eb09..3e5bc9fc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -125,6 +125,50 @@ export interface MonthSplitEntry { wet: number } +export interface UtilityCategoryCost { + category: string + category_name: string + unit_label: string + consumption: number + 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 +} + +export interface UtilityCostTotals { + consumption: number + 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 +} + +export interface UtilityCosts { + period: { start: string; end: string; days_in_period: number } + categories: UtilityCategoryCost[] + totals: UtilityCostTotals +} + +export interface UtilityEstimate { + period: { year: number; month: number; start: string; end: string; remaining_days: number } + totals: { total_pence: number; projected_consumption: number } +} + +export interface UtilitiesReport { + costs: UtilityCosts | null + estimate: UtilityEstimate | null + error: string | null +} + export interface WeeklyActualData { week_ending: string week_start: string @@ -139,6 +183,7 @@ export interface WeeklyActualData { month_daily: MonthDailyEntry[] monthly_trend: MonthTrend[] monthly_split: MonthSplitEntry[] + utilities: UtilitiesReport } // ── Directors Forecast types ────────────────────────────────────────────────