From 963e43e3e41e98564b301bcf8e46fea3197a9c12 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 23 Aug 2026 14:13:56 +0200 Subject: [PATCH] M8-T18: add source and multi-commodity meter UI --- docs/design/m8-warmtelink-energy.md | 2 +- frontend/src/energy/DsmrPanel.test.tsx | 10 ++++ frontend/src/energy/DsmrPanel.tsx | 8 +-- frontend/src/energy/MeterManager.test.tsx | 37 +++++++++++++ frontend/src/energy/MeterManager.tsx | 58 +++++++++++++++++++-- frontend/src/energy/SourceForm.test.tsx | 15 ++++++ frontend/src/energy/SourceForm.tsx | 33 ++++++++++++ frontend/src/energy/SourceManager.test.tsx | 16 ++++++ frontend/src/energy/SourceManager.tsx | 33 ++++++++++++ frontend/src/energy/SourceReadings.test.tsx | 13 +++++ frontend/src/energy/SourceReadings.tsx | 12 +++++ frontend/src/energy/hooks.test.tsx | 19 +++++++ frontend/src/energy/hooks.ts | 48 ++++++++++++++++- frontend/src/pages/EnergyPage.test.tsx | 14 +++++ frontend/src/pages/EnergyPage.tsx | 19 +++---- 15 files changed, 319 insertions(+), 18 deletions(-) create mode 100644 frontend/src/energy/SourceForm.test.tsx create mode 100644 frontend/src/energy/SourceForm.tsx create mode 100644 frontend/src/energy/SourceManager.test.tsx create mode 100644 frontend/src/energy/SourceManager.tsx create mode 100644 frontend/src/energy/SourceReadings.test.tsx create mode 100644 frontend/src/energy/SourceReadings.tsx diff --git a/docs/design/m8-warmtelink-energy.md b/docs/design/m8-warmtelink-energy.md index 8f33096..bea5551 100644 --- a/docs/design/m8-warmtelink-energy.md +++ b/docs/design/m8-warmtelink-energy.md @@ -973,7 +973,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 ### M8-T18 — Sources UI 与多 Commodity Meters [structural] -- **Status**: `todo` +- **Status**: `done` - **Depends**: M8-T17 - **Context**: 把新数据源/binding 概念变成可配置体验,并把 Modbus 专用页面准确命名。 diff --git a/frontend/src/energy/DsmrPanel.test.tsx b/frontend/src/energy/DsmrPanel.test.tsx index c62e01f..3279ec5 100644 --- a/frontend/src/energy/DsmrPanel.test.tsx +++ b/frontend/src/energy/DsmrPanel.test.tsx @@ -50,6 +50,9 @@ describe('DsmrPanel', () => { renderWithProviders() await waitFor(() => expect(screen.getByTestId('dsmr-empty')).toBeInTheDocument()) expect(screen.queryByTestId('dsmr-table')).not.toBeInTheDocument() + expect(screen.getByText(/In this DSMR Source, enable or edit the broker, topic, and profile/)).toBeInTheDocument() + expect(screen.getByText(/confirm the publisher is sending/)).toBeInTheDocument() + expect(screen.queryByText(/Enable DSMR ingest.*Config/)).not.toBeInTheDocument() }) it('renders the latest telegram as a key/value table; null shown as dash', async () => { @@ -79,4 +82,11 @@ describe('DsmrPanel', () => { renderWithProviders() await waitFor(() => expect(screen.getByTestId('dsmr-error')).toBeInTheDocument()) }) + + it('keeps the compatibility endpoint available for DSMR source detail', async () => { + mockGet.mockResolvedValue({ data: { found: true, recorded_at: '2026-06-23T12:16:00Z', payload: { tariff: 'low' } } }) + renderWithProviders() + await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/api/energy/dsmr/latest')) + expect(await screen.findByText('Latest DSMR reading (compatibility view)')).toBeInTheDocument() + }) }) diff --git a/frontend/src/energy/DsmrPanel.tsx b/frontend/src/energy/DsmrPanel.tsx index fafe2c7..94fd2d3 100644 --- a/frontend/src/energy/DsmrPanel.tsx +++ b/frontend/src/energy/DsmrPanel.tsx @@ -47,7 +47,7 @@ export function DsmrPanel() {
- Latest DSMR reading + Latest DSMR reading (compatibility view) The most recent parsed telegram persisted to dsmr_reading. @@ -100,9 +100,9 @@ function DsmrContent({ isLoading, isError, data }: DsmrContentProps) { if (!data.found || !data.payload) { return ( - No DSMR data yet. Enable DSMR ingest in Config, make sure MQTT - is connected, and confirm the DSMR Reader is publishing to the configured topic - (default dsmr/json). Rows are stored about once every 10 seconds. + No DSMR data yet. In this DSMR Source, enable or edit the broker, topic, and profile + configuration, then confirm the publisher is sending to the configured topic (default + dsmr/json). Rows are stored about once every 10 seconds. ) } diff --git a/frontend/src/energy/MeterManager.test.tsx b/frontend/src/energy/MeterManager.test.tsx index 16e10c4..10aaaa9 100644 --- a/frontend/src/energy/MeterManager.test.tsx +++ b/frontend/src/energy/MeterManager.test.tsx @@ -84,6 +84,7 @@ const METERS_RESPONSE = { // --------------------------------------------------------------------------- describe('MeterManager — loading / error / empty states', () => { + // M8 keeps the existing Modbus-facing meter regressions alongside commodity additions. beforeEach(() => vi.clearAllMocks()) it('renders loading state initially', () => { @@ -115,6 +116,24 @@ describe('MeterManager — loading / error / empty states', () => { }) }) +describe('MeterManager — binding switch safety', () => { + beforeEach(() => vi.clearAllMocks()) + it('does not offer switching for a closed meter epoch', async () => { + mockGet.mockResolvedValue({ data: { items: [CLOSED_METER], total: 1 } }) + renderWithProviders() + await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'Switch source' })).not.toBeInTheDocument() + }) + it('disables switch submit until the binding timeline has loaded', async () => { + const user = userEvent.setup() + mockGet.mockImplementation((path: string) => path === '/api/energy/meters' ? Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } }) : new Promise(() => {})) + renderWithProviders() + await user.click(await screen.findByRole('button', { name: 'Switch source' })) + expect(await screen.findByText('Loading binding timeline…')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Switch binding' })).toBeDisabled() + }) +}) + describe('MeterManager — meter list', () => { beforeEach(() => vi.clearAllMocks()) @@ -140,6 +159,24 @@ describe('MeterManager — meter list', () => { expect(screen.getByText('meter_swap')).toBeInTheDocument() }) + it('renders every binding timeline segment with source, channel, and half-open boundaries', async () => { + const meterWithBindings = { + ...ACTIVE_METER, + bindings: [ + { uuid: 'binding-closed', source_uuid: 'source-old', source_channel_uuid: 'channel-old', started_at: '2025-01-01T00:00:00Z', ended_at: '2025-02-01T00:00:00Z' }, + { uuid: 'binding-active', source_uuid: 'source-new', source_channel_uuid: 'channel-new', started_at: '2025-02-01T00:00:00Z', ended_at: null }, + ], + } + mockGet.mockResolvedValue({ data: { items: [meterWithBindings], total: 1 } }) + renderWithProviders() + const closed = await screen.findByTestId('binding-timeline-binding-closed') + const active = screen.getByTestId('binding-timeline-binding-active') + expect(closed).toHaveTextContent('source-old → channel-old') + expect(closed).toHaveTextContent('[1/1/2025, 00:00:00, 2/1/2025, 00:00:00) (closed)') + expect(active).toHaveTextContent('source-new → channel-new') + expect(active).toHaveTextContent('[2/1/2025, 00:00:00, open-ended) (active)') + }) + it('renders "Declare New Meter" button', async () => { mockGet.mockResolvedValue({ data: METERS_RESPONSE }) diff --git a/frontend/src/energy/MeterManager.tsx b/frontend/src/energy/MeterManager.tsx index 2dfa954..c88ba79 100644 --- a/frontend/src/energy/MeterManager.tsx +++ b/frontend/src/energy/MeterManager.tsx @@ -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(null) const [note, setNote] = useState('') const [error, setError] = useState(null) + const [commodity, setCommodity] = useState('electricity') + const [sourceUuid, setSourceUuid] = useState(null) + const [channelUuid, setChannelUuid] = useState(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)[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" /> + { setSourceUuid(value); setChannelUuid(null) }} data={sources.data?.items.filter((source) => typeof source.uuid === 'string').map((source) => ({ value: source.uuid, label: source.name })) ?? []} /> + {sourceUuid &&