597 lines
23 KiB
TypeScript
597 lines
23 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,
|
||
useMeterBindings,
|
||
useCreateBinding,
|
||
useCloseBinding,
|
||
type MeterResponse,
|
||
type MeterReason,
|
||
} from './hooks'
|
||
import { ApiError } from '../api/client'
|
||
import { formatLocalDate, formatLocalDateTime, parseBackendTimestamp } from '../utils/datetime'
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const REASON_OPTIONS: { value: MeterReason; label: string }[] = [
|
||
{ value: 'initial', label: 'Initial installation' },
|
||
{ value: 'meter_swap', label: 'Meter swap (same address)' },
|
||
{ value: 'home_move', label: 'Home / address move' },
|
||
{ value: 'other', label: 'Other' },
|
||
]
|
||
|
||
/**
|
||
* Convert a local date string "YYYY-MM-DD" to a naive local-midnight datetime
|
||
* string (no Z suffix) following the FU10 / ContractForm convention.
|
||
* The backend interprets naive datetimes as server local wall-clock time.
|
||
*/
|
||
function toLocalMidnightNaive(dateStr: string): string {
|
||
return `${dateStr}T00:00:00`
|
||
}
|
||
|
||
/**
|
||
* Format a Date object as a "YYYY-MM-DD" string using the browser's local timezone.
|
||
* Used to populate <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}`
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 expectedUnit = ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record<string, string>)[commodity ?? 'electricity']
|
||
const oldMeter = meters.find((meter) => meter.commodity === commodity && meter.ended_at === null)
|
||
const isChannelEligible = (uuid: string, startedAt: string) => {
|
||
const channel = channels.data?.items.find((item) => item.uuid === uuid)
|
||
if (!channel || channel.unit !== expectedUnit) return false
|
||
|
||
// Channel aggregates include closed binding history. For a meter swap, only
|
||
// currently open bindings determine whether this channel can be handed off.
|
||
const openBindings = meters.flatMap((meter) =>
|
||
(meter.bindings ?? [])
|
||
.filter((binding) => binding.source_channel_uuid === channel.uuid && binding.ended_at === null)
|
||
.map((binding) => ({ meter, binding })),
|
||
)
|
||
const isUnbound = channel.binding_count === 0 &&
|
||
channel.bound_meter_ids.length === 0 && openBindings.length === 0
|
||
const oldBinding = openBindings[0]
|
||
const canHandoff = reason === 'meter_swap' && oldMeter !== undefined &&
|
||
openBindings.length === 1 && oldBinding !== undefined &&
|
||
oldBinding.meter.id === oldMeter.id && oldBinding.meter.commodity === commodity &&
|
||
startedAt > toLocalDateInputString(parseBackendTimestamp(oldBinding.binding.started_at))
|
||
return isUnbound || canHandoff
|
||
}
|
||
const channelOptions = channels.data?.items
|
||
.filter((channel) => channel.unit === expectedUnit)
|
||
.map((channel) => {
|
||
const canHandoff = !(
|
||
channel.binding_count === 0 && channel.bound_meter_ids.length === 0
|
||
) && isChannelEligible(channel.uuid, dateStr)
|
||
return {
|
||
value: channel.uuid,
|
||
label: `${channel.label} (${channel.unit})${canHandoff ? ' — hand off from current meter' : ''}`,
|
||
disabled: !isChannelEligible(channel.uuid, dateStr),
|
||
}
|
||
}) ?? []
|
||
const selectedChannelUuid = channelUuid && isChannelEligible(channelUuid, dateStr)
|
||
? channelUuid
|
||
: null
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setError(null)
|
||
|
||
if (!label.trim()) {
|
||
setError('Label is required.')
|
||
return
|
||
}
|
||
if (!dateStr) {
|
||
setError('Start date is required.')
|
||
return
|
||
}
|
||
if (!reason) {
|
||
setError('Reason is required.')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await declareMutation.mutateAsync({
|
||
label: label.trim(),
|
||
started_at: toLocalMidnightNaive(dateStr),
|
||
reason: reason as MeterReason,
|
||
note: note.trim() || undefined,
|
||
commodity: commodity ?? 'electricity',
|
||
...(selectedChannelUuid ? { source_channel_uuid: selectedChannelUuid } : {}),
|
||
})
|
||
onSaved()
|
||
onClose()
|
||
} catch (err) {
|
||
if (err instanceof ApiError) {
|
||
const detail = (err.body as { detail?: string } | null)?.detail
|
||
setError(detail ?? `Error ${err.status}: failed to declare meter.`)
|
||
} else {
|
||
setError('Failed to declare meter. Please try again.')
|
||
}
|
||
}
|
||
}
|
||
|
||
return (
|
||
<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 && !isChannelEligible(uuid, nextDateStr) ? 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 })) ?? []} />
|
||
{sourceUuid && <Select label="Compatible source channel (optional)" value={selectedChannelUuid} onChange={setChannelUuid} description={reason === 'meter_swap' ? 'An unbound channel, or the current same-commodity meter’s single binding, can be selected. Other occupied channels stay unavailable.' : 'Only unbound channels with the required unit are eligible.'} 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) {
|
||
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.')
|
||
}
|
||
}
|
||
}
|
||
|
||
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[]
|
||
onEdit: (meter: MeterResponse) => void
|
||
}
|
||
|
||
function MeterTable({ meters, onEdit }: 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) => (
|
||
<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"
|
||
onClick={() => onEdit(meter)}
|
||
data-testid={`meter-edit-${meter.id}`}
|
||
>
|
||
Edit
|
||
</Button>
|
||
</Group>
|
||
</Table.Td>
|
||
</Table.Tr>
|
||
)
|
||
})}
|
||
</Table.Tbody>
|
||
</Table>
|
||
</ScrollArea>
|
||
)
|
||
}
|
||
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function MeterManager() {
|
||
const metersQuery = useMeters()
|
||
|
||
const [showDeclareForm, setShowDeclareForm] = useState(false)
|
||
const [editMeter, setEditMeter] = 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} onEdit={(m) => setEditMeter(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)
|
||
}}
|
||
/>
|
||
)}
|
||
</Stack>
|
||
)
|
||
}
|