diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index e5c38bc..b45072b 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -559,6 +559,84 @@ export interface paths { patch?: never; trace?: never; }; + "/api/energy/meters": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Energy Meters + * @description List all meter epochs in ascending ``started_at`` order. + * + * Returns the full historical sequence of meter installations across all + * commodities. The active meter (``ended_at=null``) appears last because it + * has the latest ``started_at``. + */ + get: operations["list_energy_meters_api_energy_meters_get"]; + put?: never; + /** + * Declare Energy Meter + * @description Declare a new meter epoch (swap, home move, or initial declaration). + * + * Closes the current active meter for the given commodity at ``started_at`` + * and opens a new active meter. If no active meter exists, the new meter is + * simply created without closing anything. + * + * **Validation**: ``started_at`` must be **≥** the current active meter's + * own ``started_at`` (no chronological backdate below the active epoch's + * start). Equal timestamps are allowed (replaces the current meter at the + * same logical moment). Violation → 422. + * + * **Retroactive recompute**: if ``started_at`` is in the past, billing + * records from that point forward are re-judged via ``recompute_range`` to + * reflect the new meter attribution. The response includes the count of + * recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse`` + * — the recompute is transparent; callers should re-fetch costs if needed). + */ + post: operations["declare_energy_meter_api_energy_meters_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/energy/meters/{meter_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Patch Energy Meter + * @description Partially update a meter epoch: rename, edit note, or correct started_at. + * + * - ``label``: updates the human-readable label. + * - ``note``: updates the free-form note. + * - ``started_at``: **retroactive correction** — shifts this meter's start + * boundary. The service layer maintains timeline continuity by also + * updating the preceding meter's ``ended_at``. Validation: + * * Must be strictly after the previous meter's own ``started_at``. + * * Must be strictly before this meter's ``ended_at`` (if closed). + * Violation → 422. + * + * **Retroactive recompute when ``started_at`` changes**: billing records in + * the window ``[min(old, new), now)`` are re-judged to reflect the corrected + * meter attribution. + * + * Not found → 404. + */ + patch: operations["patch_energy_meter_api_energy_meters__meter_id__patch"]; + trace?: never; + }; "/api/expose": { parameters: { query?: never; @@ -1569,6 +1647,114 @@ export interface components { */ sell_normal: number; }; + /** + * MeterDeclareRequest + * @description Request body for POST /api/energy/meters. + * + * Declares a new meter epoch (swap, home move, or initial declaration). The + * service layer closes the current active meter for the given commodity at + * ``started_at`` and opens a new one. + * + * ``started_at`` follows the Principle-A localisation convention: a + * timezone-naive value is interpreted as the **server's local wall-clock time** + * (e.g. CEST midnight → stored as UTC the night before); a timezone-aware + * value is converted to UTC as-is. Omitting ``started_at`` is not allowed — + * every meter declaration must carry an explicit start timestamp. + * + * ``commodity`` defaults to ``"electricity"``; the field is available for + * future use with ``gas`` or ``heating``. + */ + MeterDeclareRequest: { + /** Label */ + label: string; + /** + * Started At + * Format: date-time + * @description UTC (or server-local naive) datetime from which this meter epoch starts. May be in the past (retroactive declaration). + */ + started_at: string; + /** @description Why this epoch was created. One of: initial, meter_swap, home_move, other. */ + reason: components["schemas"]["MeterReason"]; + /** Note */ + note?: string | null; + /** + * Commodity + * @description Energy commodity this meter measures. Defaults to 'electricity'. + * @default electricity + */ + commodity: string; + }; + /** + * MeterListResponse + * @description Response schema for GET /api/energy/meters. + * + * Meters are returned in ascending ``started_at`` order so the caller sees + * the historical installation sequence. + */ + MeterListResponse: { + /** Items */ + items: components["schemas"]["MeterResponse"][]; + /** Total */ + total: number; + }; + /** + * MeterPatchRequest + * @description Request body for PATCH /api/energy/meters/{id}. + * + * All fields are optional. Only non-``None`` values are applied. + * + * Updating ``started_at`` is a **retroactive correction**: the service layer + * maintains timeline continuity (adjusting the preceding meter's ``ended_at``) + * and the API layer triggers ``recompute_range`` over the affected window so + * that billing attribution is re-judged. + */ + MeterPatchRequest: { + /** Label */ + label?: string | null; + /** Note */ + note?: string | null; + /** + * Started At + * @description Retroactive correction of the meter epoch start timestamp. Triggers billing recompute over the affected window. + */ + started_at?: string | null; + }; + /** + * MeterReason + * @description Allowed values for the meter epoch creation reason. + * @enum {string} + */ + MeterReason: "initial" | "meter_swap" | "home_move" | "other"; + /** + * MeterResponse + * @description Response schema for a single Meter epoch row. + * + * ``ended_at`` is ``null`` for the currently active meter. + */ + MeterResponse: { + /** Id */ + id: number; + /** Label */ + label: string; + /** Commodity */ + commodity: string; + /** + * Started At + * Format: date-time + */ + started_at: string; + /** Ended At */ + ended_at: string | null; + /** Reason */ + reason: string; + /** Note */ + note: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + }; /** * MetricInfo * @description Metadata for a single measurable quantity in a device's profile. @@ -3007,6 +3193,98 @@ export interface operations { }; }; }; + list_energy_meters_api_energy_meters_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeterListResponse"]; + }; + }; + }; + }; + declare_energy_meter_api_energy_meters_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MeterDeclareRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeterResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + patch_energy_meter_api_energy_meters__meter_id__patch: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + meter_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MeterPatchRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeterResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_expose_api_expose_get: { parameters: { query?: never; diff --git a/frontend/src/energy/MeterManager.test.tsx b/frontend/src/energy/MeterManager.test.tsx new file mode 100644 index 0000000..d83d6f8 --- /dev/null +++ b/frontend/src/energy/MeterManager.test.tsx @@ -0,0 +1,357 @@ +/** + * Tests for MeterManager component. + * + * Coverage: + * 1. Loading state rendering. + * 2. Error state rendering. + * 3. Empty state when no meters exist. + * 4. Meter timeline list rendering (label, dates, active badge, reason). + * 5. "Declare New Meter" button opens form modal. + * 6. Declare meter — form submit calls POST /api/energy/meters. + * 7. Declare meter — 422 (倒挂) error is displayed. + * 8. Edit button opens edit form modal. + * 9. Edit meter — saves label/note via PATCH /api/energy/meters/{meter_id}. + * 10. Edit meter — retroactive started_at triggers recompute notice. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../test-utils' +import { MeterManager } from './MeterManager' + +// --------------------------------------------------------------------------- +// Mock apiClient +// --------------------------------------------------------------------------- + +const mockGet = vi.fn() +const mockPost = 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: vi.fn(), + }, + 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(), +})) + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const ACTIVE_METER = { + id: 1, + label: 'Initial 2G meter', + commodity: 'electricity', + started_at: '2024-01-15T00:00:00Z', + ended_at: null, + reason: 'initial', + note: null, + created_at: '2024-01-15T00:00:00Z', +} + +const CLOSED_METER = { + id: 2, + label: 'Old 4G meter', + commodity: 'electricity', + started_at: '2023-06-01T00:00:00Z', + ended_at: '2024-01-15T00:00:00Z', + reason: 'meter_swap', + note: 'Replaced by grid company', + created_at: '2023-06-01T00:00:00Z', +} + +const METERS_RESPONSE = { + items: [CLOSED_METER, ACTIVE_METER], + total: 2, +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('MeterManager — loading / error / empty states', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders loading state initially', () => { + mockGet.mockImplementation(() => new Promise(() => {})) + + renderWithProviders() + + expect(screen.getByTestId('meters-loading')).toBeInTheDocument() + }) + + it('renders error state when GET fails', async () => { + mockGet.mockRejectedValue(new Error('Network error')) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('meters-load-error')).toBeInTheDocument() + }) + }) + + it('renders empty state when no meters exist', async () => { + mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('meters-empty')).toBeInTheDocument() + }) + }) +}) + +describe('MeterManager — meter list', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders meter timeline with label, dates, status badge, reason', async () => { + mockGet.mockResolvedValue({ data: METERS_RESPONSE }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('meters-table')).toBeInTheDocument() + }) + + // Labels + expect(screen.getByText('Initial 2G meter')).toBeInTheDocument() + expect(screen.getByText('Old 4G meter')).toBeInTheDocument() + + // Status badges + expect(screen.getByTestId(`meter-status-${ACTIVE_METER.id}`)).toHaveTextContent('active') + expect(screen.getByTestId(`meter-status-${CLOSED_METER.id}`)).toHaveTextContent('closed') + + // Reason badges + expect(screen.getByText('initial')).toBeInTheDocument() + expect(screen.getByText('meter_swap')).toBeInTheDocument() + }) + + it('renders "Declare New Meter" button', async () => { + mockGet.mockResolvedValue({ data: METERS_RESPONSE }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument() + }) + }) +}) + +describe('MeterManager — declare new meter', () => { + beforeEach(() => vi.clearAllMocks()) + + it('opens declare modal when "Declare New Meter" is clicked', async () => { + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument()) + + await user.click(screen.getByTestId('meter-declare-button')) + + await waitFor(() => { + expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument() + }) + expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument() + }) + + it('calls POST /api/energy/meters with correct payload including local-midnight naive datetime', async () => { + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) + mockPost.mockResolvedValue({ data: ACTIVE_METER }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument()) + await user.click(screen.getByTestId('meter-declare-button')) + + await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument()) + + // Fill form + await user.type(screen.getByTestId('meter-label'), 'New meter label') + await user.type(screen.getByTestId('meter-started-at'), '2026-01-01') + + // Select reason via the combobox (Mantine Select renders a combobox) + await user.click(screen.getByTestId('meter-reason')) + await waitFor(() => screen.getByText('Initial installation')) + await user.click(screen.getByText('Initial installation')) + + await user.click(screen.getByTestId('declare-meter-submit')) + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith( + '/api/energy/meters', + expect.objectContaining({ + body: expect.objectContaining({ + label: 'New meter label', + // FU10 local-midnight naive convention: no Z suffix + started_at: '2026-01-01T00:00:00', + reason: 'initial', + commodity: 'electricity', + }), + }), + ) + }) + }) + + it('displays error when POST fails with 422 (倒挂 / validation error)', async () => { + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) + + const { ApiError } = await import('../api/client') + mockPost.mockRejectedValue( + new ApiError(422, { detail: 'started_at must be ≥ current active meter started_at' }), + ) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument()) + await user.click(screen.getByTestId('meter-declare-button')) + await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument()) + + await user.type(screen.getByTestId('meter-label'), 'Bad meter') + await user.type(screen.getByTestId('meter-started-at'), '2020-01-01') + await user.click(screen.getByTestId('meter-reason')) + await waitFor(() => screen.getByText('Meter swap (same address)')) + await user.click(screen.getByText('Meter swap (same address)')) + + await user.click(screen.getByTestId('declare-meter-submit')) + + await waitFor(() => { + expect(screen.getByTestId('declare-meter-error')).toBeInTheDocument() + }) + expect(screen.getByTestId('declare-meter-error').textContent).toContain( + 'started_at must be ≥', + ) + }) + + it('cancel button closes the declare modal', async () => { + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument()) + await user.click(screen.getByTestId('meter-declare-button')) + await waitFor(() => expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument()) + + await user.click(screen.getByTestId('declare-meter-cancel')) + + await waitFor(() => { + expect(screen.queryByTestId('declare-meter-modal')).not.toBeInTheDocument() + }) + }) +}) + +describe('MeterManager — edit meter', () => { + beforeEach(() => vi.clearAllMocks()) + + it('opens edit modal when Edit button is clicked', async () => { + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: METERS_RESPONSE }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument()) + + await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)) + + await waitFor(() => { + expect(screen.getByTestId('edit-meter-modal')).toBeInTheDocument() + }) + expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument() + }) + + it('calls PATCH /api/energy/meters/{meter_id} when label is changed', async () => { + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) + mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'Renamed meter' } }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument()) + await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)) + await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) + + // Clear label and type new one + const labelInput = screen.getByTestId('edit-meter-label') + await user.clear(labelInput) + await user.type(labelInput, 'Renamed meter') + + await user.click(screen.getByTestId('edit-meter-submit')) + + await waitFor(() => { + expect(mockPatch).toHaveBeenCalledWith( + '/api/energy/meters/{meter_id}', + expect.objectContaining({ + params: { path: { meter_id: ACTIVE_METER.id } }, + body: expect.objectContaining({ label: 'Renamed meter' }), + }), + ) + }) + }) + + it('shows recompute notice when started_at is changed (retroactive correction)', async () => { + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) + mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, started_at: '2024-02-01T00:00:00' } }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument()) + await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)) + await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) + + // Change the date field + const dateInput = screen.getByTestId('edit-meter-started-at') + await user.clear(dateInput) + await user.type(dateInput, '2024-02-01') + + await user.click(screen.getByTestId('edit-meter-submit')) + + await waitFor(() => { + expect(screen.getByTestId('meter-recompute-notice')).toBeInTheDocument() + }) + }) + + it('displays error when PATCH fails', async () => { + const user = userEvent.setup() + mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) + + const { ApiError } = await import('../api/client') + mockPatch.mockRejectedValue(new ApiError(422, { detail: 'started_at conflict' })) + + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument()) + await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)) + await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) + + // Change label so there's something to patch + const labelInput = screen.getByTestId('edit-meter-label') + await user.clear(labelInput) + await user.type(labelInput, 'Different label') + + await user.click(screen.getByTestId('edit-meter-submit')) + + await waitFor(() => { + expect(screen.getByTestId('edit-meter-error')).toBeInTheDocument() + }) + expect(screen.getByTestId('edit-meter-error').textContent).toContain('started_at conflict') + }) +}) diff --git a/frontend/src/energy/MeterManager.tsx b/frontend/src/energy/MeterManager.tsx new file mode 100644 index 0000000..4479cd3 --- /dev/null +++ b/frontend/src/energy/MeterManager.tsx @@ -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(null) + const [note, setNote] = useState('') + const [error, setError] = useState(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 ( + +
+ + setLabel(e.currentTarget.value)} + data-testid="meter-label" + /> + + setDateStr(e.currentTarget.value)} + data-testid="meter-started-at" + /> + +