2026-06-23 23:32:39 +02:00
/**
* 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'
2026-08-23 15:47:41 +02:00
import { useMutation , useQuery , useQueryClient } from '@tanstack/react-query'
2026-06-23 23:32:39 +02:00
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'
2026-06-24 13:22:07 +02:00
import { formatLocalTime } from '../utils/datetime'
2026-08-23 15:47:41 +02:00
import apiClient from '../api/client'
2026-06-23 23:32:39 +02:00
// ---------------------------------------------------------------------------
// Cost limit — prevent accidental full-table pulls
// ---------------------------------------------------------------------------
const COSTS_MAX_LIMIT = 500
// ---------------------------------------------------------------------------
// Date range helpers
// ---------------------------------------------------------------------------
function getTodayRange () : { start : string ; end : string } {
2026-06-24 13:22:07 +02:00
// 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 )
2026-06-23 23:32:39 +02:00
return { start : start.toISOString (), end : end.toISOString () }
}
function getThisMonthRange () : { start : string ; end : string } {
2026-06-24 13:22:07 +02:00
// Use local date boundary so "this month" matches what the user sees in the display.
2026-06-23 23:32:39 +02:00
const now = new Date ()
2026-06-24 13:22:07 +02:00
const start = new Date ( now . getFullYear (), now . getMonth (), 1 )
const end = new Date ( now . getFullYear (), now . getMonth () + 1 , 1 )
2026-06-23 23:32:39 +02:00
return { start : start.toISOString (), end : end.toISOString () }
}
// ---------------------------------------------------------------------------
// Summary cards
// ---------------------------------------------------------------------------
interface SummaryCardProps {
label : string
value : string
2026-08-06 22:20:56 +02:00
/** Optional secondary line, e.g. the monetary equivalent of an energy figure. */
sub? : string
2026-06-23 23:32:39 +02:00
testId? : string
}
2026-08-06 22:20:56 +02:00
function SummaryCard ({ label , value , sub , testId } : SummaryCardProps ) {
2026-06-23 23:32:39 +02:00
return (
< Paper withBorder p = "sm" data-testid = { testId }>
< Stack gap = { 4 }>
< Text size = "xs" c = "dimmed" >
{ label }
</ Text >
< Text fw = { 600 } size = "lg" >
{ value }
</ Text >
2026-08-06 22:20:56 +02:00
{ sub !== undefined && (
< Text size = "xs" c = "dimmed" data-testid = { testId ? ` ${ testId } -sub` : undefined }>
{ sub }
</ Text >
)}
2026-06-23 23:32:39 +02:00
</ Stack >
</ Paper >
)
}
// ---------------------------------------------------------------------------
// CostView — main component
// ---------------------------------------------------------------------------
type RangePreset = 'today' | 'month' | 'custom'
export function CostView() {
2026-08-23 15:47:41 +02:00
const [ scope , setScope ] = useState < 'electricity' | 'thermal' > ( 'electricity' )
if ( scope === 'thermal' ) return < ThermalCostView onScopeChange = { setScope } />
return < ElectricityCostView onScopeChange = { setScope } />
}
function ScopeSelector ({ scope , onScopeChange } : { scope : 'electricity' | 'thermal' ; onScopeChange : ( scope : 'electricity' | 'thermal' ) => void }) {
return < SegmentedControl value = { scope } onChange = {( value ) => 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 }) {
2026-06-23 23:32:39 +02:00
const [ rangePreset , setRangePreset ] = useState < RangePreset >( '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 (
< Stack gap = "lg" data-testid = "cost-view" >
{ /* Date range selector */ }
< Group align = "flex-start" gap = "md" wrap = "wrap" >
2026-08-23 15:47:41 +02:00
< ScopeSelector scope = "electricity" onScopeChange = { onScopeChange } />
2026-06-23 23:32:39 +02:00
< Stack gap = "xs" >
< Text size = "sm" fw = { 500 }>
Date range
</ Text >
< SegmentedControl
value = { rangePreset }
onChange = {( v ) => setRangePreset ( v as RangePreset )}
data = {[
{ label : 'Today' , value : 'today' },
{ label : 'This month' , value : 'month' },
{ label : 'Custom' , value : 'custom' },
]}
data-testid = "cost-range-control"
/>
</ Stack >
{ rangePreset === 'custom' && (
< Group gap = "sm" align = "flex-end" >
< TextInput
label = "From"
type = "date"
value = { customStartStr }
onChange = {( e ) => setCustomStartStr ( e . currentTarget . value )}
data-testid = "cost-custom-start"
/>
< TextInput
label = "To"
type = "date"
value = { customEndStr }
onChange = {( e ) => setCustomEndStr ( e . currentTarget . value )}
data-testid = "cost-custom-end"
/>
</ Group >
)}
< Group gap = "sm" style = {{ marginLeft : 'auto' }} align = "flex-end" >
< Button
variant = "outline"
color = "orange"
size = "sm"
onClick = {() => setShowRecomputeConfirm ( true )}
loading = { recomputeMutation . isPending }
data-testid = "cost-recompute-button"
>
Recompute
</ Button >
</ Group >
</ Group >
{ /* Summary cards */ }
{ summaryQuery . isLoading && (
< Center >
< Loader size = "sm" />
</ Center >
)}
{ summaryQuery . isError && (
< Alert color = "red" data-testid = "summary-error" >
Failed to load cost summary .
</ Alert >
)}
{ summaryQuery . data && (
< Stack gap = "xs" >
< Title order = { 6 } c = "dimmed" >
Summary
</ Title >
< SimpleGrid cols = {{ base : 2 , sm : 3 }} spacing = "sm" data-testid = "cost-summary" >
< SummaryCard
label = "Import (kWh)"
2026-08-06 22:20:56 +02:00
value = { summaryQuery . data . metered_import_kwh . toFixed ( 3 )}
sub = { ` ${ summaryQuery . data . metered_import . toFixed ( 2 ) } ${ currency } ` }
2026-06-23 23:32:39 +02:00
testId = "summary-import"
/>
< SummaryCard
label = "Export (kWh)"
2026-08-06 22:20:56 +02:00
value = { summaryQuery . data . metered_export_kwh . toFixed ( 3 )}
sub = { ` ${ summaryQuery . data . metered_export . toFixed ( 2 ) } ${ currency } ` }
2026-06-23 23:32:39 +02:00
testId = "summary-export"
/>
< SummaryCard
label = { `Fixed costs ( ${ currency } )` }
value = { summaryQuery . data . fixed_costs . toFixed ( 2 )}
testId = "summary-fixed"
/>
< SummaryCard
label = { `Credits ( ${ currency } )` }
value = { summaryQuery . data . credits . toFixed ( 2 )}
testId = "summary-credits"
/>
< SummaryCard
label = { `Total payable ( ${ currency } )` }
value = { summaryQuery . data . total_payable . toFixed ( 2 )}
testId = "summary-total"
/>
</ SimpleGrid >
</ Stack >
)}
{ /* Cost chart */ }
{ costsQuery . isLoading && (
< Center py = "xl" data-testid = "costs-loading" >
< Loader />
</ Center >
)}
{ costsQuery . isError && (
< Alert color = "red" data-testid = "costs-error" >
Failed to load cost periods . Please refresh .
</ Alert >
)}
{ costsQuery . data && costsQuery . data . items . length === 0 && (
< Alert color = "gray" data-testid = "costs-empty" >
No cost data available for the selected period .
</ Alert >
)}
{ costsQuery . data && costsQuery . data . items . length > 0 && (
<>
{ /* Area chart */ }
< Stack gap = "xs" data-testid = "cost-chart" >
< Title order = { 6 } c = "dimmed" >
Cost trends ({ currency })
</ Title >
< ResponsiveContainer width = "100%" height = { 220 }>
< AreaChart
data = { costsQuery . data . items . map (( item ) => ({
2026-06-24 13:22:07 +02:00
time : formatLocalTime ( item . period_start ),
2026-06-23 23:32:39 +02:00
import_cost : item.import_cost ,
export_revenue : item.export_revenue ,
net_cost : item.net_cost ,
}))}
margin = {{ top : 4 , right : 16 , left : 0 , bottom : 4 }}
>
< CartesianGrid strokeDasharray = "3 3" />
< XAxis dataKey = "time" tick = {{ fontSize : 10 }} interval = "preserveStartEnd" />
< YAxis tick = {{ fontSize : 10 }} tickFormatter = {( v : number ) => v . toFixed ( 2 )} />
< Tooltip
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter = {( val : any ) =>
[ ` ${ typeof val === 'number' ? val . toFixed ( 4 ) : String ( val ) } ${ currency } ` , undefined ]
}
/>
< Legend />
< Area
type = "monotone"
dataKey = "import_cost"
stroke = "#2196f3"
fill = "#bbdefb"
name = "Import cost"
dot = { false }
/>
< Area
type = "monotone"
dataKey = "export_revenue"
stroke = "#4caf50"
fill = "#c8e6c9"
name = "Export revenue"
dot = { false }
/>
< Area
type = "monotone"
dataKey = "net_cost"
stroke = "#ff9800"
fill = "#ffe0b2"
name = "Net cost"
dot = { false }
/>
</ AreaChart >
</ ResponsiveContainer >
</ Stack >
{ /* Detail table */ }
< Stack gap = "xs" >
< Title order = { 6 } c = "dimmed" >
Period detail
</ Title >
< ScrollArea >
< Table
striped
highlightOnHover
withTableBorder
withColumnBorders
style = {{ fontSize : 12 }}
data-testid = "costs-table"
>
< Table.Thead >
< Table.Tr >
< Table.Th > Time </ Table.Th >
< Table.Th > Import kWh </ Table.Th >
< Table.Th > Export kWh </ Table.Th >
< Table.Th > Import cost </ Table.Th >
< Table.Th > Export rev .</ Table.Th >
< Table.Th > Net cost </ Table.Th >
< Table.Th ></ Table.Th >
</ Table.Tr >
</ Table.Thead >
< Table.Tbody >
{ costsQuery . data . items . map (( item , idx ) => (
< Table.Tr
key = { idx }
style = { item . degraded ? { opacity : 0.6 } : undefined }
data-testid = { `cost-row- ${ idx } ` }
>
< Table.Td >
< Text size = "xs" >
2026-06-24 13:22:07 +02:00
{ formatLocalTime ( item . period_start )}
2026-06-23 23:32:39 +02:00
</ Text >
</ Table.Td >
< Table.Td >{( item . d1_kwh + item . d2_kwh ). toFixed ( 3 )}</ Table.Td >
< Table.Td >{( item . r1_kwh + item . r2_kwh ). toFixed ( 3 )}</ Table.Td >
< Table.Td >{ item . import_cost . toFixed ( 4 )}</ Table.Td >
< Table.Td >{ item . export_revenue . toFixed ( 4 )}</ Table.Td >
< Table.Td >{ item . net_cost . toFixed ( 4 )}</ Table.Td >
< Table.Td >
{ item . degraded && (
< Badge color = "orange" size = "xs" data-testid = { `cost-degraded- ${ idx } ` }>
degraded
</ Badge >
)}
</ Table.Td >
</ Table.Tr >
))}
</ Table.Tbody >
</ Table >
</ ScrollArea >
</ Stack >
</>
)}
{ /* Recompute confirmation modal */ }
{ showRecomputeConfirm && (
< Modal
opened
onClose = {() => setShowRecomputeConfirm ( false )}
title = "Recompute costs?"
size = "sm"
data-testid = "recompute-confirm-modal"
>
< Stack gap = "md" >
< Text size = "sm" >
This will recompute all cost periods for the selected date range . Continue ?
</ Text >
< Group justify = "flex-end" gap = "sm" >
< Button
variant = "default"
onClick = {() => setShowRecomputeConfirm ( false )}
data-testid = "recompute-cancel"
>
Cancel
</ Button >
< Button
color = "orange"
onClick = { handleRecompute }
data-testid = "recompute-confirm"
>
Recompute
</ Button >
</ Group >
</ Stack >
</ Modal >
)}
</ Stack >
)
}
2026-08-23 15:47:41 +02:00
function ThermalCostView ({ onScopeChange } : { onScopeChange : ( scope : 'electricity' | 'thermal' ) => void }) {
const [ rangePreset , setRangePreset ] = useState < RangePreset >( 'today' )
const [ customStartStr , setCustomStartStr ] = useState ( '' )
const [ customEndStr , setCustomEndStr ] = useState ( '' )
const [ showConfirm , setShowConfirm ] = useState ( false )
const [ recomputeError , setRecomputeError ] = useState < string | null >( null )
const [ recomputeSuccess , setRecomputeSuccess ] = useState < string | null >( null )
const [ expandedRows , setExpandedRows ] = useState < Set < number >>(() => 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 < Stack gap = "lg" data-testid = "thermal-cost-view" >
< Group align = "flex-start" gap = "md" wrap = "wrap" >< ScopeSelector scope = "thermal" onScopeChange = { onScopeChange } />< Stack gap = "xs" >< Text size = "sm" fw = { 500 }> Date range </ Text >< SegmentedControl value = { rangePreset } onChange = {( value ) => { 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" /></ Stack >{ rangePreset === 'custom' && < Group gap = "sm" align = "flex-end" >< TextInput label = "From" type = "date" value = { customStartStr } onChange = {( event ) => { resetLedgerPage (); setCustomStartStr ( event . currentTarget . value ) }} data-testid = "thermal-cost-custom-start" />< TextInput label = "To" type = "date" value = { customEndStr } onChange = {( event ) => { resetLedgerPage (); setCustomEndStr ( event . currentTarget . value ) }} data-testid = "thermal-cost-custom-end" /></ Group >}< Button variant = "outline" color = "orange" onClick = {() => { setRecomputeError ( null ); setRecomputeSuccess ( null ); setShowConfirm ( true ) }} disabled = { ! recomputeAvailable } data-testid = "thermal-recompute-button" > Recompute </ Button ></ Group >
{( rows . isLoading || summary . isLoading ) && < Center >< Loader size = "sm" /></ Center >}
{( rows . isError || summary . isError ) && < Alert color = "red" > Failed to load thermal costs .</ Alert >}
{ recomputeError && < Alert color = "red" data-testid = "thermal-recompute-error" >{ recomputeError }</ Alert >}
{ recomputeSuccess && < Alert color = "green" data-testid = "thermal-recompute-success" >{ recomputeSuccess }</ Alert >}
{ summary . data && < Stack gap = "xs" data-testid = "thermal-cost-summary" >< Title order = { 6 }>{ rangePreset === 'today' ? 'Today' : rangePreset === 'month' ? 'This month' : 'Custom range' } ({ currency })</ Title >< Text size = "sm" data-testid = "thermal-cost-range" >{ start ?? 'Select a start date' } — { end ?? 'Select an end date' }</ Text >< SimpleGrid cols = {{ base : 2 , sm : 3 }}>
< SummaryCard label = "Heating" value = { hasCurrentHeatingMeter === false ? 'Not configured' : summary . data . heating } />< SummaryCard label = "Hot-water heating" value = { hasCurrentHotWaterMeter === false ? 'Not configured' : summary . data . hot_water_heating } />< SummaryCard label = "Hot water" value = { hasCurrentHotWaterMeter === false ? 'Not configured' : summary . data . hot_water } />< SummaryCard label = "Hot-water tax" value = { hasCurrentHotWaterMeter === false ? 'Not configured' : summary . data . hot_water_tax } />< SummaryCard label = "Variable subtotal" value = { summary . data . variable_subtotal } />< SummaryCard label = "Fixed subtotal" value = { summary . data . fixed_subtotal } />< SummaryCard label = "All-in total" value = { summary . data . all_in } />
</ SimpleGrid >{ missingCurrentMeters . length > 0 && < Alert color = "yellow" data-testid = "thermal-missing-current-meter" > Current { missingCurrentMeters . join ( ' and ' )} meter { missingCurrentMeters . length > 1 ? 's are' : ' is' } not configured . Historical ledger rows do not establish a current meter .</ Alert >}< Text size = "sm" data-testid = "thermal-period-count" >{ summary . data . period_count } periods ; { summary . data . degraded_count } degraded </ Text >{ summary . data . degraded_count > 0 && < Alert color = "orange" data-testid = "thermal-summary-degraded" > Some totals include degraded periods . Expand a row to see its recorded reason .</ Alert >}{ fixed && < Text size = "sm" data-testid = "thermal-fixed-breakdown" > Fixed once per settled local day ( summary only ) : { Object . entries ( fixed ). map (([ key , value ]) => ` ${ key } ${ value } ` ). join ( ' · ' )}</ Text >}</ Stack >}
{ rows . data ? . items . length === 0 && < Alert color = "gray" data-testid = "thermal-costs-empty" > No thermal cost data for this range . Check that heating or hot - water meters are bound and have settled readings .</ Alert >}
{ rows . data && < Stack gap = "xs" >< Text size = "sm" c = "dimmed" data-testid = "thermal-ledger-count" > Showing { shownStart } - { shownEnd } of { totalRows }</ Text >{ rows . data . items . length > 0 && < ScrollArea >< Table striped withTableBorder data-testid = "thermal-costs-table" >< Table.Thead >< Table.Tr >< Table.Th > Time </ Table.Th >< Table.Th > Commodity </ Table.Th >< Table.Th > Quantity </ Table.Th >< Table.Th > Cost </ Table.Th >< Table.Th > Breakdown </ Table.Th >< Table.Th > Status </ Table.Th >< Table.Th ></ Table.Th ></ Table.Tr ></ Table.Thead >< Table.Tbody >{ rows . data . items . flatMap (( item , index ) => [< Table.Tr key = { ` ${ item . commodity } - ${ item . period_start } ` } data-testid = { `thermal-cost-row- ${ index } ` }>< Table.Td >{ formatLocalTime ( item . period_start )}</ Table.Td >< Table.Td >{ item . commodity }</ Table.Td >< Table.Td >{ item . quantity }</ Table.Td >< Table.Td >{ item . cost } { item . currency }</ Table.Td >< Table.Td >{ Object . entries ( item . cost_breakdown ). map (([ key , value ]) => ` ${ key } ${ value } ` ). join ( ', ' )}</ Table.Td >< Table.Td >{ item . degraded ? < Badge color = "orange" data-testid = { `thermal-degraded- ${ index } ` }>{ item . degraded_reason ?? 'degraded' }</ Badge > : 'normal' }</ Table.Td >< Table.Td >< Button size = "xs" variant = "subtle" onClick = {() => setExpandedRows (( current ) => { const next = new Set ( current ); if ( next . has ( index )) next . delete ( index ); else next . add ( index ); return next })} data-testid = { `thermal-cost-expand- ${ index } ` }>{ expandedRows . has ( index ) ? 'Hide audit' : 'Audit' }</ Button ></ Table.Td ></ Table.Tr >, ...( expandedRows . has ( index ) ? [< Table.Tr key = { `audit- ${ item . commodity } - ${ item . period_start } ` } data-testid = { `thermal-cost-audit- ${ index } ` }>< Table.Td colSpan = { 7 }>< Text size = "xs" > Contract version : { item . contract_version_id ?? 'none' }</ Text >< Text size = "xs" > Pricing snapshot : { JSON . stringify ( item . pricing_snapshot )}</ Text ></ Table.Td ></ Table.Tr >] : [])])}</ Table.Tbody ></ Table ></ ScrollArea >}< Group justify = "flex-end" >< Button size = "xs" variant = "default" disabled = { ledgerOffset === 0 } onClick = {() => { setExpandedRows ( new Set ()); setLedgerOffset (( current ) => Math . max ( 0 , current - COSTS_MAX_LIMIT )) }} data-testid = "thermal-ledger-prev" > Previous </ Button >< Button size = "xs" variant = "default" disabled = { ledgerOffset + ( rows . data . items . length ?? 0 ) >= totalRows } onClick = {() => { setExpandedRows ( new Set ()); setLedgerOffset (( current ) => current + COSTS_MAX_LIMIT ) }} data-testid = "thermal-ledger-next" > Next </ Button ></ Group ></ Stack >}
{ showConfirm && < Modal opened onClose = {() => setShowConfirm ( false )} title = "Recompute thermal costs?" data-testid = "thermal-recompute-confirm-modal" >< Stack >< Text > This explicitly overwrites closed 15 - minute thermal ledger rows for { recomputeStart ?? 'the selected start' } — { closedEnd }. Continue ? </ Text >{ ! recomputeAvailable && < Alert color = "yellow" > Select a range containing at least one closed UTC quarter .</ Alert >}< Group justify = "flex-end" >< Button variant = "default" onClick = {() => setShowConfirm ( false )} data-testid = "thermal-recompute-cancel" > Cancel </ Button >< Button color = "orange" loading = { recompute . isPending } disabled = { ! recomputeAvailable } onClick = { async () => { try { await recompute . mutateAsync (); setShowConfirm ( false ) } catch { setRecomputeError ( 'Failed to recompute thermal costs. Please try again.' ); setShowConfirm ( false ) } }} data-testid = "thermal-recompute-confirm" > Recompute </ Button ></ Group ></ Stack ></ Modal >}
</ Stack >
}