M8-T18: add source and multi-commodity meter UI
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user