M8-R09: add meter close unbind and transfer workflows

This commit is contained in:
2026-08-24 18:37:46 +02:00
parent 8dc3f71aaf
commit b1b6a309cb
4 changed files with 1217 additions and 118 deletions
+329 -51
View File
@@ -36,9 +36,10 @@ import {
useUpdateMeter,
useSources,
useSourceChannels,
useMeterBindings,
useCreateBinding,
useCloseBinding,
useCloseMeter,
useTransferBinding,
type MeterResponse,
type MeterReason,
} from './hooks'
@@ -78,6 +79,94 @@ function toLocalDateInputString(d: Date): string {
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
}
// ---------------------------------------------------------------------------
// Declare meter form (modal)
// ---------------------------------------------------------------------------
@@ -101,11 +190,16 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
const channels = useSourceChannels(sourceUuid)
const declareMutation = useDeclareMeter()
const expectedUnit = ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record<string, string>)[commodity ?? 'electricity']
const unit = expectedUnit(commodity ?? 'electricity')
const oldMeter = meters.find((meter) => meter.commodity === commodity && meter.ended_at === null)
const isChannelEligible = (uuid: string, startedAt: string) => {
const channelEligibility = (uuid: string, startedAt: string): Eligibility => {
const channel = channels.data?.items.find((item) => item.uuid === uuid)
if (!channel || channel.unit !== expectedUnit) return false
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.
@@ -114,30 +208,45 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
.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 &&
const isSingleOldBinding = 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
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
.filter((channel) => channel.unit === expectedUnit)
.map((channel) => {
const canHandoff = !(
channel.binding_count === 0 && channel.bound_meter_ids.length === 0
) && isChannelEligible(channel.uuid, dateStr)
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' : ''}`,
disabled: !isChannelEligible(channel.uuid, dateStr),
label: `${channel.label} (${channel.unit})${canHandoff ? ' — hand off from current meter' : result.reason ? `${result.reason}` : ''}`,
disabled: !result.eligible,
}
}) ?? []
const selectedChannelUuid = channelUuid && isChannelEligible(channelUuid, dateStr)
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()
@@ -155,6 +264,29 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
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({
@@ -167,14 +299,7 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
})
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.')
}
}
} catch (err) { setError(apiErrorMessage(err, 'Failed to declare meter. Please try again.')) }
}
return (
@@ -205,7 +330,7 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
onChange={(e) => {
const nextDateStr = e.currentTarget.value
setDateStr(nextDateStr)
setChannelUuid((uuid) => uuid && !isChannelEligible(uuid, nextDateStr) ? null : uuid)
setChannelUuid((uuid) => uuid && !channelEligibility(uuid, nextDateStr).eligible ? null : uuid)
}}
data-testid="meter-started-at"
/>
@@ -225,7 +350,10 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
{ value: 'hot_water', label: 'Hot water' },
]} />
<Select label="Bind source (optional)" value={sourceUuid} onChange={(value) => { setSourceUuid(value); setChannelUuid(null) }} data={sources.data?.items.filter((source) => typeof source.uuid === 'string').map((source) => ({ value: source.uuid, label: source.name })) ?? []} />
{sourceUuid && <Select label="Compatible source channel (optional)" value={selectedChannelUuid} onChange={setChannelUuid} description={reason === 'meter_swap' ? 'An unbound channel, or the current same-commodity meters single binding, can be selected. Other occupied channels stay unavailable.' : 'Only unbound channels with the required unit are eligible.'} data={channelOptions} />}
{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)"
@@ -313,14 +441,7 @@ function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
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.')
}
}
} catch (err) { setError(apiErrorMessage(err, 'Failed to update meter. Please try again.')) }
}
return (
@@ -395,9 +516,10 @@ function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
interface MeterTableProps {
meters: MeterResponse[]
onEdit: (meter: MeterResponse) => void
onClose: (meter: MeterResponse) => void
}
function MeterTable({ meters, onEdit }: MeterTableProps) {
function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
if (meters.length === 0) {
return (
<Text c="dimmed" ta="center" size="sm" data-testid="meters-empty">
@@ -474,7 +596,11 @@ function MeterTable({ meters, onEdit }: MeterTableProps) {
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
{isActive && <SourceSwitchButton meter={meter} />}
{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>}
<Button
size="xs"
variant="outline"
@@ -494,22 +620,171 @@ function MeterTable({ meters, onEdit }: MeterTableProps) {
)
}
function SourceSwitchButton({ meter }: { meter: MeterResponse }) {
function DirectBindButton({ meter, meters }: { meter: MeterResponse; meters: MeterResponse[] }) {
const [opened, setOpened] = useState(false)
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 }) {
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 bindings = useMeterBindings(opened ? meter.id : null)
const create = useCreateBinding(); const close = useCloseBinding()
const compatible = (channel: { unit: string; binding_count: number; bound_meter_ids: number[] }) => ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record<string, string>)[meter.commodity] === channel.unit && channel.binding_count === 0 && channel.bound_meter_ids.length === 0
const timeline = bindings.data?.items
const timelineReady = bindings.isSuccess && !!timeline
async function save() { if (!timelineReady) return setError('Binding timeline has not loaded; no change was made.'); if (!channelUuid) return setError('Select a compatible unbound source channel.'); setError(null)
const started_at = new Date().toISOString(); const active = bindings.data?.items.find((binding) => !binding.ended_at)
try { if (active) await close.mutateAsync({ uuid: active.uuid, ended_at: started_at }) } catch (err) { const detail = err instanceof ApiError ? String((err.body as { detail?: string })?.detail ?? '') : ''; return setError(`Could not close the old binding; no change was made.${detail ? ` ${detail}` : ''}`) }
try { await create.mutateAsync({ id: meter.id, body: { source_channel_uuid: channelUuid, started_at } }); setOpened(false) } catch (err) { const detail = err instanceof ApiError ? String((err.body as { detail?: string })?.detail ?? '') : ''; setError(`Old binding was closed, but creating the new binding failed. Retry after resolving the error.${detail ? ` ${detail}` : ''}`) }
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)
}
return <><Button size="xs" variant="subtle" onClick={() => setOpened(true)}>Switch source</Button>{opened && <Modal opened onClose={() => setOpened(false)} title="Switch source binding"><Stack><Alert color="blue">This is a two-step close then create process, not a meter swap. If create fails after close, the old binding remains closed and you can retry.</Alert>{bindings.isLoading && <Alert color="blue">Loading binding timeline</Alert>}{bindings.isError && <Alert color="red">Failed to load binding timeline; no change was made.</Alert>}{timelineReady && timeline.length === 0 && <Alert color="gray">No existing bindings for this meter.</Alert>}<Select label="Source" value={sourceUuid} onChange={(value) => { setSourceUuid(value); setChannelUuid(null) }} data={sources.data?.items.filter((source) => typeof source.uuid === 'string').map((source) => ({ value: source.uuid, label: source.name })) ?? []} /><Select label="Compatible unbound channel" value={channelUuid} onChange={setChannelUuid} description="Eligibility is based on unit and binding state; suggestions are informational." data={channels.data?.items.filter(compatible).map((channel) => ({ value: channel.uuid, label: `${channel.label} (${channel.unit})` })) ?? []} />{error && <Alert color="red">{error}</Alert>}<Group justify="flex-end"><Button variant="default" onClick={() => setOpened(false)}>Cancel</Button><Button onClick={save} loading={create.isPending || close.isPending} disabled={!timelineReady}>Switch binding</Button></Group></Stack></Modal>}</>
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>
}
// ---------------------------------------------------------------------------
@@ -521,6 +796,7 @@ export function MeterManager() {
const [showDeclareForm, setShowDeclareForm] = useState(false)
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
const [closeMeter, setCloseMeter] = useState<MeterResponse | null>(null)
const [recomputeNotice, setRecomputeNotice] = useState(false)
// ---------------------------------------------------------------------------
@@ -569,7 +845,7 @@ export function MeterManager() {
</Notification>
)}
<MeterTable meters={meters} onEdit={(m) => setEditMeter(m)} />
<MeterTable meters={meters} onEdit={(m) => setEditMeter(m)} onClose={(m) => setCloseMeter(m)} />
{/* Declare new meter */}
{showDeclareForm && (
@@ -591,6 +867,8 @@ export function MeterManager() {
}}
/>
)}
{closeMeter && <CloseMeterModal meter={closeMeter} onClose={() => setCloseMeter(null)} />}
</Stack>
)
}