/**
* 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,
useSources,
useSourceChannels,
useMeterBindings,
useCreateBinding,
useCloseBinding,
type MeterResponse,
type MeterReason,
} from './hooks'
import { ApiError } from '../api/client'
import { formatLocalDate, formatLocalDateTime, parseBackendTimestamp } from '../utils/datetime'
// ---------------------------------------------------------------------------
// 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`
}
/**
* Format a Date object as a "YYYY-MM-DD" string using the browser's local timezone.
* Used to populate 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}`
}
// ---------------------------------------------------------------------------
// Declare meter form (modal)
// ---------------------------------------------------------------------------
interface DeclareMeterFormProps {
meters: MeterResponse[]
onClose: () => void
onSaved: () => void
}
function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
const [label, setLabel] = useState('')
const [dateStr, setDateStr] = useState('')
const [reason, setReason] = useState(null)
const [note, setNote] = useState('')
const [error, setError] = useState(null)
const [commodity, setCommodity] = useState('electricity')
const [sourceUuid, setSourceUuid] = useState(null)
const [channelUuid, setChannelUuid] = useState(null)
const sources = useSources()
const channels = useSourceChannels(sourceUuid)
const declareMutation = useDeclareMeter()
const expectedUnit = ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record)[commodity ?? 'electricity']
const oldMeter = meters.find((meter) => meter.commodity === commodity && meter.ended_at === null)
const isChannelEligible = (uuid: string, startedAt: string) => {
const channel = channels.data?.items.find((item) => item.uuid === uuid)
if (!channel || channel.unit !== expectedUnit) return false
// 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 isUnbound = channel.binding_count === 0 &&
channel.bound_meter_ids.length === 0 && openBindings.length === 0
const oldBinding = openBindings[0]
const canHandoff = reason === 'meter_swap' && oldMeter !== undefined &&
openBindings.length === 1 && oldBinding !== undefined &&
oldBinding.meter.id === oldMeter.id && oldBinding.meter.commodity === commodity &&
startedAt > toLocalDateInputString(parseBackendTimestamp(oldBinding.binding.started_at))
return isUnbound || canHandoff
}
const channelOptions = channels.data?.items
.filter((channel) => channel.unit === expectedUnit)
.map((channel) => {
const canHandoff = !(
channel.binding_count === 0 && channel.bound_meter_ids.length === 0
) && isChannelEligible(channel.uuid, dateStr)
return {
value: channel.uuid,
label: `${channel.label} (${channel.unit})${canHandoff ? ' — hand off from current meter' : ''}`,
disabled: !isChannelEligible(channel.uuid, dateStr),
}
}) ?? []
const selectedChannelUuid = channelUuid && isChannelEligible(channelUuid, dateStr)
? channelUuid
: null
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,
commodity: commodity ?? 'electricity',
...(selectedChannelUuid ? { source_channel_uuid: selectedChannelUuid } : {}),
})
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 (
)
}
// ---------------------------------------------------------------------------
// 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 ?? '')
// Convert started_at to a local date string for the .
// 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))
const [dateStr, setDateStr] = useState(initialDateStr)
const [error, setError] = useState(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 (
)
}
// ---------------------------------------------------------------------------
// Meter timeline table
// ---------------------------------------------------------------------------
interface MeterTableProps {
meters: MeterResponse[]
onEdit: (meter: MeterResponse) => void
}
function MeterTable({ meters, onEdit }: MeterTableProps) {
if (meters.length === 0) {
return (
No meters declared yet. Click "Declare New Meter" to add one.
)
}
return (
Label
Commodity
From
To
Status
Reason
Binding timeline
Actions
{meters.map((meter) => {
const isActive = meter.ended_at === null
return (
{meter.label}
{meter.commodity}
{formatLocalDate(meter.started_at)}
{meter.ended_at ? formatLocalDate(meter.ended_at) : '—'}
{isActive ? 'active' : 'closed'}
{meter.reason}
{meter.bindings?.length ? meter.bindings.map((binding) => (
{binding.source_uuid} → {binding.source_channel_uuid}
[{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'})
{' '}({binding.ended_at ? 'closed' : 'active'})
)) : Unbound}
{isActive && }
)
})}
)
}
function SourceSwitchButton({ meter }: { meter: MeterResponse }) {
const [opened, setOpened] = useState(false)
const [sourceUuid, setSourceUuid] = useState(null)
const [channelUuid, setChannelUuid] = useState(null)
const [error, setError] = useState(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)[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 <>{opened && setOpened(false)} title="Switch source binding">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.{bindings.isLoading && Loading binding timeline…}{bindings.isError && Failed to load binding timeline; no change was made.}{timelineReady && timeline.length === 0 && No existing bindings for this meter.}}>
}
// ---------------------------------------------------------------------------
// MeterManager — top-level
// ---------------------------------------------------------------------------
export function MeterManager() {
const metersQuery = useMeters()
const [showDeclareForm, setShowDeclareForm] = useState(false)
const [editMeter, setEditMeter] = useState(null)
const [recomputeNotice, setRecomputeNotice] = useState(false)
// ---------------------------------------------------------------------------
// Render states
// ---------------------------------------------------------------------------
if (metersQuery.isLoading) {
return (
)
}
if (metersQuery.isError || !metersQuery.data) {
return (
Failed to load meters. Please refresh.
)
}
const meters = metersQuery.data.items
return (
Meters
{recomputeNotice && (
setRecomputeNotice(false)}
data-testid="meter-recompute-notice"
>
The start date was corrected. Affected billing periods have been
re-judged and cost attributions updated.
)}
setEditMeter(m)} />
{/* Declare new meter */}
{showDeclareForm && (
setShowDeclareForm(false)}
onSaved={() => setShowDeclareForm(false)}
/>
)}
{/* Edit existing meter */}
{editMeter && (
setEditMeter(null)}
onSaved={(retroactive) => {
setEditMeter(null)
if (retroactive) setRecomputeNotice(true)
}}
/>
)}
)
}