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 ,
useCreateBinding ,
useCloseBinding ,
2026-08-24 13:38:02 +02:00
useCloseMeter ,
useTransferBinding ,
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-08-24 13:38:02 +02:00
function toLocalDateTimeInputString ( d = new Date ()) : string {
const pad = ( value : number ) => String ( value ). padStart ( 2 , '0' )
return ` ${ toLocalDateInputString ( d ) } T ${ pad ( d . getHours ()) } : ${ pad ( d . getMinutes ()) } `
}
function expectedUnit ( commodity : string ) : string {
return ({ electricity : 'kWh' , heating : 'GJ' , hot_water : 'm³' } as Record < string , string >)[ commodity ] ?? ''
}
function apiErrorMessage ( err : unknown , fallback : string ) : string {
if ( err instanceof ApiError ) {
const body = err . body
if ( typeof body === 'string' ) return body
if ( body && typeof body === 'object' && 'detail' in body ) {
const detail = ( body as { detail? : unknown }). detail
if ( typeof detail === 'string' ) return detail
if ( Array . isArray ( detail )) {
const messages = detail . map (( item ) => item && typeof item === 'object' && typeof item . msg === 'string'
? item.msg : String ( item )). filter ( Boolean )
if ( messages . length ) return messages . join ( '; ' )
}
if ( detail != null ) return typeof detail === 'object' ? JSON . stringify ( detail ) : String ( detail )
}
return ` ${ fallback } (error ${ err . status } ).`
}
return fallback
}
function isValidLocalDateTime ( value : string ) : boolean {
return value . trim () !== '' && ! Number . isNaN ( new Date ( value ). getTime ())
}
type Eligibility = { eligible : boolean ; reason? : string }
function localInstant ( value : string ) : number | null {
const instant = new Date ( value ). getTime ()
return Number . isNaN ( instant ) ? null : instant
}
function intervalsOverlap ( start : number , end : number | null , otherStart : number , otherEnd : number | null ) : boolean {
return ( end === null || otherStart < end ) && ( otherEnd === null || start < otherEnd )
}
function channelIntervalEligibility (
meters : MeterResponse [], channelUuid : string , startedAt : string , excludeBindingUuids : string [] = [],
) : Eligibility {
const start = localInstant ( startedAt )
if ( start === null ) return { eligible : false , reason : 'choose a valid start time first' }
const conflicts = meters . flatMap (( meter ) => ( meter . bindings ?? []). filter (( binding ) =>
binding . source_channel_uuid === channelUuid && ! excludeBindingUuids . includes ( binding . uuid ) &&
intervalsOverlap ( start , null , parseBackendTimestamp ( binding . started_at ). getTime (), binding . ended_at ? parseBackendTimestamp ( binding . ended_at ). getTime () : null ),
))
if ( ! conflicts . length ) return { eligible : true }
if ( conflicts . length > 1 ) return { eligible : false , reason : 'ambiguous: channel has overlapping binding history; resolve it first' }
return conflicts [ 0 ]. ended_at === null
? { eligible : false , reason : 'occupied by an open binding; close or transfer it first' }
: { eligible : false , reason : 'overlaps closed binding history; choose a time at or after it ends' }
}
function meterContainsInstant ( meter : MeterResponse , instant : number ) : boolean {
const start = parseBackendTimestamp ( meter . started_at ). getTime ()
const end = meter . ended_at ? parseBackendTimestamp ( meter . ended_at ). getTime () : null
return instant >= start && ( end === null || instant < end )
}
function recoveryTargetFor ( meter : MeterResponse , meters : MeterResponse []) : MeterResponse | null {
if ( meter . ended_at === null ) return null
const active = meters . filter (( candidate ) => candidate . commodity === meter . commodity && candidate . ended_at === null )
if ( active . length !== 1 ) return null
const target = active [ 0 ]
const targetStart = parseBackendTimestamp ( target . started_at ). getTime ()
const predecessors = meters . filter (( candidate ) => candidate . commodity === meter . commodity && candidate . ended_at !== null &&
parseBackendTimestamp ( candidate . ended_at ). getTime () <= targetStart )
const latestEnd = Math . max (... predecessors . map (( candidate ) => parseBackendTimestamp ( candidate . ended_at ! ). getTime ()))
const immediate = predecessors . filter (( candidate ) => parseBackendTimestamp ( candidate . ended_at ! ). getTime () === latestEnd )
if ( immediate . length !== 1 || immediate [ 0 ]. id !== meter . id ) return null
const sourceStart = parseBackendTimestamp ( meter . started_at ). getTime ()
const sourceEnd = parseBackendTimestamp ( meter . ended_at ). getTime ()
for ( const candidate of meters ) {
if ( candidate . id === meter . id || candidate . id === target . id || candidate . commodity !== meter . commodity ) continue
const candidateStart = parseBackendTimestamp ( candidate . started_at ). getTime ()
const candidateEnd = candidate . ended_at ? parseBackendTimestamp ( candidate . ended_at ). getTime () : null
if ( intervalsOverlap ( sourceStart , sourceEnd , candidateStart , candidateEnd ) ||
intervalsOverlap ( targetStart , null , candidateStart , candidateEnd )) return null
}
return target
}
2026-06-25 16:45:11 +02:00
// ---------------------------------------------------------------------------
// Declare meter form (modal)
// ---------------------------------------------------------------------------
interface DeclareMeterFormProps {
2026-08-24 02:33:34 +02:00
meters : MeterResponse []
2026-06-25 16:45:11 +02:00
onClose : () => void
onSaved : () => void
}
2026-08-24 02:33:34 +02:00
function DeclareMeterForm ({ meters , onClose , onSaved } : DeclareMeterFormProps ) {
2026-06-25 16:45:11 +02:00
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-24 13:38:02 +02:00
const unit = expectedUnit ( commodity ?? 'electricity' )
2026-08-24 02:33:34 +02:00
const oldMeter = meters . find (( meter ) => meter . commodity === commodity && meter . ended_at === null )
2026-08-24 13:38:02 +02:00
const channelEligibility = ( uuid : string , startedAt : string ) : Eligibility => {
2026-08-24 02:33:34 +02:00
const channel = channels . data ? . items . find (( item ) => item . uuid === uuid )
2026-08-24 13:38:02 +02:00
if ( ! channel ) return { eligible : false , reason : 'channel is unavailable; reload the source' }
if ( channel . unit !== unit ) return { eligible : false , reason : `unit mismatch: ${ channel . unit } ; this meter needs ${ unit } ` }
if ( ! startedAt ) return { eligible : false , reason : 'choose a start date first' }
const start = localInstant ( toLocalMidnightNaive ( startedAt ))
if ( start === null ) return { eligible : false , reason : 'choose a valid start date first' }
if ( startedAt > toLocalDateInputString ( new Date ())) return { eligible : false , reason : 'future start dates cannot bind a channel' }
2026-08-24 02:33:34 +02:00
// Channel aggregates include closed binding history. For a meter swap, only
// currently open bindings determine whether this channel can be handed off.
const openBindings = meters . flatMap (( meter ) =>
( meter . bindings ?? [])
. filter (( binding ) => binding . source_channel_uuid === channel . uuid && binding . ended_at === null )
. map (( binding ) => ({ meter , binding })),
)
const oldBinding = openBindings [ 0 ]
2026-08-24 13:38:02 +02:00
const isSingleOldBinding = reason === 'meter_swap' && oldMeter !== undefined &&
2026-08-24 02:33:34 +02:00
openBindings . length === 1 && oldBinding !== undefined &&
2026-08-24 13:38:02 +02:00
oldBinding . meter . id === oldMeter . id && oldBinding . meter . commodity === commodity
// A handoff must leave a non-empty interval on the old Meter. The backend
// rejects equality too, so surface it here instead of offering a request
// that is guaranteed to fail.
if ( isSingleOldBinding && start <= parseBackendTimestamp ( oldBinding . binding . started_at ). getTime ()) {
return { eligible : false , reason : 'handoff boundary must be strictly after the current binding start' }
}
const canHandoff = isSingleOldBinding
const interval = channelIntervalEligibility (
meters , channel . uuid , toLocalMidnightNaive ( startedAt ), canHandoff ? [ oldBinding . binding . uuid ] : [],
)
if ( ! interval . eligible ) return interval
if ( openBindings . length && ! canHandoff ) {
return openBindings . length > 1
? { eligible : false , reason : 'ambiguous: multiple open bindings; resolve them first' }
: { eligible : false , reason : 'occupied by an open binding; choose a different channel or close/transfer it first' }
}
return { eligible : true }
2026-08-24 02:33:34 +02:00
}
2026-08-24 13:38:02 +02:00
const channelOptions = channels . data ? . items . map (( channel ) => {
const result = channelEligibility ( channel . uuid , dateStr )
const canHandoff = ! isUnboundChannel ( channel . uuid ) && result . eligible
2026-08-24 02:33:34 +02:00
return {
value : channel.uuid ,
2026-08-24 13:38:02 +02:00
label : ` ${ channel . label } ( ${ channel . unit } ) ${ canHandoff ? ' — hand off from current meter' : result . reason ? ` — ${ result . reason } ` : '' } ` ,
disabled : ! result . eligible ,
2026-08-24 02:33:34 +02:00
}
}) ?? []
2026-08-24 13:38:02 +02:00
const selectedChannelUuid = channelUuid && channelEligibility ( channelUuid , dateStr ). eligible
2026-08-24 02:33:34 +02:00
? channelUuid
: null
2026-08-24 13:38:02 +02:00
function isUnboundChannel ( uuid : string ) : boolean {
return ! meters . some (( meter ) => meter . bindings ? . some (
( binding ) => binding . source_channel_uuid === uuid && binding . ended_at === null ,
))
}
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
}
2026-08-24 13:38:02 +02:00
if ( sourceUuid && ! selectedChannelUuid ) {
const unavailable = channelOptions . length === 1 && channelOptions [ 0 ]. disabled
? channelEligibility ( channelOptions [ 0 ]. value , dateStr ). reason
: undefined
setError ( unavailable ? ` ${ unavailable [ 0 ]. toUpperCase () }${ unavailable . slice ( 1 ) } ` : 'Select an eligible source channel or clear the optional source.' )
return
}
// Omitting a channel for meter_swap asks the backend to auto-handoff the
// sole open binding. Keep that implicit path subject to the same strict
// boundary rule as an explicitly selected channel.
const start = localInstant ( toLocalMidnightNaive ( dateStr ))
const oldMeter = meters . find (( meter ) => meter . commodity === commodity && meter . ended_at === null )
// The implicit backend handoff only considers bindings on the current
// commodity's old meter. Bindings are unit-compatible with their meter
// by the binding contract, so an unrelated heating/hot-water binding must
// neither make this ambiguous nor bypass this strict boundary check.
const autoHandoffCandidates = ( oldMeter ? . bindings ?? []). filter (( binding ) => binding . ended_at === null )
const oldBinding = autoHandoffCandidates [ 0 ]
if ( reason === 'meter_swap' && start !== null && oldMeter !== undefined && autoHandoffCandidates . length === 1 &&
oldBinding !== undefined && start <= parseBackendTimestamp ( oldBinding . started_at ). getTime ()) {
setError ( 'Handoff boundary must be strictly after the current binding start.' )
return
}
2026-06-25 16:45:11 +02:00
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' ,
2026-08-24 02:33:34 +02:00
...( selectedChannelUuid ? { source_channel_uuid : selectedChannelUuid } : {}),
2026-06-25 16:45:11 +02:00
})
onSaved ()
onClose ()
2026-08-24 13:38:02 +02:00
} catch ( err ) { setError ( apiErrorMessage ( err , 'Failed to declare meter. Please try again.' )) }
2026-06-25 16:45:11 +02:00
}
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 }
2026-08-24 02:33:34 +02:00
onChange = {( e ) => {
const nextDateStr = e . currentTarget . value
setDateStr ( nextDateStr )
2026-08-24 13:38:02 +02:00
setChannelUuid (( uuid ) => uuid && ! channelEligibility ( uuid , nextDateStr ). eligible ? null : uuid )
2026-08-24 02:33:34 +02:00
}}
2026-06-25 16:45:11 +02:00
data-testid = "meter-started-at"
/>
< Select
label = "Reason"
required
data = { REASON_OPTIONS }
value = { reason }
2026-08-24 02:33:34 +02:00
onChange = {( value ) => { setReason ( value ); setChannelUuid ( null ) }}
2026-06-25 16:45:11 +02:00
data-testid = "meter-reason"
/>
2026-08-24 02:33:34 +02:00
< Select label = "Commodity" value = { commodity } onChange = {( value ) => { setCommodity ( value ); setChannelUuid ( null ) }} data = {[
2026-08-23 14:13:56 +02:00
{ 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 })) ?? []} />
2026-08-24 13:38:02 +02:00
{ reason === 'meter_swap' && < Alert color = "blue" > If the previous meter has exactly one compatible open binding , declaring this meter automatically hands that channel over atomically . Ambiguous bindings remain unavailable .</ Alert >}
{ sources . isLoading && < Text size = "sm" > Loading sources … </ Text >}{ sources . isError && < Alert color = "red" > Could not load sources . Retry after the connection recovers .</ Alert >}
{ sourceUuid && channels . isLoading && < Text size = "sm" > Loading source channels … </ Text >}{ sourceUuid && channels . isError && < Alert color = "red" > Could not load source channels . Choose another source or retry .</ Alert >}
{ sourceUuid && < Select label = "Compatible source channel (optional)" value = { selectedChannelUuid } onChange = { setChannelUuid } description = "Disabled channels explain unit, current interval, ambiguity, or required time. Closed history remains reusable." data = { channelOptions } />}
2026-08-23 14:13:56 +02:00
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 ()
2026-08-24 13:38:02 +02:00
} catch ( err ) { setError ( apiErrorMessage ( err , 'Failed to update meter. Please try again.' )) }
2026-06-25 16:45:11 +02:00
}
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
2026-08-24 13:38:02 +02:00
onClose : ( meter : MeterResponse ) => void
2026-06-25 16:45:11 +02:00
}
2026-08-24 13:38:02 +02:00
function MeterTable ({ meters , onEdit , onClose } : MeterTableProps ) {
2026-06-25 16:45:11 +02:00
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-24 13:38:02 +02:00
{ meter . bindings ? . filter (( binding ) => binding . ended_at === null ). map (( binding ) => (
< BindingActions key = { binding . uuid } meter = { meter } binding = { binding } meters = { meters } />
))}
{ isActive && ! meter . bindings ? . some (( binding ) => binding . ended_at === null ) && < DirectBindButton meter = { meter } meters = { meters } />}
{ isActive && < Button size = "xs" color = "red" variant = "light" onClick = {() => onClose ( meter )}> Close meter </ Button >}
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-24 13:38:02 +02:00
function DirectBindButton ({ meter , meters } : { meter : MeterResponse ; meters : MeterResponse [] }) {
2026-08-23 14:13:56 +02:00
const [ opened , setOpened ] = useState ( false )
2026-08-24 13:38:02 +02:00
return <>{< Button size = "xs" variant = "subtle" onClick = {() => setOpened ( true )}> Bind source </ Button >}{ opened && < DirectBindModal meter = { meter } meters = { meters } onClose = {() => setOpened ( false )} />}</>
}
function DirectBindModal ({ meter , meters , onClose } : { meter : MeterResponse ; meters : MeterResponse []; onClose : () => void }) {
2026-08-23 14:13:56 +02:00
const [ sourceUuid , setSourceUuid ] = useState < string | null >( null )
const [ channelUuid , setChannelUuid ] = useState < string | null >( null )
2026-08-24 13:38:02 +02:00
const [ startedAt , setStartedAt ] = useState (() => toLocalDateTimeInputString ())
2026-08-23 14:13:56 +02:00
const [ error , setError ] = useState < string | null >( null )
2026-08-24 13:38:02 +02:00
const sources = useSources (); const channels = useSourceChannels ( sourceUuid ); const create = useCreateBinding ()
const eligibilityAt = ( channel : { uuid : string ; unit : string }, value : string ) : Eligibility => {
if ( channel . unit !== expectedUnit ( meter . commodity )) return { eligible : false , reason : `unit mismatch: ${ channel . unit } ; this meter needs ${ expectedUnit ( meter . commodity ) } ` }
const start = localInstant ( value )
if ( start === null ) return { eligible : false , reason : 'choose a valid binding start time first' }
if ( value > toLocalDateTimeInputString ()) return { eligible : false , reason : 'future binding start times are not allowed' }
if ( ! meterContainsInstant ( meter , start )) return { eligible : false , reason : 'binding start must be within this meter epoch' }
return channelIntervalEligibility ( meters , channel . uuid , value )
2026-08-23 14:13:56 +02:00
}
2026-08-24 13:38:02 +02:00
const eligibility = ( channel : { uuid : string ; unit : string }) => eligibilityAt ( channel , startedAt )
const options = channels . data ? . items . map (( channel ) => {
const result = eligibility ( channel )
return { value : channel.uuid , label : ` ${ channel . label } ( ${ channel . unit } ) ${ result . reason ? ` — ${ result . reason } ` : '' } ` , disabled : ! result . eligible }
}) ?? []
const selectedChannel = channels . data ? . items . find (( channel ) => channel . uuid === channelUuid )
const selectedEligible = selectedChannel !== undefined && eligibility ( selectedChannel ). eligible
async function save() {
if ( create . isPending ) return
if ( ! channelUuid ) return setError ( 'Select a genuinely unbound, unit-compatible channel.' )
if ( ! isValidLocalDateTime ( startedAt )) return setError ( 'Choose a valid binding start time.' )
if ( ! selectedEligible ) return setError ( 'The selected channel is no longer eligible. Choose an available channel.' )
setError ( null )
try { await create . mutateAsync ({ id : meter.id , body : { source_channel_uuid : channelUuid , started_at : startedAt } }); onClose () } catch ( err ) { setError ( apiErrorMessage ( err , 'Could not bind this source.' )) }
}
return < Modal opened onClose = { onClose } title = "Bind source" data-testid = { `direct-bind-modal- ${ meter . id } ` }>< form onSubmit = {( event ) => { event . preventDefault (); void save () }}>< Stack >
< Alert color = "blue" > This active meter has no open binding . Choose a currently unoccupied compatible channel .</ Alert >
{ sources . isLoading && < Text size = "sm" > Loading sources … </ Text >}{ sources . isError && < Alert color = "red" > Could not load sources . Retry after the connection recovers .</ Alert >}
< Select label = "Source" value = { sourceUuid } onChange = {( value ) => { setSourceUuid ( value ); setChannelUuid ( null ) }} data = { sources . data ? . items . map (( source ) => ({ value : source.uuid , label : source.name })) ?? []} />
{ sourceUuid && channels . isLoading && < Text size = "sm" > Loading source channels … </ Text >}{ sourceUuid && channels . isError && < Alert color = "red" > Could not load source channels . Choose another source or retry .</ Alert >}
< Select label = "Source channel" value = { channelUuid } onChange = { setChannelUuid } description = "Disabled channels explain the unit, interval, ambiguity, or time constraint." data = { options } />
< TextInput label = "Binding start time" type = "datetime-local" value = { startedAt } onChange = {( event ) => { const value = event . currentTarget . value ; setStartedAt ( value ); setChannelUuid (( uuid ) => { if ( ! isValidLocalDateTime ( value )) return uuid ; const channel = channels . data ? . items . find (( item ) => item . uuid === uuid ); return channel && ! eligibilityAt ( channel , value ). eligible ? null : uuid }) }} required />
{ error && < Alert color = "red" >{ error }</ Alert >}
< Group justify = "flex-end" >< Button type = "button" variant = "default" onClick = { onClose }> Cancel </ Button >< Button type = "submit" loading = { create . isPending } disabled = { create . isPending || ( !! channelUuid && ! selectedEligible )}> Bind source </ Button ></ Group >
</ Stack ></ form ></ Modal >
}
function BindingActions ({ meter , binding , meters } : { meter : MeterResponse ; binding : NonNullable < MeterResponse [ 'bindings' ] >[ number ]; meters : MeterResponse [] }) {
const [ unbindOpened , setUnbindOpened ] = useState ( false )
const [ transferOpened , setTransferOpened ] = useState ( false )
const recoveryTarget = recoveryTargetFor ( meter , meters )
// Cross-Meter recovery closes at the old Meter boundary. An anomalous
// retained binding beginning at or after that boundary would create a
// zero-length/negative source interval that the server correctly rejects.
const recoverySourceIsClosable = meter . ended_at === null ||
parseBackendTimestamp ( binding . started_at ). getTime () < parseBackendTimestamp ( meter . ended_at ). getTime ()
return <>
< Button size = "xs" variant = "subtle" onClick = {() => setUnbindOpened ( true )}> Unbind </ Button >
{ meter . ended_at === null ? < Button size = "xs" variant = "subtle" onClick = {() => setTransferOpened ( true )}> Transfer source </ Button > : recoveryTarget && recoverySourceIsClosable && < Button size = "xs" variant = "light" onClick = {() => setTransferOpened ( true )}> Recover binding </ Button >}
{ meter . ended_at !== null && recoveryTarget && ! recoverySourceIsClosable && < Text size = "xs" c = "red" > Cannot recover : the source binding starts at or after this Meter ended .</ Text >}
{ unbindOpened && < UnbindModal meter = { meter } binding = { binding } onClose = {() => setUnbindOpened ( false )} />}
{ transferOpened && < TransferModal target = { recoveryTarget ?? meter } sourceBinding = { binding } meters = { meters } recovery = { !! recoveryTarget } onClose = {() => setTransferOpened ( false )} />}
</>
}
function UnbindModal ({ meter , binding , onClose } : { meter : MeterResponse ; binding : NonNullable < MeterResponse [ 'bindings' ] >[ number ]; onClose : () => void }) {
const [ endedAt , setEndedAt ] = useState (() => meter . ended_at ? toLocalDateTimeInputString ( parseBackendTimestamp ( meter . ended_at )) : toLocalDateTimeInputString ())
const [ error , setError ] = useState < string | null >( null )
const close = useCloseBinding ()
async function save() {
if ( close . isPending ) return
if ( ! isValidLocalDateTime ( endedAt )) return setError ( 'Choose a valid unbind time.' )
const instant = localInstant ( endedAt )
if ( instant === null || instant <= parseBackendTimestamp ( binding . started_at ). getTime ()) {
return setError ( 'Unbind time must be strictly after the binding start.' )
}
if ( endedAt > toLocalDateTimeInputString ()) return setError ( 'A future unbind time is not allowed.' )
if ( meter . ended_at && instant > parseBackendTimestamp ( meter . ended_at ). getTime ()) {
return setError ( 'Unbind time must not be after the meter end.' )
}
setError ( null )
try { await close . mutateAsync ({ uuid : binding.uuid , ended_at : endedAt }); onClose () } catch ( err ) { setError ( apiErrorMessage ( err , 'Could not unbind this source. History was not deleted.' )) }
}
return < Modal opened onClose = { onClose } title = "Unbind source" data-testid = { `unbind-modal- ${ binding . uuid } ` }>< form onSubmit = {( event ) => { event . preventDefault (); void save () }}>< Stack >
< Alert color = "blue" > Unbinding closes this binding at the selected time . It never deletes binding history .</ Alert >
< TextInput label = "Unbind time" type = "datetime-local" value = { endedAt } onChange = {( event ) => setEndedAt ( event . currentTarget . value )} required />
{ error && < Alert color = "red" >{ error }</ Alert >}
< Group justify = "flex-end" >< Button type = "button" variant = "default" onClick = { onClose }> Cancel </ Button >< Button type = "submit" loading = { close . isPending } disabled = { close . isPending }> Unbind </ Button ></ Group >
</ Stack ></ form ></ Modal >
}
function TransferModal ({ target , sourceBinding , meters , recovery , onClose } : { target : MeterResponse ; sourceBinding : NonNullable < MeterResponse [ 'bindings' ] >[ number ]; meters : MeterResponse []; recovery : boolean ; onClose : () => void }) {
const [ sourceUuid , setSourceUuid ] = useState < string | null >( null )
const [ channelUuid , setChannelUuid ] = useState < string | null >( null )
const [ effectiveAt , setEffectiveAt ] = useState (() => recovery ? toLocalDateTimeInputString ( parseBackendTimestamp ( target . started_at )) : toLocalDateTimeInputString ())
const [ error , setError ] = useState < string | null >( null )
const sources = useSources (); const channels = useSourceChannels ( sourceUuid )
const transfer = useTransferBinding ()
const channelEligibilityAt = ( channel : { uuid : string ; unit : string }, value : string ) : Eligibility => {
if ( channel . unit !== expectedUnit ( target . commodity )) return { eligible : false , reason : `unit mismatch: ${ channel . unit } ; target needs ${ expectedUnit ( target . commodity ) } ` }
const start = localInstant ( value )
if ( start === null ) return { eligible : false , reason : 'choose a valid effective time first' }
if ( value > toLocalDateTimeInputString ()) return { eligible : false , reason : 'future effective times are not allowed' }
if ( ! meterContainsInstant ( target , start )) return { eligible : false , reason : 'effective time must be within the target meter epoch' }
// A same-meter transfer closes its source at `effective_at`. Equality
// would therefore create the forbidden zero-length [start, start)
// interval. Recovery closes at the old meter boundary instead, so it
// deliberately keeps the normal target-epoch rule and may be equal to
// the source binding's (much earlier) start.
if ( ! recovery && start <= parseBackendTimestamp ( sourceBinding . started_at ). getTime ()) {
return { eligible : false , reason : 'same-meter transfer must be strictly after the source binding start' }
}
return channelIntervalEligibility ( meters , channel . uuid , value , [ sourceBinding . uuid ])
}
const channelEligibility = ( channel : { uuid : string ; unit : string }) => channelEligibilityAt ( channel , effectiveAt )
const options = channels . data ? . items . map (( channel ) => {
const result = channelEligibility ( channel )
return { value : channel.uuid , label : ` ${ channel . label } ( ${ channel . unit } ) ${ result . reason ? ` — ${ result . reason } ` : '' } ` , disabled : ! result . eligible }
}) ?? []
const selectedChannel = channels . data ? . items . find (( channel ) => channel . uuid === channelUuid )
const selectedEligible = selectedChannel !== undefined && channelEligibility ( selectedChannel ). eligible
const isFuture = effectiveAt > toLocalDateTimeInputString ()
async function save() {
if ( transfer . isPending ) return
if ( ! channelUuid ) return setError ( 'Select a unit-compatible source channel.' )
if ( ! isValidLocalDateTime ( effectiveAt ) || isFuture ) return setError ( 'Choose a valid non-future effective time.' )
if ( ! selectedEligible ) return setError ( 'The selected channel is no longer eligible. Choose an available channel.' )
setError ( null )
try { await transfer . mutateAsync ({ id : target.id , body : { from_binding_uuid : sourceBinding.uuid , to_source_channel_uuid : channelUuid , effective_at : effectiveAt } }); onClose () } catch ( err ) { setError ( apiErrorMessage ( err , 'Transfer failed. No partial source switch was saved.' )) }
}
return < Modal opened onClose = { onClose } title = { recovery ? 'Recover stranded binding' : 'Transfer source binding' } data-testid = { `transfer-modal- ${ sourceBinding . uuid } ` }>< form onSubmit = {( event ) => { event . preventDefault (); void save () }}>< Stack >
< Alert color = "blue" > This is one atomic Transfer request : either the old binding closes and the new one opens together , or neither change is saved .</ Alert >
{ recovery && < Alert color = "yellow" > This binding is stranded on a closed meter . Recovery defaults to the new meter start . A later time is allowed , but creates an unbound gap before it .</ Alert >}
{ sources . isLoading && < Text size = "sm" > Loading sources … </ Text >}{ sources . isError && < Alert color = "red" > Could not load sources . Retry after the connection recovers .</ Alert >}
< Select label = "Source" value = { sourceUuid } onChange = {( value ) => { setSourceUuid ( value ); setChannelUuid ( null ) }} data = { sources . data ? . items . map (( source ) => ({ value : source.uuid , label : source.name })) ?? []} />
{ sourceUuid && channels . isLoading && < Text size = "sm" > Loading source channels … </ Text >}{ sourceUuid && channels . isError && < Alert color = "red" > Could not load source channels . Choose another source or retry .</ Alert >}
< Select label = "Source channel" value = { channelUuid } onChange = { setChannelUuid } description = "Disabled channels name the specific unit or unrelated-open-interval conflict. The server also rejects ambiguity atomically." data = { options } />
< TextInput label = "Effective time" type = "datetime-local" value = { effectiveAt } onChange = {( event ) => { const value = event . currentTarget . value ; setEffectiveAt ( value ); setChannelUuid (( uuid ) => { if ( ! isValidLocalDateTime ( value )) return uuid ; const channel = channels . data ? . items . find (( item ) => item . uuid === uuid ); return channel && ! channelEligibilityAt ( channel , value ). eligible ? null : uuid }) }} required data-testid = "transfer-effective-at" />
{ recovery && effectiveAt && effectiveAt > toLocalDateTimeInputString ( parseBackendTimestamp ( target . started_at )) && < Alert color = "yellow" > Warning : this later time leaves an unbound gap from the new meter start until this transfer .</ Alert >}
{ isFuture && < Alert color = "red" > A future effective time is not allowed .</ Alert >}
{ error && < Alert color = "red" >{ error }</ Alert >}
< Group justify = "flex-end" >< Button type = "button" variant = "default" onClick = { onClose }> Cancel </ Button >< Button type = "submit" loading = { transfer . isPending } disabled = { transfer . isPending || ( !! channelUuid && ! selectedEligible )}> Transfer binding </ Button ></ Group >
</ Stack ></ form ></ Modal >
}
function CloseMeterModal ({ meter , onClose } : { meter : MeterResponse ; onClose : () => void }) {
const [ endedAt , setEndedAt ] = useState (() => toLocalDateTimeInputString ())
const [ error , setError ] = useState < string | null >( null )
const close = useCloseMeter ()
async function save() {
if ( close . isPending ) return
if ( ! isValidLocalDateTime ( endedAt )) return setError ( 'Choose a valid close time.' )
const instant = localInstant ( endedAt )
if ( instant === null || instant <= parseBackendTimestamp ( meter . started_at ). getTime ()) {
return setError ( 'Close time must be strictly after the meter start.' )
}
if ( endedAt > toLocalDateTimeInputString ()) return setError ( 'A future close time is not allowed.' )
setError ( null )
try { await close . mutateAsync ({ id : meter.id , ended_at : endedAt }); onClose () } catch ( err ) { setError ( apiErrorMessage ( err , 'Could not close this meter.' )) }
}
return < Modal opened onClose = { onClose } title = { `Close Meter — ${ meter . label } ` } data-testid = { `close-meter-modal- ${ meter . id } ` }>< form onSubmit = {( event ) => { event . preventDefault (); void save () }}>< Stack >
< Alert color = "yellow" > Closing leaves no active { meter . commodity } meter . Every open binding on this meter closes at the same boundary .</ Alert >
< TextInput label = "Close time" type = "datetime-local" value = { endedAt } onChange = {( event ) => setEndedAt ( event . currentTarget . value )} required />
{ error && < Alert color = "red" >{ error }</ Alert >}
< Group justify = "flex-end" >< Button type = "button" variant = "default" onClick = { onClose }> Cancel </ Button >< Button type = "submit" color = "red" loading = { close . isPending } disabled = { close . isPending }> Close meter </ Button ></ Group >
</ Stack ></ form ></ Modal >
2026-08-23 14:13:56 +02:00
}
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 )
2026-08-24 13:38:02 +02:00
const [ closeMeter , setCloseMeter ] = useState < MeterResponse | null >( null )
2026-06-25 16:45:11 +02:00
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 >
)}
2026-08-24 13:38:02 +02:00
< MeterTable meters = { meters } onEdit = {( m ) => setEditMeter ( m )} onClose = {( m ) => setCloseMeter ( m )} />
2026-06-25 16:45:11 +02:00
{ /* Declare new meter */ }
{ showDeclareForm && (
< DeclareMeterForm
2026-08-24 02:33:34 +02:00
meters = { meters }
2026-06-25 16:45:11 +02:00
onClose = {() => setShowDeclareForm ( false )}
onSaved = {() => setShowDeclareForm ( false )}
/>
)}
{ /* Edit existing meter */ }
{ editMeter && (
< EditMeterForm
meter = { editMeter }
onClose = {() => setEditMeter ( null )}
onSaved = {( retroactive ) => {
setEditMeter ( null )
if ( retroactive ) setRecomputeNotice ( true )
}}
/>
)}
2026-08-24 13:38:02 +02:00
{ closeMeter && < CloseMeterModal meter = { closeMeter } onClose = {() => setCloseMeter ( null )} />}
2026-06-25 16:45:11 +02:00
</ Stack >
)
}