2026-06-22 13:52:33 +02:00
|
|
|
/**
|
|
|
|
|
* EnergyPage — device management UI for the Energy (Modbus) domain.
|
|
|
|
|
*
|
2026-06-22 14:17:59 +02:00
|
|
|
* Features (T06 + T07):
|
2026-06-22 13:52:33 +02:00
|
|
|
* - Device list with status indicators (enabled, last poll).
|
|
|
|
|
* - Create / edit via DeviceForm modal.
|
|
|
|
|
* - Delete with二次确认; 409 (has readings) → friendly prompt to disable instead.
|
|
|
|
|
* - "Test read" button per device — calls POST /devices/{uuid}/test, shows
|
2026-06-22 14:17:59 +02:00
|
|
|
* decoded payload or error without persisting to the database (T06 OBS-1: error
|
|
|
|
|
* now caught and displayed rather than producing an unhandled rejection).
|
|
|
|
|
* - Latest readings card per device (T07): fetches /latest and /metrics; tolerates
|
|
|
|
|
* missing payload keys.
|
|
|
|
|
* - Trend charts per device (T07): EnergyCharts component (Recharts isolated inside).
|
2026-06-23 23:32:39 +02:00
|
|
|
*
|
|
|
|
|
* M6-T10: added Tabs layout with Devices / Contracts / Prices / Costs tabs.
|
2026-06-22 13:52:33 +02:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { useState } from 'react'
|
|
|
|
|
import {
|
|
|
|
|
Container,
|
|
|
|
|
Title,
|
|
|
|
|
Table,
|
|
|
|
|
Button,
|
|
|
|
|
Group,
|
|
|
|
|
Text,
|
|
|
|
|
Loader,
|
|
|
|
|
Center,
|
|
|
|
|
Alert,
|
|
|
|
|
Stack,
|
|
|
|
|
Badge,
|
|
|
|
|
ScrollArea,
|
|
|
|
|
Modal,
|
|
|
|
|
Code,
|
|
|
|
|
Tooltip,
|
2026-06-22 14:17:59 +02:00
|
|
|
Paper,
|
|
|
|
|
SimpleGrid,
|
|
|
|
|
Divider,
|
2026-06-22 19:18:29 +02:00
|
|
|
Switch,
|
2026-06-23 23:32:39 +02:00
|
|
|
Tabs,
|
2026-06-22 13:52:33 +02:00
|
|
|
} from '@mantine/core'
|
2026-06-22 14:17:59 +02:00
|
|
|
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
|
2026-06-22 13:52:33 +02:00
|
|
|
import { DeviceForm } from '../energy/DeviceForm'
|
2026-06-22 14:17:59 +02:00
|
|
|
import { EnergyCharts } from '../energy/EnergyCharts'
|
2026-06-23 23:32:39 +02:00
|
|
|
import { ContractManager } from '../energy/ContractManager'
|
|
|
|
|
import { TibberPrices } from '../energy/TibberPrices'
|
|
|
|
|
import { CostView } from '../energy/CostView'
|
2026-06-24 11:02:42 +02:00
|
|
|
import { DsmrPanel } from '../energy/DsmrPanel'
|
2026-06-22 14:17:59 +02:00
|
|
|
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
|
2026-06-22 13:52:33 +02:00
|
|
|
import { ApiError } from '../api/client'
|
2026-06-22 19:18:29 +02:00
|
|
|
import { formatMetricValue } from '../energy/format'
|
2026-06-22 13:52:33 +02:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Delete confirmation modal (with 409 "disable instead" hint)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface ConfirmDeleteProps {
|
|
|
|
|
device: ModbusDevice
|
|
|
|
|
onConfirm: () => void
|
|
|
|
|
onCancel: () => void
|
|
|
|
|
loading: boolean
|
|
|
|
|
/** Set when the delete failed with 409. */
|
|
|
|
|
has409Error: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error }: ConfirmDeleteProps) {
|
|
|
|
|
return (
|
|
|
|
|
<Modal
|
|
|
|
|
opened
|
|
|
|
|
onClose={onCancel}
|
|
|
|
|
title="Confirm Delete"
|
|
|
|
|
size="sm"
|
|
|
|
|
data-testid="device-delete-modal"
|
|
|
|
|
>
|
|
|
|
|
<Stack gap="md">
|
|
|
|
|
<Text data-testid="device-delete-message">
|
|
|
|
|
Delete device <strong>{device.friendly_name}</strong>?
|
|
|
|
|
</Text>
|
|
|
|
|
|
|
|
|
|
{has409Error && (
|
|
|
|
|
<Alert color="orange" data-testid="device-delete-409-hint">
|
|
|
|
|
This device has existing readings and cannot be deleted. Consider{' '}
|
|
|
|
|
<strong>disabling</strong> it instead (click Edit → uncheck Enabled).
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Group justify="flex-end" gap="sm">
|
|
|
|
|
<Button variant="default" onClick={onCancel} data-testid="device-delete-cancel">
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
color="red"
|
|
|
|
|
loading={loading}
|
|
|
|
|
onClick={onConfirm}
|
|
|
|
|
data-testid="device-delete-confirm"
|
|
|
|
|
>
|
|
|
|
|
Delete
|
|
|
|
|
</Button>
|
|
|
|
|
</Group>
|
|
|
|
|
</Stack>
|
|
|
|
|
</Modal>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Test-read result modal
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface TestResultModalProps {
|
|
|
|
|
result: ModbusTestReadResponse
|
|
|
|
|
deviceName: string
|
2026-06-22 19:18:29 +02:00
|
|
|
metrics?: MetricInfo[]
|
2026-06-22 13:52:33 +02:00
|
|
|
onClose: () => void
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 19:18:29 +02:00
|
|
|
function TestResultModal({ result, deviceName, metrics, onClose }: TestResultModalProps) {
|
2026-06-22 13:52:33 +02:00
|
|
|
return (
|
|
|
|
|
<Modal
|
|
|
|
|
opened
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
title={`Test read — ${deviceName}`}
|
|
|
|
|
size="md"
|
|
|
|
|
data-testid="test-read-modal"
|
|
|
|
|
>
|
|
|
|
|
{result.ok ? (
|
|
|
|
|
<Stack gap="sm">
|
|
|
|
|
<Badge color="green" data-testid="test-read-ok">Success</Badge>
|
2026-06-22 19:18:29 +02:00
|
|
|
{result.payload && typeof result.payload === 'object' ? (
|
|
|
|
|
<SimpleGrid cols={2} spacing="xs" data-testid="test-read-payload">
|
|
|
|
|
{Object.entries(result.payload as Record<string, unknown>).map(([key, val]) => {
|
|
|
|
|
const metricMeta = metrics?.find((m) => m.key === key)
|
|
|
|
|
return (
|
|
|
|
|
<Group key={key} justify="space-between" align="baseline" gap={4}
|
|
|
|
|
data-testid={`test-read-value-${key}`}>
|
|
|
|
|
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
|
|
|
|
{metricMeta?.label ?? key}
|
|
|
|
|
</Text>
|
|
|
|
|
<Text size="sm" fw={500}>
|
|
|
|
|
{formatMetricValue(val, metricMeta)}
|
|
|
|
|
{metricMeta?.unit ? (
|
|
|
|
|
<Text component="span" size="xs" c="dimmed" ml={2}>
|
|
|
|
|
{metricMeta.unit}
|
|
|
|
|
</Text>
|
|
|
|
|
) : null}
|
|
|
|
|
</Text>
|
|
|
|
|
</Group>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</SimpleGrid>
|
|
|
|
|
) : (
|
|
|
|
|
<Code block data-testid="test-read-payload">
|
|
|
|
|
{JSON.stringify(result.payload, null, 2)}
|
|
|
|
|
</Code>
|
|
|
|
|
)}
|
2026-06-22 13:52:33 +02:00
|
|
|
</Stack>
|
|
|
|
|
) : (
|
|
|
|
|
<Stack gap="sm">
|
|
|
|
|
<Badge color="red" data-testid="test-read-error-badge">Failed</Badge>
|
|
|
|
|
<Text c="red" data-testid="test-read-error">
|
|
|
|
|
{result.error ?? 'Unknown error'}
|
|
|
|
|
</Text>
|
|
|
|
|
</Stack>
|
|
|
|
|
)}
|
|
|
|
|
<Group justify="flex-end" mt="md">
|
|
|
|
|
<Button variant="default" onClick={onClose}>
|
|
|
|
|
Close
|
|
|
|
|
</Button>
|
|
|
|
|
</Group>
|
|
|
|
|
</Modal>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Device row action: test-read button (self-contained per row)
|
2026-06-22 14:17:59 +02:00
|
|
|
// T06 OBS-1 fix: catch errors from mutateAsync so they don't produce unhandled
|
|
|
|
|
// promise rejections (e.g. 422 when profile cannot load).
|
2026-06-22 13:52:33 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface TestReadButtonProps {
|
|
|
|
|
device: ModbusDevice
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TestReadButton({ device }: TestReadButtonProps) {
|
|
|
|
|
const testMutation = useTestReadDevice()
|
2026-06-22 19:18:29 +02:00
|
|
|
const metricsQuery = useMetrics(device.uuid)
|
2026-06-22 13:52:33 +02:00
|
|
|
const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
|
2026-06-22 14:17:59 +02:00
|
|
|
const [testError, setTestError] = useState<string | null>(null)
|
2026-06-22 13:52:33 +02:00
|
|
|
|
|
|
|
|
async function handleTest() {
|
|
|
|
|
setResult(null)
|
2026-06-22 14:17:59 +02:00
|
|
|
setTestError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await testMutation.mutateAsync(device.uuid)
|
|
|
|
|
if (res.data) {
|
|
|
|
|
setResult(res.data)
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// Network or API errors (e.g. 422 profile load failure) — surface inline.
|
|
|
|
|
const message =
|
|
|
|
|
err instanceof ApiError
|
|
|
|
|
? `Error ${err.status}: ${JSON.stringify(err.body?.detail ?? err.body ?? 'unknown error')}`
|
|
|
|
|
: err instanceof Error
|
|
|
|
|
? err.message
|
|
|
|
|
: 'Unexpected error during test read.'
|
|
|
|
|
setTestError(message)
|
2026-06-22 13:52:33 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<Tooltip label="Immediately read device (not saved)" position="top">
|
|
|
|
|
<Button
|
|
|
|
|
size="xs"
|
|
|
|
|
variant="outline"
|
|
|
|
|
color="blue"
|
|
|
|
|
loading={testMutation.isPending}
|
|
|
|
|
onClick={handleTest}
|
|
|
|
|
data-testid={`device-test-${device.uuid}`}
|
|
|
|
|
>
|
|
|
|
|
Test read
|
|
|
|
|
</Button>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
2026-06-22 14:17:59 +02:00
|
|
|
{/* Show inline test error as a small alert (no modal needed for error path) */}
|
|
|
|
|
{testError && (
|
|
|
|
|
<Modal
|
|
|
|
|
opened
|
|
|
|
|
onClose={() => setTestError(null)}
|
|
|
|
|
title={`Test read error — ${device.friendly_name}`}
|
|
|
|
|
size="sm"
|
|
|
|
|
data-testid="test-read-error-modal"
|
|
|
|
|
>
|
|
|
|
|
<Text c="red" data-testid="test-read-inline-error">
|
|
|
|
|
{testError}
|
|
|
|
|
</Text>
|
|
|
|
|
<Group justify="flex-end" mt="md">
|
|
|
|
|
<Button variant="default" onClick={() => setTestError(null)}>
|
|
|
|
|
Close
|
|
|
|
|
</Button>
|
|
|
|
|
</Group>
|
|
|
|
|
</Modal>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-22 13:52:33 +02:00
|
|
|
{result && (
|
|
|
|
|
<TestResultModal
|
|
|
|
|
result={result}
|
|
|
|
|
deviceName={device.friendly_name}
|
2026-06-22 19:18:29 +02:00
|
|
|
metrics={metricsQuery.data?.metrics}
|
2026-06-22 13:52:33 +02:00
|
|
|
onClose={() => setResult(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 14:17:59 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Latest readings card (T07)
|
|
|
|
|
// Fetches /latest and /metrics; tolerates missing payload keys.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface LatestReadingsCardProps {
|
|
|
|
|
device: ModbusDevice
|
2026-06-22 19:18:29 +02:00
|
|
|
autoRefresh?: { refetchIntervalMs: number }
|
2026-06-22 14:17:59 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-22 19:18:29 +02:00
|
|
|
function LatestReadingsCard({ device, autoRefresh }: LatestReadingsCardProps) {
|
|
|
|
|
const latestQuery = useLatestReading(device.uuid, autoRefresh)
|
2026-06-22 14:17:59 +02:00
|
|
|
const metricsQuery = useMetrics(device.uuid)
|
|
|
|
|
|
|
|
|
|
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
|
|
|
|
|
const latest = latestQuery.data
|
|
|
|
|
|
|
|
|
|
if (latestQuery.isLoading || metricsQuery.isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<Paper withBorder p="sm" data-testid={`latest-card-loading-${device.uuid}`}>
|
|
|
|
|
<Group gap="xs">
|
|
|
|
|
<Loader size="xs" />
|
|
|
|
|
<Text size="sm" c="dimmed">
|
|
|
|
|
Loading latest reading…
|
|
|
|
|
</Text>
|
|
|
|
|
</Group>
|
|
|
|
|
</Paper>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (latestQuery.isError || metricsQuery.isError) {
|
|
|
|
|
return (
|
|
|
|
|
<Alert color="red" data-testid={`latest-card-error-${device.uuid}`}>
|
|
|
|
|
Failed to load latest reading.
|
|
|
|
|
</Alert>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!latest?.found || !latest.payload) {
|
|
|
|
|
return (
|
|
|
|
|
<Paper withBorder p="sm" data-testid={`latest-card-empty-${device.uuid}`}>
|
|
|
|
|
<Text size="sm" c="dimmed">
|
|
|
|
|
No readings yet.
|
|
|
|
|
</Text>
|
|
|
|
|
</Paper>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = latest.payload as Record<string, unknown>
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Paper withBorder p="sm" data-testid={`latest-card-${device.uuid}`}>
|
|
|
|
|
<Stack gap={4}>
|
|
|
|
|
<Group justify="space-between" align="center">
|
|
|
|
|
<Text size="xs" fw={600} c="dimmed">
|
|
|
|
|
Latest reading
|
|
|
|
|
</Text>
|
|
|
|
|
{latest.recorded_at && (
|
|
|
|
|
<Text size="xs" c="dimmed">
|
|
|
|
|
{new Date(latest.recorded_at).toLocaleString()}
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
</Group>
|
|
|
|
|
<Divider />
|
|
|
|
|
<SimpleGrid cols={2} spacing="xs">
|
|
|
|
|
{metrics.map((m) => {
|
|
|
|
|
const raw = payload[m.key]
|
2026-06-22 19:18:29 +02:00
|
|
|
const displayValue = formatMetricValue(raw, m)
|
|
|
|
|
const hasValue = displayValue !== '—'
|
2026-06-22 14:17:59 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Group key={m.key} justify="space-between" align="baseline" gap={4}
|
|
|
|
|
data-testid={`latest-value-${device.uuid}-${m.key}`}>
|
|
|
|
|
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
|
|
|
|
{m.label}
|
|
|
|
|
</Text>
|
|
|
|
|
<Text size="sm" fw={500}>
|
|
|
|
|
{displayValue}
|
|
|
|
|
{hasValue && m.unit ? (
|
|
|
|
|
<Text component="span" size="xs" c="dimmed" ml={2}>
|
|
|
|
|
{m.unit}
|
|
|
|
|
</Text>
|
|
|
|
|
) : null}
|
|
|
|
|
</Text>
|
|
|
|
|
</Group>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</SimpleGrid>
|
|
|
|
|
</Stack>
|
|
|
|
|
</Paper>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:52:33 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Device list table
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface DeviceTableProps {
|
|
|
|
|
devices: ModbusDevice[]
|
|
|
|
|
onEdit: (device: ModbusDevice) => void
|
|
|
|
|
onDelete: (device: ModbusDevice) => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DeviceTable({ devices, onEdit, onDelete }: DeviceTableProps) {
|
|
|
|
|
if (devices.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<Text c="dimmed" ta="center" size="sm" data-testid="devices-empty">
|
|
|
|
|
No devices configured yet. Click "New Device" to add one.
|
|
|
|
|
</Text>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ScrollArea>
|
|
|
|
|
<Table striped highlightOnHover withTableBorder data-testid="devices-table">
|
|
|
|
|
<Table.Thead>
|
|
|
|
|
<Table.Tr>
|
|
|
|
|
<Table.Th>Name</Table.Th>
|
|
|
|
|
<Table.Th>Host</Table.Th>
|
|
|
|
|
<Table.Th>Port</Table.Th>
|
|
|
|
|
<Table.Th>Unit ID</Table.Th>
|
|
|
|
|
<Table.Th>Profile</Table.Th>
|
|
|
|
|
<Table.Th>Poll (s)</Table.Th>
|
|
|
|
|
<Table.Th>Status</Table.Th>
|
|
|
|
|
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
</Table.Thead>
|
|
|
|
|
<Table.Tbody>
|
|
|
|
|
{devices.map((device) => (
|
|
|
|
|
<Table.Tr key={device.uuid} data-testid={`device-row-${device.uuid}`}>
|
|
|
|
|
<Table.Td>
|
|
|
|
|
<Text fw={500} size="sm">
|
|
|
|
|
{device.friendly_name}
|
|
|
|
|
</Text>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
<Table.Td>{device.host}</Table.Td>
|
|
|
|
|
<Table.Td>{device.port}</Table.Td>
|
|
|
|
|
<Table.Td>{device.unit_id}</Table.Td>
|
|
|
|
|
<Table.Td>
|
|
|
|
|
<Badge variant="outline" size="sm">
|
|
|
|
|
{device.profile}
|
|
|
|
|
</Badge>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
<Table.Td>{device.poll_interval_s}</Table.Td>
|
|
|
|
|
<Table.Td>
|
|
|
|
|
<Group gap="xs">
|
|
|
|
|
<Badge color={device.enabled ? 'green' : 'gray'} variant="light" size="sm">
|
|
|
|
|
{device.enabled ? 'enabled' : 'disabled'}
|
|
|
|
|
</Badge>
|
|
|
|
|
{device.last_poll_ok !== null && (
|
|
|
|
|
<Badge
|
|
|
|
|
color={device.last_poll_ok ? 'teal' : 'red'}
|
|
|
|
|
variant="dot"
|
|
|
|
|
size="sm"
|
|
|
|
|
>
|
|
|
|
|
{device.last_poll_ok ? 'online' : 'offline'}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</Group>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
<Table.Td>
|
|
|
|
|
<Group justify="flex-end" gap="xs">
|
|
|
|
|
<TestReadButton device={device} />
|
|
|
|
|
<Button
|
|
|
|
|
size="xs"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => onEdit(device)}
|
|
|
|
|
data-testid={`device-edit-${device.uuid}`}
|
|
|
|
|
>
|
|
|
|
|
Edit
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="xs"
|
|
|
|
|
variant="outline"
|
|
|
|
|
color="red"
|
|
|
|
|
onClick={() => onDelete(device)}
|
|
|
|
|
data-testid={`device-delete-${device.uuid}`}
|
|
|
|
|
>
|
|
|
|
|
Delete
|
|
|
|
|
</Button>
|
|
|
|
|
</Group>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
))}
|
|
|
|
|
</Table.Tbody>
|
|
|
|
|
</Table>
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 14:17:59 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Device readings section — latest card + trend chart per device (T07)
|
2026-06-22 19:18:29 +02:00
|
|
|
// Auto-refresh switch controls TanStack Query refetchInterval for all
|
|
|
|
|
// readings and latest queries in this section.
|
2026-06-22 14:17:59 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-06-22 19:18:29 +02:00
|
|
|
/** Default auto-refresh interval in milliseconds (matches backend ~5s poll). */
|
|
|
|
|
const AUTO_REFRESH_INTERVAL_MS = 5_000
|
|
|
|
|
|
2026-06-22 14:17:59 +02:00
|
|
|
interface DeviceReadingsSectionProps {
|
|
|
|
|
devices: ModbusDevice[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) {
|
2026-06-22 19:18:29 +02:00
|
|
|
// Default on — user wants charts to auto-update without manual refresh.
|
|
|
|
|
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
|
|
|
|
|
|
2026-06-22 14:17:59 +02:00
|
|
|
if (devices.length === 0) return null
|
|
|
|
|
|
2026-06-22 19:18:29 +02:00
|
|
|
const autoRefresh = autoRefreshEnabled
|
|
|
|
|
? { refetchIntervalMs: AUTO_REFRESH_INTERVAL_MS }
|
|
|
|
|
: undefined
|
|
|
|
|
|
2026-06-22 14:17:59 +02:00
|
|
|
return (
|
|
|
|
|
<Stack gap="xl" data-testid="device-readings-section">
|
2026-06-22 19:18:29 +02:00
|
|
|
<Group justify="space-between" align="center">
|
|
|
|
|
<Divider label="Readings & Trends" labelPosition="left" style={{ flex: 1 }} />
|
|
|
|
|
<Switch
|
2026-06-22 19:40:18 +02:00
|
|
|
label="Auto-refresh"
|
2026-06-22 19:18:29 +02:00
|
|
|
checked={autoRefreshEnabled}
|
|
|
|
|
onChange={(e) => setAutoRefreshEnabled(e.currentTarget.checked)}
|
|
|
|
|
size="sm"
|
|
|
|
|
data-testid="auto-refresh-switch"
|
|
|
|
|
/>
|
|
|
|
|
</Group>
|
2026-06-22 14:17:59 +02:00
|
|
|
{devices.map((device) => (
|
|
|
|
|
<Stack key={device.uuid} gap="sm" data-testid={`device-readings-${device.uuid}`}>
|
|
|
|
|
<Text fw={500} size="sm">
|
|
|
|
|
{device.friendly_name}
|
|
|
|
|
</Text>
|
2026-06-22 19:18:29 +02:00
|
|
|
<LatestReadingsCard device={device} autoRefresh={autoRefresh} />
|
|
|
|
|
<EnergyCharts
|
|
|
|
|
uuid={device.uuid}
|
|
|
|
|
deviceName={device.friendly_name}
|
|
|
|
|
autoRefresh={autoRefresh}
|
|
|
|
|
/>
|
2026-06-22 14:17:59 +02:00
|
|
|
</Stack>
|
|
|
|
|
))}
|
|
|
|
|
</Stack>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:52:33 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-06-23 23:32:39 +02:00
|
|
|
// DevicesTab — self-contained devices management tab (extracted from original EnergyPage)
|
2026-06-22 13:52:33 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-06-23 23:32:39 +02:00
|
|
|
function DevicesTab() {
|
2026-06-22 13:52:33 +02:00
|
|
|
const devicesQuery = useDevices()
|
|
|
|
|
const deleteMutation = useDeleteDevice()
|
|
|
|
|
|
|
|
|
|
const [editDevice, setEditDevice] = useState<ModbusDevice | null>(null)
|
|
|
|
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
|
|
|
|
const [deleteDevice, setDeleteDevice] = useState<ModbusDevice | null>(null)
|
|
|
|
|
const [delete409, setDelete409] = useState(false)
|
|
|
|
|
|
|
|
|
|
function openCreate() {
|
|
|
|
|
setEditDevice(null)
|
|
|
|
|
setShowCreateForm(true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openEdit(device: ModbusDevice) {
|
|
|
|
|
setShowCreateForm(false)
|
|
|
|
|
setEditDevice(device)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openDelete(device: ModbusDevice) {
|
|
|
|
|
setDeleteDevice(device)
|
|
|
|
|
setDelete409(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeDelete() {
|
|
|
|
|
setDeleteDevice(null)
|
|
|
|
|
setDelete409(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleDeleteConfirm() {
|
|
|
|
|
if (!deleteDevice) return
|
|
|
|
|
setDelete409(false)
|
|
|
|
|
try {
|
|
|
|
|
await deleteMutation.mutateAsync(deleteDevice.uuid)
|
|
|
|
|
closeDelete()
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (err instanceof ApiError && err.status === 409) {
|
|
|
|
|
setDelete409(true)
|
|
|
|
|
}
|
|
|
|
|
// Leave modal open so user sees the hint.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (devicesQuery.isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<Center pt="xl" data-testid="energy-loading">
|
|
|
|
|
<Loader />
|
|
|
|
|
</Center>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (devicesQuery.isError || !devicesQuery.data) {
|
|
|
|
|
return (
|
2026-06-23 23:32:39 +02:00
|
|
|
<Alert color="red" data-testid="energy-load-error">
|
|
|
|
|
Failed to load devices. Please refresh.
|
|
|
|
|
</Alert>
|
2026-06-22 13:52:33 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const devices = devicesQuery.data.items
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-23 23:32:39 +02:00
|
|
|
<Stack gap="lg">
|
|
|
|
|
<Group justify="space-between" align="center">
|
|
|
|
|
<Title order={2}>Energy — Devices</Title>
|
|
|
|
|
<Button onClick={openCreate} data-testid="device-new-button">
|
|
|
|
|
New Device
|
|
|
|
|
</Button>
|
|
|
|
|
</Group>
|
2026-06-22 13:52:33 +02:00
|
|
|
|
2026-06-23 23:32:39 +02:00
|
|
|
<DeviceTable
|
|
|
|
|
devices={devices}
|
|
|
|
|
onEdit={openEdit}
|
|
|
|
|
onDelete={openDelete}
|
|
|
|
|
/>
|
2026-06-22 14:17:59 +02:00
|
|
|
|
2026-06-23 23:32:39 +02:00
|
|
|
{/* Latest readings cards + trend charts (T07) */}
|
|
|
|
|
<DeviceReadingsSection devices={devices} />
|
2026-06-22 13:52:33 +02:00
|
|
|
|
|
|
|
|
{/* Create form */}
|
|
|
|
|
{showCreateForm && (
|
|
|
|
|
<DeviceForm
|
|
|
|
|
onClose={() => setShowCreateForm(false)}
|
|
|
|
|
onSaved={() => setShowCreateForm(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Edit form */}
|
|
|
|
|
{editDevice && (
|
|
|
|
|
<DeviceForm
|
|
|
|
|
device={editDevice}
|
|
|
|
|
onClose={() => setEditDevice(null)}
|
|
|
|
|
onSaved={() => setEditDevice(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Delete confirmation */}
|
|
|
|
|
{deleteDevice && (
|
|
|
|
|
<ConfirmDeleteModal
|
|
|
|
|
device={deleteDevice}
|
|
|
|
|
onConfirm={handleDeleteConfirm}
|
|
|
|
|
onCancel={closeDelete}
|
|
|
|
|
loading={deleteMutation.isPending}
|
|
|
|
|
has409Error={delete409}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-06-23 23:32:39 +02:00
|
|
|
</Stack>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// EnergyPage — top-level with Tabs
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export function EnergyPage() {
|
|
|
|
|
return (
|
|
|
|
|
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
|
|
|
|
|
<Tabs defaultValue="devices">
|
|
|
|
|
<Tabs.List mb="lg">
|
|
|
|
|
<Tabs.Tab value="devices" data-testid="tab-devices">
|
|
|
|
|
Devices
|
|
|
|
|
</Tabs.Tab>
|
|
|
|
|
<Tabs.Tab value="contracts" data-testid="tab-contracts">
|
|
|
|
|
Contracts
|
|
|
|
|
</Tabs.Tab>
|
|
|
|
|
<Tabs.Tab value="prices" data-testid="tab-prices">
|
|
|
|
|
Prices
|
|
|
|
|
</Tabs.Tab>
|
|
|
|
|
<Tabs.Tab value="costs" data-testid="tab-costs">
|
|
|
|
|
Costs
|
|
|
|
|
</Tabs.Tab>
|
2026-06-24 11:02:42 +02:00
|
|
|
<Tabs.Tab value="dsmr" data-testid="tab-dsmr">
|
|
|
|
|
DSMR
|
|
|
|
|
</Tabs.Tab>
|
2026-06-23 23:32:39 +02:00
|
|
|
</Tabs.List>
|
|
|
|
|
|
|
|
|
|
<Tabs.Panel value="devices" data-testid="panel-devices">
|
|
|
|
|
<DevicesTab />
|
|
|
|
|
</Tabs.Panel>
|
|
|
|
|
|
|
|
|
|
<Tabs.Panel value="contracts" data-testid="panel-contracts">
|
|
|
|
|
<ContractManager />
|
|
|
|
|
</Tabs.Panel>
|
|
|
|
|
|
|
|
|
|
<Tabs.Panel value="prices" data-testid="panel-prices">
|
|
|
|
|
<TibberPrices />
|
|
|
|
|
</Tabs.Panel>
|
|
|
|
|
|
|
|
|
|
<Tabs.Panel value="costs" data-testid="panel-costs">
|
|
|
|
|
<CostView />
|
|
|
|
|
</Tabs.Panel>
|
2026-06-24 11:02:42 +02:00
|
|
|
|
|
|
|
|
<Tabs.Panel value="dsmr" data-testid="panel-dsmr">
|
|
|
|
|
<DsmrPanel />
|
|
|
|
|
</Tabs.Panel>
|
2026-06-23 23:32:39 +02:00
|
|
|
</Tabs>
|
2026-06-22 13:52:33 +02:00
|
|
|
</Container>
|
|
|
|
|
)
|
|
|
|
|
}
|