/** * 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 (
setLabel(e.currentTarget.value)} data-testid="meter-label" /> { const nextDateStr = e.currentTarget.value setDateStr(nextDateStr) setChannelUuid((uuid) => uuid && !isChannelEligible(uuid, nextDateStr) ? null : uuid) }} data-testid="meter-started-at" /> { setCommodity(value); setChannelUuid(null) }} data={[ { value: 'electricity', label: 'Electricity' }, { value: 'heating', label: 'Heating' }, { value: 'hot_water', label: 'Hot water' }, ]} /> }