2026-06-25 16:45:11 +02:00
/**
* MeterManager — electricity meter timeline UI.
*
* Features:
* - Table of meter epochs: label / interval (started_at → ended_at or "active") /
* active badge / reason.
* - "Declare New Meter" button: form with label + date (started_at) + reason +
* optional note. Sends local-midnight naive datetime per FU10 convention.
* - Edit modal: update label, note, or correct started_at (retroactive).
* - Retroactive feedback: if started_at is changed, a success notice mentions
* that affected billing periods have been recomputed.
* - Loading / error / empty states.
*/
import { useState } from 'react'
import {
Table ,
Button ,
Group ,
Text ,
Loader ,
Center ,
Alert ,
Stack ,
Badge ,
ScrollArea ,
Modal ,
TextInput ,
Textarea ,
Select ,
Notification ,
} from '@mantine/core'
import {
useMeters ,
useDeclareMeter ,
useUpdateMeter ,
2026-08-23 14:13:56 +02:00
useSources ,
useSourceChannels ,
useMeterBindings ,
useCreateBinding ,
useCloseBinding ,
2026-06-25 16:45:11 +02:00
type MeterResponse ,
type MeterReason ,
} from './hooks'
import { ApiError } from '../api/client'
2026-08-23 14:13:56 +02:00
import { formatLocalDate , formatLocalDateTime , parseBackendTimestamp } from '../utils/datetime'
2026-06-25 16:45:11 +02:00
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const REASON_OPTIONS : { value : MeterReason ; label : string }[] = [
{ value : 'initial' , label : 'Initial installation' },
{ value : 'meter_swap' , label : 'Meter swap (same address)' },
{ value : 'home_move' , label : 'Home / address move' },
{ value : 'other' , label : 'Other' },
]
/**
* Convert a local date string "YYYY-MM-DD" to a naive local-midnight datetime
* string (no Z suffix) following the FU10 / ContractForm convention.
* The backend interprets naive datetimes as server local wall-clock time.
*/
function toLocalMidnightNaive ( dateStr : string ) : string {
return ` ${ dateStr } T00:00:00`
}
2026-06-25 18:17:24 +02:00
/**
* Format a Date object as a "YYYY-MM-DD" string using the browser's local timezone.
* Used to populate <input type="date"> fields.
* Returns '' if the Date is invalid.
*/
function toLocalDateInputString ( d : Date ) : string {
if ( isNaN ( d . getTime ())) return ''
const y = d . getFullYear ()
const m = String ( d . getMonth () + 1 ). padStart ( 2 , '0' )
const day = String ( d . getDate ()). padStart ( 2 , '0' )
return ` ${ y } - ${ m } - ${ day } `
}
2026-06-25 16:45:11 +02:00
// ---------------------------------------------------------------------------
// Declare meter form (modal)
// ---------------------------------------------------------------------------
interface DeclareMeterFormProps {
onClose : () => void
onSaved : () => void
}
function DeclareMeterForm ({ onClose , onSaved } : DeclareMeterFormProps ) {
const [ label , setLabel ] = useState ( '' )
const [ dateStr , setDateStr ] = useState ( '' )
const [ reason , setReason ] = useState < string | null >( null )
const [ note , setNote ] = useState ( '' )
const [ error , setError ] = useState < string | null >( null )
2026-08-23 14:13:56 +02:00
const [ commodity , setCommodity ] = useState < string | null >( 'electricity' )
const [ sourceUuid , setSourceUuid ] = useState < string | null >( null )
const [ channelUuid , setChannelUuid ] = useState < string | null >( null )
const sources = useSources ()
const channels = useSourceChannels ( sourceUuid )
2026-06-25 16:45:11 +02:00
const declareMutation = useDeclareMeter ()
2026-08-23 14:13:56 +02:00
const compatible = ( channel : { unit : string ; binding_count : number ; bound_meter_ids : number [] }) =>
({ electricity : 'kWh' , heating : 'GJ' , hot_water : 'm³' } as Record < string , string >)[ commodity ?? 'electricity' ] === channel . unit && channel . binding_count === 0 && channel . bound_meter_ids . length === 0
2026-06-25 16:45:11 +02:00
async function handleSubmit ( e : React.FormEvent ) {
e . preventDefault ()
setError ( null )
if ( ! label . trim ()) {
setError ( 'Label is required.' )
return
}
if ( ! dateStr ) {
setError ( 'Start date is required.' )
return
}
if ( ! reason ) {
setError ( 'Reason is required.' )
return
}
try {
await declareMutation . mutateAsync ({
label : label.trim (),
started_at : toLocalMidnightNaive ( dateStr ),
reason : reason as MeterReason ,
note : note.trim () || undefined ,
2026-08-23 14:13:56 +02:00
commodity : commodity ?? 'electricity' ,
...( channelUuid ? { source_channel_uuid : channelUuid } : {}),
2026-06-25 16:45:11 +02:00
})
onSaved ()
onClose ()
} catch ( err ) {
if ( err instanceof ApiError ) {
const detail = ( err . body as { detail? : string } | null ) ? . detail
setError ( detail ?? `Error ${ err . status } : failed to declare meter.` )
} else {
setError ( 'Failed to declare meter. Please try again.' )
}
}
}
return (
< Modal
opened
onClose = { onClose }
title = "Declare New Meter"
size = "md"
data-testid = "declare-meter-modal"
>
< form onSubmit = { handleSubmit } data-testid = "declare-meter-form" >
< Stack gap = "sm" >
< TextInput
label = "Label"
description = { 'Human-readable identifier, e.g. "2G meter @ Dorpsstraat 1"' }
required
value = { label }
onChange = {( e ) => setLabel ( e . currentTarget . value )}
data-testid = "meter-label"
/>
< TextInput
label = "Start date"
description = "Date in YYYY-MM-DD format (interpreted as local midnight)"
type = "date"
required
value = { dateStr }
onChange = {( e ) => setDateStr ( e . currentTarget . value )}
data-testid = "meter-started-at"
/>
< Select
label = "Reason"
required
data = { REASON_OPTIONS }
value = { reason }
onChange = { setReason }
data-testid = "meter-reason"
/>
2026-08-23 14:13:56 +02:00
< Select label = "Commodity" value = { commodity } onChange = { setCommodity } data = {[
{ value : 'electricity' , label : 'Electricity' },
{ value : 'heating' , label : 'Heating' },
{ value : 'hot_water' , label : 'Hot water' },
]} />
< Select label = "Bind source (optional)" value = { sourceUuid } onChange = {( value ) => { setSourceUuid ( value ); setChannelUuid ( null ) }} data = { sources . data ? . items . filter (( source ) => typeof source . uuid === 'string' ). map (( source ) => ({ value : source.uuid , label : source.name })) ?? []} />
{ sourceUuid && < Select label = "Compatible source channel (optional)" value = { channelUuid } onChange = { setChannelUuid } description = "Only unbound channels with the required unit are eligible. Suggestions are informational only." data = { channels . data ? . items . filter ( compatible ). map (( channel ) => ({ value : channel.uuid , label : ` ${ channel . label } ( ${ channel . unit } ) ${ channel . suggested_commodity ? ` — suggestion: ${ channel . suggested_commodity } ` : '' } ` })) ?? []} />}
2026-06-25 16:45:11 +02:00
< Textarea
label = "Note (optional)"
value = { note }
onChange = {( e ) => setNote ( e . currentTarget . value )}
autosize
minRows = { 2 }
data-testid = "meter-note"
/>
{ error && (
< Alert color = "red" data-testid = "declare-meter-error" >
{ error }
</ Alert >
)}
< Group justify = "flex-end" gap = "sm" >
< Button
type = "button"
variant = "default"
onClick = { onClose }
data-testid = "declare-meter-cancel"
>
Cancel
</ Button >
< Button
type = "submit"
loading = { declareMutation . isPending }
data-testid = "declare-meter-submit"
>
Declare Meter
</ Button >
</ Group >
</ Stack >
</ form >
</ Modal >
)
}
// ---------------------------------------------------------------------------
// Edit meter form (modal)
// ---------------------------------------------------------------------------
interface EditMeterFormProps {
meter : MeterResponse
onClose : () => void
onSaved : ( retroactive : boolean ) => void
}
function EditMeterForm ({ meter , onClose , onSaved } : EditMeterFormProps ) {
const [ label , setLabel ] = useState ( meter . label )
const [ note , setNote ] = useState ( meter . note ?? '' )
2026-06-25 18:17:24 +02:00
// Convert started_at to a local date string for the <input type="date">.
// parseBackendTimestamp correctly handles naive strings (no tz marker → treated as UTC,
// matching the backend's storage convention), Z-suffixed strings, and strings with
// explicit offsets like +02:00. We then extract the local-timezone date components
// so the displayed date matches the local wall-clock date of the meter start.
const initialDateStr = toLocalDateInputString ( parseBackendTimestamp ( meter . started_at ))
2026-06-25 16:45:11 +02:00
const [ dateStr , setDateStr ] = useState ( initialDateStr )
const [ error , setError ] = useState < string | null >( null )
const updateMutation = useUpdateMeter ()
// Detect if the user changed started_at (retroactive correction).
const startedAtChanged = dateStr !== initialDateStr
async function handleSubmit ( e : React.FormEvent ) {
e . preventDefault ()
setError ( null )
const patchBody : { label? : string | null ; note? : string | null ; started_at? : string | null } = {}
if ( label . trim () !== meter . label ) patchBody . label = label . trim ()
const noteVal = note . trim () || null
if ( noteVal !== meter . note ) patchBody . note = noteVal
if ( startedAtChanged && dateStr ) {
patchBody . started_at = toLocalMidnightNaive ( dateStr )
}
if ( Object . keys ( patchBody ). length === 0 ) {
onClose ()
return
}
try {
await updateMutation . mutateAsync ({ id : meter.id , body : patchBody })
onSaved ( startedAtChanged )
onClose ()
} catch ( err ) {
if ( err instanceof ApiError ) {
const detail = ( err . body as { detail? : string } | null ) ? . detail
setError ( detail ?? `Error ${ err . status } : failed to update meter.` )
} else {
setError ( 'Failed to update meter. Please try again.' )
}
}
}
return (
< Modal
opened
onClose = { onClose }
title = { `Edit Meter — ${ meter . label } ` }
size = "md"
data-testid = "edit-meter-modal"
>
< form onSubmit = { handleSubmit } data-testid = "edit-meter-form" >
< Stack gap = "sm" >
< TextInput
label = "Label"
required
value = { label }
onChange = {( e ) => setLabel ( e . currentTarget . value )}
data-testid = "edit-meter-label"
/>
< TextInput
label = "Start date"
description = "Retroactive correction: shifts the epoch boundary and re-judges billing periods"
type = "date"
value = { dateStr }
onChange = {( e ) => setDateStr ( e . currentTarget . value )}
data-testid = "edit-meter-started-at"
/>
< Textarea
label = "Note (optional)"
value = { note }
onChange = {( e ) => setNote ( e . currentTarget . value )}
autosize
minRows = { 2 }
data-testid = "edit-meter-note"
/>
{ error && (
< Alert color = "red" data-testid = "edit-meter-error" >
{ error }
</ Alert >
)}
< Group justify = "flex-end" gap = "sm" >
< Button
type = "button"
variant = "default"
onClick = { onClose }
data-testid = "edit-meter-cancel"
>
Cancel
</ Button >
< Button
type = "submit"
loading = { updateMutation . isPending }
data-testid = "edit-meter-submit"
>
Save
</ Button >
</ Group >
</ Stack >
</ form >
</ Modal >
)
}
// ---------------------------------------------------------------------------
// Meter timeline table
// ---------------------------------------------------------------------------
interface MeterTableProps {
meters : MeterResponse []
onEdit : ( meter : MeterResponse ) => void
}
function MeterTable ({ meters , onEdit } : MeterTableProps ) {
if ( meters . length === 0 ) {
return (
< Text c = "dimmed" ta = "center" size = "sm" data-testid = "meters-empty" >
No meters declared yet . Click "Declare New Meter" to add one .
</ Text >
)
}
return (
< ScrollArea >
< Table striped highlightOnHover withTableBorder data-testid = "meters-table" >
< Table.Thead >
< Table.Tr >
< Table.Th > Label </ Table.Th >
< Table.Th > Commodity </ Table.Th >
< Table.Th > From </ Table.Th >
< Table.Th > To </ Table.Th >
< Table.Th > Status </ Table.Th >
< Table.Th > Reason </ Table.Th >
2026-08-23 14:13:56 +02:00
< Table.Th > Binding timeline </ Table.Th >
2026-06-25 16:45:11 +02:00
< Table.Th style = {{ textAlign : 'right' }}> Actions </ Table.Th >
</ Table.Tr >
</ Table.Thead >
< Table.Tbody >
{ meters . map (( meter ) => {
const isActive = meter . ended_at === null
return (
< Table.Tr key = { meter . id } data-testid = { `meter-row- ${ meter . id } ` }>
< Table.Td >
< Text fw = { 500 } size = "sm" >
{ meter . label }
</ Text >
</ Table.Td >
< Table.Td >
< Badge variant = "outline" size = "sm" >
{ meter . commodity }
</ Badge >
</ Table.Td >
< Table.Td >
< Text size = "xs" c = "dimmed" >
{ formatLocalDate ( meter . started_at )}
</ Text >
</ Table.Td >
< Table.Td >
< Text size = "xs" c = "dimmed" >
{ meter . ended_at ? formatLocalDate ( meter . ended_at ) : '—' }
</ Text >
</ Table.Td >
< Table.Td >
< Badge
color = { isActive ? 'green' : 'gray' }
variant = "light"
size = "sm"
data-testid = { `meter-status- ${ meter . id } ` }
>
{ isActive ? 'active' : 'closed' }
</ Badge >
</ Table.Td >
< Table.Td >
< Badge variant = "outline" size = "sm" color = "blue" >
{ meter . reason }
</ Badge >
</ Table.Td >
2026-08-23 14:13:56 +02:00
< Table.Td >
{ meter . bindings ? . length ? meter . bindings . map (( binding ) => (
< Stack key = { binding . uuid } gap = { 0 } mb = "xs" data-testid = { `binding-timeline- ${ binding . uuid } ` }>
< Text size = "xs" >{ binding . source_uuid } → { binding . source_channel_uuid }</ Text >
< Text size = "xs" c = "dimmed" >
[{ formatLocalDateTime ( binding . started_at )}, { binding . ended_at ? formatLocalDateTime ( binding . ended_at ) : 'open-ended' })
{ ' ' }({ binding . ended_at ? 'closed' : 'active' })
</ Text >
</ Stack >
)) : < Text size = "xs" c = "dimmed" > Unbound </ Text >}
</ Table.Td >
2026-06-25 16:45:11 +02:00
< Table.Td >
< Group justify = "flex-end" gap = "xs" >
2026-08-23 14:13:56 +02:00
{ isActive && < SourceSwitchButton meter = { meter } />}
2026-06-25 16:45:11 +02:00
< Button
size = "xs"
variant = "outline"
onClick = {() => onEdit ( meter )}
data-testid = { `meter-edit- ${ meter . id } ` }
>
Edit
</ Button >
</ Group >
</ Table.Td >
</ Table.Tr >
)
})}
</ Table.Tbody >
</ Table >
</ ScrollArea >
)
}
2026-08-23 14:13:56 +02:00
function SourceSwitchButton ({ meter } : { meter : MeterResponse }) {
const [ opened , setOpened ] = useState ( false )
const [ sourceUuid , setSourceUuid ] = useState < string | null >( null )
const [ channelUuid , setChannelUuid ] = useState < string | null >( null )
const [ error , setError ] = useState < string | null >( null )
const sources = useSources (); const channels = useSourceChannels ( sourceUuid ); const bindings = useMeterBindings ( opened ? meter.id : null )
const create = useCreateBinding (); const close = useCloseBinding ()
const compatible = ( channel : { unit : string ; binding_count : number ; bound_meter_ids : number [] }) => ({ electricity : 'kWh' , heating : 'GJ' , hot_water : 'm³' } as Record < string , string >)[ meter . commodity ] === channel . unit && channel . binding_count === 0 && channel . bound_meter_ids . length === 0
const timeline = bindings . data ? . items
const timelineReady = bindings . isSuccess && !! timeline
async function save() { if ( ! timelineReady ) return setError ( 'Binding timeline has not loaded; no change was made.' ); if ( ! channelUuid ) return setError ( 'Select a compatible unbound source channel.' ); setError ( null )
const started_at = new Date (). toISOString (); const active = bindings . data ? . items . find (( binding ) => ! binding . ended_at )
try { if ( active ) await close . mutateAsync ({ uuid : active.uuid , ended_at : started_at }) } catch ( err ) { const detail = err instanceof ApiError ? String (( err . body as { detail? : string }) ? . detail ?? '' ) : '' ; return setError ( `Could not close the old binding; no change was made. ${ detail ? ` ${ detail } ` : '' } ` ) }
try { await create . mutateAsync ({ id : meter.id , body : { source_channel_uuid : channelUuid , started_at } }); setOpened ( false ) } catch ( err ) { const detail = err instanceof ApiError ? String (( err . body as { detail? : string }) ? . detail ?? '' ) : '' ; setError ( `Old binding was closed, but creating the new binding failed. Retry after resolving the error. ${ detail ? ` ${ detail } ` : '' } ` ) }
}
return <>< Button size = "xs" variant = "subtle" onClick = {() => setOpened ( true )}> Switch source </ Button >{ opened && < Modal opened onClose = {() => setOpened ( false )} title = "Switch source binding" >< Stack >< Alert color = "blue" > This is a two - step close then create process , not a meter swap . If create fails after close , the old binding remains closed and you can retry .</ Alert >{ bindings . isLoading && < Alert color = "blue" > Loading binding timeline … </ Alert >}{ bindings . isError && < Alert color = "red" > Failed to load binding timeline ; no change was made .</ Alert >}{ timelineReady && timeline . length === 0 && < Alert color = "gray" > No existing bindings for this meter .</ Alert >}< Select label = "Source" value = { sourceUuid } onChange = {( value ) => { setSourceUuid ( value ); setChannelUuid ( null ) }} data = { sources . data ? . items . filter (( source ) => typeof source . uuid === 'string' ). map (( source ) => ({ value : source.uuid , label : source.name })) ?? []} />< Select label = "Compatible unbound channel" value = { channelUuid } onChange = { setChannelUuid } description = "Eligibility is based on unit and binding state; suggestions are informational." data = { channels . data ? . items . filter ( compatible ). map (( channel ) => ({ value : channel.uuid , label : ` ${ channel . label } ( ${ channel . unit } )` })) ?? []} />{ error && < Alert color = "red" >{ error }</ Alert >}< Group justify = "flex-end" >< Button variant = "default" onClick = {() => setOpened ( false )}> Cancel </ Button >< Button onClick = { save } loading = { create . isPending || close . isPending } disabled = { ! timelineReady }> Switch binding </ Button ></ Group ></ Stack ></ Modal >}</>
}
2026-06-25 16:45:11 +02:00
// ---------------------------------------------------------------------------
// MeterManager — top-level
// ---------------------------------------------------------------------------
export function MeterManager() {
const metersQuery = useMeters ()
const [ showDeclareForm , setShowDeclareForm ] = useState ( false )
const [ editMeter , setEditMeter ] = useState < MeterResponse | null >( null )
const [ recomputeNotice , setRecomputeNotice ] = useState ( false )
// ---------------------------------------------------------------------------
// Render states
// ---------------------------------------------------------------------------
if ( metersQuery . isLoading ) {
return (
< Center py = "xl" data-testid = "meters-loading" >
< Loader />
</ Center >
)
}
if ( metersQuery . isError || ! metersQuery . data ) {
return (
< Alert color = "red" data-testid = "meters-load-error" >
Failed to load meters . Please refresh .
</ Alert >
)
}
const meters = metersQuery . data . items
return (
< Stack gap = "lg" data-testid = "meter-manager" >
< Group justify = "space-between" align = "center" >
2026-08-23 14:13:56 +02:00
< Text fw = { 500 }> Meters </ Text >
2026-06-25 16:45:11 +02:00
< Button
onClick = {() => setShowDeclareForm ( true )}
data-testid = "meter-declare-button"
>
Declare New Meter
</ Button >
</ Group >
{ recomputeNotice && (
< Notification
color = "teal"
title = "Billing periods recomputed"
onClose = {() => setRecomputeNotice ( false )}
data-testid = "meter-recompute-notice"
>
The start date was corrected . Affected billing periods have been
re - judged and cost attributions updated .
</ Notification >
)}
< MeterTable meters = { meters } onEdit = {( m ) => setEditMeter ( m )} />
{ /* Declare new meter */ }
{ showDeclareForm && (
< DeclareMeterForm
onClose = {() => setShowDeclareForm ( false )}
onSaved = {() => setShowDeclareForm ( false )}
/>
)}
{ /* Edit existing meter */ }
{ editMeter && (
< EditMeterForm
meter = { editMeter }
onClose = {() => setEditMeter ( null )}
onSaved = {( retroactive ) => {
setEditMeter ( null )
if ( retroactive ) setRecomputeNotice ( true )
}}
/>
)}
</ Stack >
)
}