899 lines
44 KiB
TypeScript
899 lines
44 KiB
TypeScript
/**
|
|
* 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,
|
|
useCreateBinding,
|
|
useCloseBinding,
|
|
useCloseMeter,
|
|
useTransferBinding,
|
|
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 <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}`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function BindingTimeline({ binding, sources }: {
|
|
binding: NonNullable<MeterResponse['bindings']>[number]
|
|
sources: ReturnType<typeof useSources>
|
|
}) {
|
|
const source = sources.data?.items.find((item) => item.uuid === binding.source_uuid)
|
|
const channels = useSourceChannels(source?.uuid ?? null)
|
|
const channel = channels.data?.items.find((item) => item.uuid === binding.source_channel_uuid)
|
|
let endpoint = 'Source details unavailable'
|
|
if (sources.isLoading) endpoint = 'Loading source details…'
|
|
else if (!sources.isError && source) endpoint = source.name
|
|
else if (!sources.isError) endpoint = 'Source unavailable'
|
|
|
|
let channelName = 'Channel details unavailable'
|
|
if (source && channels.isLoading) channelName = 'Loading channel details…'
|
|
else if (source && !channels.isError && channel) channelName = channel.label
|
|
else if (source && !channels.isError) channelName = 'Channel unavailable'
|
|
|
|
return (
|
|
<Stack gap={0} mb="xs" data-testid={`binding-timeline-${binding.uuid}`}>
|
|
<Text size="xs">{endpoint} → {channelName}</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>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<string | null>(null)
|
|
const [note, setNote] = useState('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
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)
|
|
|
|
const declareMutation = useDeclareMeter()
|
|
const unit = expectedUnit(commodity ?? 'electricity')
|
|
const oldMeter = meters.find((meter) => meter.commodity === commodity && meter.ended_at === null)
|
|
const channelEligibility = (uuid: string, startedAt: string): Eligibility => {
|
|
const channel = channels.data?.items.find((item) => item.uuid === uuid)
|
|
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' }
|
|
|
|
// 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]
|
|
const isSingleOldBinding = reason === 'meter_swap' && oldMeter !== undefined &&
|
|
openBindings.length === 1 && oldBinding !== undefined &&
|
|
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 }
|
|
}
|
|
const channelOptions = channels.data?.items.map((channel) => {
|
|
const result = channelEligibility(channel.uuid, dateStr)
|
|
const canHandoff = !isUnboundChannel(channel.uuid) && result.eligible
|
|
return {
|
|
value: channel.uuid,
|
|
label: `${channel.label} (${channel.unit})${canHandoff ? ' — hand off from current meter' : result.reason ? ` — ${result.reason}` : ''}`,
|
|
disabled: !result.eligible,
|
|
}
|
|
}) ?? []
|
|
const selectedChannelUuid = channelUuid && channelEligibility(channelUuid, dateStr).eligible
|
|
? channelUuid
|
|
: null
|
|
function isUnboundChannel(uuid: string): boolean {
|
|
return !meters.some((meter) => meter.bindings?.some(
|
|
(binding) => binding.source_channel_uuid === uuid && binding.ended_at === 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
|
|
}
|
|
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
|
|
}
|
|
|
|
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) { setError(apiErrorMessage(err, '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) => {
|
|
const nextDateStr = e.currentTarget.value
|
|
setDateStr(nextDateStr)
|
|
setChannelUuid((uuid) => uuid && !channelEligibility(uuid, nextDateStr).eligible ? null : uuid)
|
|
}}
|
|
data-testid="meter-started-at"
|
|
/>
|
|
|
|
<Select
|
|
label="Reason"
|
|
required
|
|
data={REASON_OPTIONS}
|
|
value={reason}
|
|
onChange={(value) => { setReason(value); setChannelUuid(null) }}
|
|
data-testid="meter-reason"
|
|
/>
|
|
|
|
<Select label="Commodity" value={commodity} onChange={(value) => { setCommodity(value); setChannelUuid(null) }} 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 })) ?? []} />
|
|
{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} />}
|
|
|
|
<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 ?? '')
|
|
// 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))
|
|
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) { setError(apiErrorMessage(err, '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[]
|
|
sources: ReturnType<typeof useSources>
|
|
onEdit: (meter: MeterResponse) => void
|
|
onClose: (meter: MeterResponse) => void
|
|
}
|
|
|
|
function MeterTable({ meters, sources, onEdit, onClose }: 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>
|
|
<Table.Th>Binding timeline</Table.Th>
|
|
<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>
|
|
<Table.Td>
|
|
{meter.bindings?.length ? meter.bindings.map((binding) => (
|
|
<BindingTimeline key={binding.uuid} binding={binding} sources={sources} />
|
|
)) : <Text size="xs" c="dimmed">Unbound</Text>}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Group justify="flex-end" gap="xs">
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
onClick={() => onEdit(meter)}
|
|
data-testid={`meter-edit-${meter.id}`}
|
|
>
|
|
Edit
|
|
</Button>
|
|
{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>}
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
)
|
|
})}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</ScrollArea>
|
|
)
|
|
}
|
|
|
|
function DirectBindButton({ meter, meters }: { meter: MeterResponse; meters: MeterResponse[] }) {
|
|
const [opened, setOpened] = useState(false)
|
|
return <>{<Button size="xs" variant="light" 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 }) {
|
|
const [sourceUuid, setSourceUuid] = useState<string | null>(null)
|
|
const [channelUuid, setChannelUuid] = useState<string | null>(null)
|
|
const [startedAt, setStartedAt] = useState(() => toLocalDateTimeInputString())
|
|
const [error, setError] = useState<string | null>(null)
|
|
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)
|
|
}
|
|
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 <>
|
|
{meter.ended_at === null ? <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Transfer source</Button> : recoveryTarget && recoverySourceIsClosable && <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Recover binding</Button>}
|
|
<Button size="xs" variant="light" onClick={() => setUnbindOpened(true)}>Unbind</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>
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MeterManager — top-level
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function MeterManager() {
|
|
const metersQuery = useMeters()
|
|
const sources = useSources()
|
|
|
|
const [showDeclareForm, setShowDeclareForm] = useState(false)
|
|
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
|
|
const [closeMeter, setCloseMeter] = 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">
|
|
<Text fw={500}>Meters</Text>
|
|
<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} sources={sources} onEdit={(m) => setEditMeter(m)} onClose={(m) => setCloseMeter(m)} />
|
|
|
|
{/* Declare new meter */}
|
|
{showDeclareForm && (
|
|
<DeclareMeterForm
|
|
meters={meters}
|
|
onClose={() => setShowDeclareForm(false)}
|
|
onSaved={() => setShowDeclareForm(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Edit existing meter */}
|
|
{editMeter && (
|
|
<EditMeterForm
|
|
meter={editMeter}
|
|
onClose={() => setEditMeter(null)}
|
|
onSaved={(retroactive) => {
|
|
setEditMeter(null)
|
|
if (retroactive) setRecomputeNotice(true)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{closeMeter && <CloseMeterModal meter={closeMeter} onClose={() => setCloseMeter(null)} />}
|
|
</Stack>
|
|
)
|
|
}
|