M8-T18: add source and multi-commodity meter UI

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent 39c11ae606
commit 963e43e3e4
15 changed files with 319 additions and 18 deletions
+55 -3
View File
@@ -34,11 +34,16 @@ import {
useMeters,
useDeclareMeter,
useUpdateMeter,
useSources,
useSourceChannels,
useMeterBindings,
useCreateBinding,
useCloseBinding,
type MeterResponse,
type MeterReason,
} from './hooks'
import { ApiError } from '../api/client'
import { formatLocalDate, parseBackendTimestamp } from '../utils/datetime'
import { formatLocalDate, formatLocalDateTime, parseBackendTimestamp } from '../utils/datetime'
// ---------------------------------------------------------------------------
// Helpers
@@ -88,8 +93,15 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
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 compatible = (channel: { unit: string; binding_count: number; bound_meter_ids: number[] }) =>
({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record<string, string>)[commodity ?? 'electricity'] === channel.unit && channel.binding_count === 0 && channel.bound_meter_ids.length === 0
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -114,7 +126,8 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
started_at: toLocalMidnightNaive(dateStr),
reason: reason as MeterReason,
note: note.trim() || undefined,
commodity: 'electricity',
commodity: commodity ?? 'electricity',
...(channelUuid ? { source_channel_uuid: channelUuid } : {}),
})
onSaved()
onClose()
@@ -166,6 +179,14 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
data-testid="meter-reason"
/>
<Select label="Commodity" value={commodity} onChange={setCommodity} 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 })) ?? []} />
{sourceUuid && <Select label="Compatible source channel (optional)" value={channelUuid} onChange={setChannelUuid} description="Only unbound channels with the required unit are eligible. Suggestions are informational only." data={channels.data?.items.filter(compatible).map((channel) => ({ value: channel.uuid, label: `${channel.label} (${channel.unit})${channel.suggested_commodity ? ` — suggestion: ${channel.suggested_commodity}` : ''}` })) ?? []} />}
<Textarea
label="Note (optional)"
value={note}
@@ -356,6 +377,7 @@ function MeterTable({ meters, onEdit }: MeterTableProps) {
<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>
@@ -399,8 +421,20 @@ function MeterTable({ meters, onEdit }: MeterTableProps) {
{meter.reason}
</Badge>
</Table.Td>
<Table.Td>
{meter.bindings?.length ? meter.bindings.map((binding) => (
<Stack key={binding.uuid} gap={0} mb="xs" data-testid={`binding-timeline-${binding.uuid}`}>
<Text size="xs">{binding.source_uuid} {binding.source_channel_uuid}</Text>
<Text size="xs" c="dimmed">
[{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'})
{' '}({binding.ended_at ? 'closed' : 'active'})
</Text>
</Stack>
)) : <Text size="xs" c="dimmed">Unbound</Text>}
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
{isActive && <SourceSwitchButton meter={meter} />}
<Button
size="xs"
variant="outline"
@@ -420,6 +454,24 @@ function MeterTable({ meters, onEdit }: MeterTableProps) {
)
}
function SourceSwitchButton({ meter }: { meter: MeterResponse }) {
const [opened, setOpened] = useState(false)
const [sourceUuid, setSourceUuid] = useState<string | null>(null)
const [channelUuid, setChannelUuid] = useState<string | null>(null)
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}` : ''}`) }
}
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>}</>
}
// ---------------------------------------------------------------------------
// MeterManager — top-level
// ---------------------------------------------------------------------------
@@ -456,7 +508,7 @@ export function MeterManager() {
return (
<Stack gap="lg" data-testid="meter-manager">
<Group justify="space-between" align="center">
<Text fw={500}>Electricity Meters</Text>
<Text fw={500}>Meters</Text>
<Button
onClick={() => setShowDeclareForm(true)}
data-testid="meter-declare-button"