M5-T06: add Energy device management UI and sidebar entry
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Tests for EnergyPage (M5-T06).
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Renders device list from GET /api/modbus/devices.
|
||||
* 2. Empty state when no devices.
|
||||
* 3. "New Device" button opens DeviceForm modal.
|
||||
* 4. Edit button opens DeviceForm modal pre-filled with device data.
|
||||
* 5. Delete button opens confirmation modal; confirm calls DELETE.
|
||||
* 6. Delete 409 error shows friendly hint in confirmation modal.
|
||||
* 7. Test-read button calls POST /api/modbus/devices/{uuid}/test and shows result.
|
||||
* 8. Test-read failure shows error in modal.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { renderWithProviders } from '../test-utils'
|
||||
import { EnergyPage } from './EnergyPage'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEVICE = {
|
||||
uuid: 'abc-123',
|
||||
friendly_name: 'SDM120 Main',
|
||||
transport: 'tcp',
|
||||
host: '192.168.1.100',
|
||||
port: 502,
|
||||
unit_id: 1,
|
||||
profile: 'sdm120',
|
||||
poll_interval_s: 5,
|
||||
enabled: true,
|
||||
last_poll_at: '2026-06-22T10:00:00Z',
|
||||
last_poll_ok: true,
|
||||
created_at: '2026-06-01T00:00:00Z',
|
||||
updated_at: '2026-06-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const PROFILES_RESP = {
|
||||
profiles: [{ name: 'sdm120', description: 'Eastron SDM120 single-phase energy meter' }],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock apiClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
const mockPatch = vi.fn()
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
default: {
|
||||
GET: (...args: unknown[]) => mockGet(...args),
|
||||
POST: (...args: unknown[]) => mockPost(...args),
|
||||
PATCH: (...args: unknown[]) => mockPatch(...args),
|
||||
DELETE: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number
|
||||
body: unknown
|
||||
constructor(status: number, body: unknown) {
|
||||
super(`API error ${status}`)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
},
|
||||
registerLoginRedirect: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setupDefaultMocks(devices = [DEVICE]) {
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === '/api/modbus/devices') {
|
||||
return Promise.resolve({ data: { items: devices, total: devices.length } })
|
||||
}
|
||||
if (path === '/api/modbus/profiles') {
|
||||
return Promise.resolve({ data: PROFILES_RESP })
|
||||
}
|
||||
return Promise.resolve({ data: null })
|
||||
})
|
||||
}
|
||||
|
||||
function renderEnergy() {
|
||||
return renderWithProviders(<EnergyPage />, { initialPath: '/energy' })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('EnergyPage — device list', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupDefaultMocks()
|
||||
})
|
||||
|
||||
it('renders device list from GET /api/modbus/devices', async () => {
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('devices-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('SDM120 Main')).toBeInTheDocument()
|
||||
expect(screen.getByText('192.168.1.100')).toBeInTheDocument()
|
||||
expect(screen.getByText('sdm120')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty state when no devices', async () => {
|
||||
setupDefaultMocks([])
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('devices-empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading state initially', () => {
|
||||
// Delay resolution so we can catch the loading state
|
||||
mockGet.mockReturnValue(new Promise(() => {}))
|
||||
renderEnergy()
|
||||
|
||||
expect(screen.getByTestId('energy-loading')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('EnergyPage — create device', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupDefaultMocks()
|
||||
})
|
||||
|
||||
it('opens DeviceForm modal when New Device is clicked', async () => {
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('device-new-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('device-new-button'))
|
||||
|
||||
// Wait for both modal AND the form (which appears after profiles load)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('device-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('device-friendly-name')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('device-host')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submit on new device form calls POST /api/modbus/devices', async () => {
|
||||
mockPost.mockResolvedValue({ data: DEVICE })
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('device-new-button')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId('device-new-button'))
|
||||
|
||||
// Wait for profiles to load so the form is visible (profiles auto-select sdm120)
|
||||
await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
|
||||
|
||||
// Fill required fields (profile is auto-selected to 'sdm120')
|
||||
fireEvent.change(screen.getByTestId('device-friendly-name'), {
|
||||
target: { value: 'Test Device' },
|
||||
})
|
||||
fireEvent.change(screen.getByTestId('device-host'), {
|
||||
target: { value: '10.0.0.1' },
|
||||
})
|
||||
|
||||
fireEvent.submit(screen.getByTestId('device-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/api/modbus/devices',
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
friendly_name: 'Test Device',
|
||||
host: '10.0.0.1',
|
||||
profile: 'sdm120',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EnergyPage — edit device', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupDefaultMocks()
|
||||
})
|
||||
|
||||
it('opens DeviceForm modal with device data when Edit is clicked', async () => {
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`device-edit-${DEVICE.uuid}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId(`device-edit-${DEVICE.uuid}`))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Wait for profiles to load so form body is visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('device-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// UUID shown in edit mode
|
||||
expect(screen.getByTestId('device-uuid-display').textContent).toContain(DEVICE.uuid)
|
||||
})
|
||||
})
|
||||
|
||||
describe('EnergyPage — delete device', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupDefaultMocks()
|
||||
})
|
||||
|
||||
it('shows confirmation modal on Delete click', async () => {
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('device-delete-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('device-delete-message').textContent).toContain('SDM120 Main')
|
||||
})
|
||||
|
||||
it('calls DELETE on confirm and closes modal', async () => {
|
||||
mockDelete.mockResolvedValue({ data: null })
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
|
||||
await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByTestId('device-delete-confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||
params: { path: { uuid: DEVICE.uuid } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('cancel button closes confirmation modal', async () => {
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
|
||||
await waitFor(() => expect(screen.getByTestId('device-delete-cancel')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByTestId('device-delete-cancel'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('device-delete-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows friendly 409 hint when device has readings', async () => {
|
||||
const { ApiError } = await import('../api/client')
|
||||
mockDelete.mockRejectedValue(new ApiError(409, { detail: 'device has readings' }))
|
||||
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
|
||||
await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByTestId('device-delete-confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('device-delete-409-hint')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EnergyPage — test-read', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupDefaultMocks()
|
||||
})
|
||||
|
||||
it('shows success payload modal after successful test-read', async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
data: { ok: true, payload: { voltage: 230.2, current: 1.3 } },
|
||||
})
|
||||
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`device-test-${DEVICE.uuid}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId(`device-test-${DEVICE.uuid}`))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('test-read-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('test-read-ok')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('test-read-payload').textContent).toContain('230.2')
|
||||
})
|
||||
|
||||
it('shows error modal after failed test-read', async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
data: { ok: false, error: 'Connection timed out' },
|
||||
})
|
||||
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`device-test-${DEVICE.uuid}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId(`device-test-${DEVICE.uuid}`))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('test-read-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('test-read-error')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('test-read-error').textContent).toContain('Connection timed out')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* EnergyPage — device management UI for the Energy (Modbus) domain.
|
||||
*
|
||||
* Features:
|
||||
* - 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
|
||||
* decoded payload or error without persisting to the database.
|
||||
*
|
||||
* Out of scope (T07): readings cards, trend charts, Recharts import.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Table,
|
||||
Button,
|
||||
Group,
|
||||
Text,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
Stack,
|
||||
Badge,
|
||||
ScrollArea,
|
||||
Modal,
|
||||
Code,
|
||||
Tooltip,
|
||||
} from '@mantine/core'
|
||||
import { useDevices, useDeleteDevice, useTestReadDevice } from '../energy/hooks'
|
||||
import { DeviceForm } from '../energy/DeviceForm'
|
||||
import type { ModbusDevice, ModbusTestReadResponse } from '../energy/hooks'
|
||||
import { ApiError } from '../api/client'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function TestResultModal({ result, deviceName, onClose }: TestResultModalProps) {
|
||||
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>
|
||||
<Code block data-testid="test-read-payload">
|
||||
{JSON.stringify(result.payload, null, 2)}
|
||||
</Code>
|
||||
</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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TestReadButtonProps {
|
||||
device: ModbusDevice
|
||||
}
|
||||
|
||||
function TestReadButton({ device }: TestReadButtonProps) {
|
||||
const testMutation = useTestReadDevice()
|
||||
const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
|
||||
|
||||
async function handleTest() {
|
||||
setResult(null)
|
||||
const res = await testMutation.mutateAsync(device.uuid)
|
||||
if (res.data) {
|
||||
setResult(res.data)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{result && (
|
||||
<TestResultModal
|
||||
result={result}
|
||||
deviceName={device.friendly_name}
|
||||
onClose={() => setResult(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EnergyPage — top-level
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function EnergyPage() {
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading / error states
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (devicesQuery.isLoading) {
|
||||
return (
|
||||
<Center pt="xl" data-testid="energy-loading">
|
||||
<Loader />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (devicesQuery.isError || !devicesQuery.data) {
|
||||
return (
|
||||
<Container size="xl" pt="xl">
|
||||
<Alert color="red" data-testid="energy-load-error">
|
||||
Failed to load devices. Please refresh.
|
||||
</Alert>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const devices = devicesQuery.data.items
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
|
||||
<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>
|
||||
|
||||
<DeviceTable
|
||||
devices={devices}
|
||||
onEdit={openEdit}
|
||||
onDelete={openDelete}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user