M7-T06: add meter management UI (timeline, declare swap, edit) to Energy view
This commit is contained in:
Vendored
+278
@@ -559,6 +559,84 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/expose": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1569,6 +1647,114 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
sell_normal: number;
|
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
|
* MetricInfo
|
||||||
* @description Metadata for a single measurable quantity in a device's profile.
|
* @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: {
|
get_expose_api_expose_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -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(<MeterManager />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('meters-loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders error state when GET fails', async () => {
|
||||||
|
mockGet.mockRejectedValue(new Error('Network error'))
|
||||||
|
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
|
||||||
|
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(<MeterManager />)
|
||||||
|
|
||||||
|
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(<MeterManager />)
|
||||||
|
|
||||||
|
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(<MeterManager />)
|
||||||
|
|
||||||
|
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(<MeterManager />)
|
||||||
|
|
||||||
|
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(<MeterManager />)
|
||||||
|
|
||||||
|
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(<MeterManager />)
|
||||||
|
|
||||||
|
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(<MeterManager />)
|
||||||
|
|
||||||
|
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(<MeterManager />)
|
||||||
|
|
||||||
|
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(<MeterManager />)
|
||||||
|
|
||||||
|
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(<MeterManager />)
|
||||||
|
|
||||||
|
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(<MeterManager />)
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -214,6 +214,12 @@ export function useMetrics(uuid: string) {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
// Re-exported energy types for consumers
|
// Re-exported energy types for consumers
|
||||||
|
export type MeterResponse = components['schemas']['MeterResponse']
|
||||||
|
export type MeterListResponse = components['schemas']['MeterListResponse']
|
||||||
|
export type MeterDeclareRequest = components['schemas']['MeterDeclareRequest']
|
||||||
|
export type MeterPatchRequest = components['schemas']['MeterPatchRequest']
|
||||||
|
export type MeterReason = components['schemas']['MeterReason']
|
||||||
|
|
||||||
export type ContractResponse = components['schemas']['ContractResponse']
|
export type ContractResponse = components['schemas']['ContractResponse']
|
||||||
export type ContractDetailResponse = components['schemas']['ContractDetailResponse']
|
export type ContractDetailResponse = components['schemas']['ContractDetailResponse']
|
||||||
export type ContractVersionResponse = components['schemas']['ContractVersionResponse']
|
export type ContractVersionResponse = components['schemas']['ContractVersionResponse']
|
||||||
@@ -434,6 +440,66 @@ export function useRecomputeCosts() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Meter hooks — typed TanStack Query wrappers for /api/energy/meters.
|
||||||
|
//
|
||||||
|
// Query-key conventions:
|
||||||
|
// ['energy-meters'] — meter list
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: list all meter epochs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useMeters() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['energy-meters'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/energy/meters')
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: declare a new meter epoch (swap / home move / initial)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useDeclareMeter() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: MeterDeclareRequest) =>
|
||||||
|
apiClient.POST('/api/energy/meters', { body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
|
||||||
|
// Invalidate cost-related queries: a new meter may trigger recompute server-side.
|
||||||
|
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: update (PATCH) a meter epoch (label / note / started_at)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useUpdateMeter() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: number; body: MeterPatchRequest }) =>
|
||||||
|
apiClient.PATCH('/api/energy/meters/{meter_id}', {
|
||||||
|
params: { path: { meter_id: id } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
|
||||||
|
// Retroactive started_at correction triggers recompute server-side.
|
||||||
|
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Query: time-range readings for a device (window + limit — never full-table)
|
// Query: time-range readings for a device (window + limit — never full-table)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -381,6 +381,44 @@ describe('EnergyPage — test-read', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('EnergyPage — meters tab', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupDefaultMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Meters tab in the tab list', async () => {
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('tab-meters')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByTestId('tab-meters').textContent).toContain('Meters')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders meters panel when Meters tab is clicked', async () => {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/modbus/devices') {
|
||||||
|
return Promise.resolve({ data: { items: [DEVICE], total: 1 } })
|
||||||
|
}
|
||||||
|
if (path === '/api/energy/meters') {
|
||||||
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('tab-meters')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('tab-meters'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('panel-meters')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('EnergyPage — auto-refresh switch', () => {
|
describe('EnergyPage — auto-refresh switch', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMe
|
|||||||
import { DeviceForm } from '../energy/DeviceForm'
|
import { DeviceForm } from '../energy/DeviceForm'
|
||||||
import { EnergyCharts } from '../energy/EnergyCharts'
|
import { EnergyCharts } from '../energy/EnergyCharts'
|
||||||
import { ContractManager } from '../energy/ContractManager'
|
import { ContractManager } from '../energy/ContractManager'
|
||||||
|
import { MeterManager } from '../energy/MeterManager'
|
||||||
import { TibberPrices } from '../energy/TibberPrices'
|
import { TibberPrices } from '../energy/TibberPrices'
|
||||||
import { CostView } from '../energy/CostView'
|
import { CostView } from '../energy/CostView'
|
||||||
import { DsmrPanel } from '../energy/DsmrPanel'
|
import { DsmrPanel } from '../energy/DsmrPanel'
|
||||||
@@ -661,6 +662,9 @@ export function EnergyPage() {
|
|||||||
<Tabs.Tab value="devices" data-testid="tab-devices">
|
<Tabs.Tab value="devices" data-testid="tab-devices">
|
||||||
Devices
|
Devices
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="meters" data-testid="tab-meters">
|
||||||
|
Meters
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="contracts" data-testid="tab-contracts">
|
<Tabs.Tab value="contracts" data-testid="tab-contracts">
|
||||||
Contracts
|
Contracts
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -679,6 +683,10 @@ export function EnergyPage() {
|
|||||||
<DevicesTab />
|
<DevicesTab />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="meters" data-testid="panel-meters">
|
||||||
|
<MeterManager />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="contracts" data-testid="panel-contracts">
|
<Tabs.Panel value="contracts" data-testid="panel-contracts">
|
||||||
<ContractManager />
|
<ContractManager />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|||||||
Reference in New Issue
Block a user