M7-T06: add meter management UI (timeline, declare swap, edit) to Energy view
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* 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,
|
||||
type MeterResponse,
|
||||
type MeterReason,
|
||||
} from './hooks'
|
||||
import { ApiError } from '../api/client'
|
||||
import { formatLocalDate } 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`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Declare meter form (modal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeclareMeterFormProps {
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
function DeclareMeterForm({ 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 declareMutation = useDeclareMeter()
|
||||
|
||||
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: 'electricity',
|
||||
})
|
||||
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) => setDateStr(e.currentTarget.value)}
|
||||
data-testid="meter-started-at"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Reason"
|
||||
required
|
||||
data={REASON_OPTIONS}
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
data-testid="meter-reason"
|
||||
/>
|
||||
|
||||
<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 UTC started_at to a local date string for the <input type="date">.
|
||||
// We parse as UTC (appending Z if needed) and format to "YYYY-MM-DD" in local tz.
|
||||
const initialDateStr = (() => {
|
||||
const iso = meter.started_at.includes('T') && !meter.started_at.match(/[zZ+-]\d*$/)
|
||||
? meter.started_at + 'Z'
|
||||
: meter.started_at
|
||||
const d = new Date(iso)
|
||||
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}`
|
||||
})()
|
||||
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 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>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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}>Electricity 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
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user