/** * CostView — cost trends + detail + summary. * * Features: * - Date range selector: Today / This month / Custom (SegmentedControl + DateInput). * - Recharts AreaChart showing import_cost, export_revenue, net_cost per period. * - Table showing per-15min periods with time, kWh values, costs, degraded badge. * - Summary cards: metered import/export, fixed costs, credits, total payable. * - "Recompute" button with confirmation. * - Loading/error/empty states, degraded period highlighting. * * Recharts imports are isolated to this file only. */ import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Stack, Text, TextInput, Loader, Center, Alert, Table, Group, Badge, ScrollArea, SegmentedControl, Button, Paper, SimpleGrid, Modal, Title, } from '@mantine/core' import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, } from 'recharts' import { useEnergyCosts, useEnergyCostSummary, useRecomputeCosts } from './hooks' import { formatLocalTime } from '../utils/datetime' import apiClient from '../api/client' // --------------------------------------------------------------------------- // Cost limit — prevent accidental full-table pulls // --------------------------------------------------------------------------- const COSTS_MAX_LIMIT = 500 // --------------------------------------------------------------------------- // Date range helpers // --------------------------------------------------------------------------- function getTodayRange(): { start: string; end: string } { // Use local date boundary so "today" matches what the user sees in the display. const now = new Date() const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) return { start: start.toISOString(), end: end.toISOString() } } function getThisMonthRange(): { start: string; end: string } { // Use local date boundary so "this month" matches what the user sees in the display. const now = new Date() const start = new Date(now.getFullYear(), now.getMonth(), 1) const end = new Date(now.getFullYear(), now.getMonth() + 1, 1) return { start: start.toISOString(), end: end.toISOString() } } // --------------------------------------------------------------------------- // Summary cards // --------------------------------------------------------------------------- interface SummaryCardProps { label: string value: string /** Optional secondary line, e.g. the monetary equivalent of an energy figure. */ sub?: string testId?: string } function SummaryCard({ label, value, sub, testId }: SummaryCardProps) { return ( {label} {value} {sub !== undefined && ( {sub} )} ) } // --------------------------------------------------------------------------- // CostView — main component // --------------------------------------------------------------------------- type RangePreset = 'today' | 'month' | 'custom' export function CostView() { const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity') if (scope === 'thermal') return return } function ScopeSelector({ scope, onScopeChange }: { scope: 'electricity' | 'thermal'; onScopeChange: (scope: 'electricity' | 'thermal') => void }) { return onScopeChange(value as 'electricity' | 'thermal')} data={[{ label: 'Electricity', value: 'electricity' }, { label: 'Thermal', value: 'thermal' }]} data-testid="costs-scope-selector" /> } function ElectricityCostView({ onScopeChange }: { onScopeChange: (scope: 'electricity' | 'thermal') => void }) { const [rangePreset, setRangePreset] = useState('today') // Date strings in YYYY-MM-DD format for custom range const [customStartStr, setCustomStartStr] = useState('') const [customEndStr, setCustomEndStr] = useState('') const [showRecomputeConfirm, setShowRecomputeConfirm] = useState(false) // Compute effective date range const { start, end } = (() => { if (rangePreset === 'today') return getTodayRange() if (rangePreset === 'month') return getThisMonthRange() return { start: customStartStr ? new Date(customStartStr).toISOString() : undefined, end: customEndStr ? new Date(customEndStr).toISOString() : undefined, } })() const costsQuery = useEnergyCosts(start, end, COSTS_MAX_LIMIT) const summaryQuery = useEnergyCostSummary(start, end) const recomputeMutation = useRecomputeCosts() async function handleRecompute() { setShowRecomputeConfirm(false) await recomputeMutation.mutateAsync({ start, end }) } // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- const currency = costsQuery.data?.items[0]?.currency ?? summaryQuery.data?.currency ?? 'EUR' return ( {/* Date range selector */} Date range setRangePreset(v as RangePreset)} data={[ { label: 'Today', value: 'today' }, { label: 'This month', value: 'month' }, { label: 'Custom', value: 'custom' }, ]} data-testid="cost-range-control" /> {rangePreset === 'custom' && ( setCustomStartStr(e.currentTarget.value)} data-testid="cost-custom-start" /> setCustomEndStr(e.currentTarget.value)} data-testid="cost-custom-end" /> )} {/* Summary cards */} {summaryQuery.isLoading && (
)} {summaryQuery.isError && ( Failed to load cost summary. )} {summaryQuery.data && ( Summary )} {/* Cost chart */} {costsQuery.isLoading && (
)} {costsQuery.isError && ( Failed to load cost periods. Please refresh. )} {costsQuery.data && costsQuery.data.items.length === 0 && ( No cost data available for the selected period. )} {costsQuery.data && costsQuery.data.items.length > 0 && ( <> {/* Area chart */} Cost trends ({currency}) ({ time: formatLocalTime(item.period_start), import_cost: item.import_cost, export_revenue: item.export_revenue, net_cost: item.net_cost, }))} margin={{ top: 4, right: 16, left: 0, bottom: 4 }} > v.toFixed(2)} /> [`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined] } /> {/* Detail table */} Period detail Time Import kWh Export kWh Import cost Export rev. Net cost {costsQuery.data.items.map((item, idx) => ( {formatLocalTime(item.period_start)} {(item.d1_kwh + item.d2_kwh).toFixed(3)} {(item.r1_kwh + item.r2_kwh).toFixed(3)} {item.import_cost.toFixed(4)} {item.export_revenue.toFixed(4)} {item.net_cost.toFixed(4)} {item.degraded && ( degraded )} ))}
)} {/* Recompute confirmation modal */} {showRecomputeConfirm && ( setShowRecomputeConfirm(false)} title="Recompute costs?" size="sm" data-testid="recompute-confirm-modal" > This will recompute all cost periods for the selected date range. Continue? )}
) } function ThermalCostView({ onScopeChange }: { onScopeChange: (scope: 'electricity' | 'thermal') => void }) { const [rangePreset, setRangePreset] = useState('today') const [customStartStr, setCustomStartStr] = useState('') const [customEndStr, setCustomEndStr] = useState('') const [showConfirm, setShowConfirm] = useState(false) const [recomputeError, setRecomputeError] = useState(null) const [recomputeSuccess, setRecomputeSuccess] = useState(null) const [expandedRows, setExpandedRows] = useState>(() => new Set()) const [ledgerOffset, setLedgerOffset] = useState(0) const { start, end } = (() => { if (rangePreset === 'today') return getTodayRange() if (rangePreset === 'month') return getThisMonthRange() return { start: customStartStr ? new Date(customStartStr).toISOString() : undefined, end: customEndStr ? new Date(customEndStr).toISOString() : undefined, } })() // The server only accepts complete UTC quarters. Never send a future end. const closedEnd = (() => { const now = new Date() now.setUTCMinutes(Math.floor(now.getUTCMinutes() / 15) * 15, 0, 0) const selectedEnd = end ? new Date(end) : now return new Date(Math.min(selectedEnd.getTime(), now.getTime())).toISOString() })() const recomputeStart = start const recomputeAvailable = !!recomputeStart && new Date(recomputeStart) < new Date(closedEnd) const qc = useQueryClient() const resetLedgerPage = () => { setLedgerOffset(0) setExpandedRows(new Set()) } const rows = useQuery({ queryKey: ['meter-costs', 'thermal', start, end, ledgerOffset], queryFn: async () => { const result = await apiClient.GET('/api/energy/meter-costs', { params: { query: { scope: 'thermal', start, end, limit: COSTS_MAX_LIMIT, offset: ledgerOffset } } }) return result.data } }) const meters = useQuery({ queryKey: ['energy-meters', 'thermal'], queryFn: async () => { const result = await apiClient.GET('/api/energy/meters') return result.data } }) const summary = useQuery({ queryKey: ['meter-cost-summary', 'thermal', start, end], queryFn: async () => { const result = await apiClient.GET('/api/energy/meter-costs/summary', { params: { query: { scope: 'thermal', start, end } } }) return result.data } }) const recompute = useMutation({ mutationFn: () => apiClient.POST('/api/energy/meter-costs/recompute', { params: { query: { scope: 'thermal', start: recomputeStart!, end: closedEnd } } }), onSuccess: (result) => { void qc.invalidateQueries({ queryKey: ['meter-costs', 'thermal'] }); void qc.invalidateQueries({ queryKey: ['meter-cost-summary', 'thermal'] }) setRecomputeSuccess(`Recomputed ${result.data?.processed ?? 0} closed periods.`) } }) const currency = summary.data?.currency ?? rows.data?.items[0]?.currency ?? 'EUR' const fixed = summary.data?.fixed_breakdown const hasCurrentHeatingMeter = meters.data?.items.some((meter) => meter.commodity === 'heating' && meter.ended_at === null) const hasCurrentHotWaterMeter = meters.data?.items.some((meter) => meter.commodity === 'hot_water' && meter.ended_at === null) const missingCurrentMeters = [ ...(hasCurrentHeatingMeter === false ? ['heating'] : []), ...(hasCurrentHotWaterMeter === false ? ['hot-water'] : []), ] const totalRows = rows.data?.total ?? 0 const shownStart = totalRows === 0 ? 0 : ledgerOffset + 1 const shownEnd = Math.min(ledgerOffset + (rows.data?.items.length ?? 0), totalRows) return Date range { resetLedgerPage(); setRangePreset(value as RangePreset) }} data={[{ label: 'Today', value: 'today' }, { label: 'This month', value: 'month' }, { label: 'Custom', value: 'custom' }]} data-testid="thermal-cost-range-control" />{rangePreset === 'custom' && { resetLedgerPage(); setCustomStartStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-start" /> { resetLedgerPage(); setCustomEndStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-end" />} {(rows.isLoading || summary.isLoading) &&
} {(rows.isError || summary.isError) && Failed to load thermal costs.} {recomputeError && {recomputeError}} {recomputeSuccess && {recomputeSuccess}} {summary.data && {rangePreset === 'today' ? 'Today' : rangePreset === 'month' ? 'This month' : 'Custom range'} ({currency}){start ?? 'Select a start date'} — {end ?? 'Select an end date'} {missingCurrentMeters.length > 0 && Current {missingCurrentMeters.join(' and ')} meter{missingCurrentMeters.length > 1 ? 's are' : ' is'} not configured. Historical ledger rows do not establish a current meter.}{summary.data.period_count} periods; {summary.data.degraded_count} degraded{summary.data.degraded_count > 0 && Some totals include degraded periods. Expand a row to see its recorded reason.}{fixed && Fixed once per settled local day (summary only): {Object.entries(fixed).map(([key, value]) => `${key} ${value}`).join(' · ')}}} {rows.data?.items.length === 0 && No thermal cost data for this range. Check that heating or hot-water meters are bound and have settled readings.} {rows.data && Showing {shownStart}-{shownEnd} of {totalRows}{rows.data.items.length > 0 && TimeCommodityQuantityCostBreakdownStatus{rows.data.items.flatMap((item, index) => [{formatLocalTime(item.period_start)}{item.commodity}{item.quantity}{item.cost} {item.currency}{Object.entries(item.cost_breakdown).map(([key, value]) => `${key} ${value}`).join(', ')}{item.degraded ? {item.degraded_reason ?? 'degraded'} : 'normal'}, ...(expandedRows.has(index) ? [Contract version: {item.contract_version_id ?? 'none'}Pricing snapshot: {JSON.stringify(item.pricing_snapshot)}] : [])])}
}
} {showConfirm && setShowConfirm(false)} title="Recompute thermal costs?" data-testid="thermal-recompute-confirm-modal">This explicitly overwrites closed 15-minute thermal ledger rows for {recomputeStart ?? 'the selected start'} — {closedEnd}. Continue?{!recomputeAvailable && Select a range containing at least one closed UTC quarter.}}
}