M6-T10: frontend contract management + price/cost views + Tibber test

- EnergyPage: Mantine Tabs (Devices kept intact + Contracts/Prices/Costs).
- ContractManager/ContractForm: list/activate/add-version + version history;
  form fields rendered dynamically from /api/energy/profiles structure.
- TibberPrices + CostView: Recharts price curve, cost trend/detail/summary,
  recompute; window-bounded, currency/units from API, empty/error/loading states.
- ConfigPage: tri-state Tibber test button (token never shown).
- hooks.ts: typed energy hooks; schema.d.ts regenerated via codegen.
This commit is contained in:
2026-06-23 23:32:39 +02:00
parent 57f2459f60
commit 96e88861d4
15 changed files with 4235 additions and 32 deletions
+1 -1
View File
@@ -416,7 +416,7 @@ Phase DAPI + 前端)
- **Reviewer checklist**: 查询有时间窗+上限;recompute 复用 T07、无破坏删;test 不泄 token。
### M6-T10 — 前端:合同管理 + 价格/费用视图 + Tibber 测试
- **Status**: `todo` · **Depends**: M6-T04, M6-T09
- **Status**: `done` · **Depends**: M6-T04, M6-T09
- **Files**: `modify frontend/src/pages/EnergyPage.tsx``create frontend/src/energy/ContractManager.tsx`、`ContractForm.tsx`、`TibberPrices.tsx`、`CostView.tsx`、扩 `hooks.ts``modify` Config 页 Tibber 测试入口;`create` 对应 `*.test.tsx`
- **Steps**: 合同列表/新建/编辑/激活/加版本(**表单字段按 `/profiles` 结构渲染**,manual 双费率/税/固定费/抵扣)+ 版本历史只读;价格曲线 + 费用走势/明细 + 汇总卡片;Tibber 测试三态;全走类型化 client;空/加载/错误态 + 缺 key 容错。
- **Out of scope**: 不做服务端降采样;不改后端。
+1164
View File
File diff suppressed because it is too large Load Diff
+249
View File
@@ -0,0 +1,249 @@
/**
* Tests for ContractForm component.
*
* Coverage:
* 1. Renders profile fields dynamically from profile structure (not hardcoded)
* — verified by passing defaultKind so fields render automatically.
* 2. Submit in create mode calls POST /api/energy/contracts.
* 3. Submit in add-version mode calls POST /api/energy/contracts/{id}/versions.
* 4. Cancel closes modal.
*/
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'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: (...args: unknown[]) => mockPost(...args),
PATCH: vi.fn(),
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 PROFILES_RESPONSE = {
profiles: [
{
kind: 'manual',
label: 'Fixed / Variable Rate (NL, dual-tariff)',
energy: {
dual_tariff: true,
buy: { normal: { unit: 'EUR/kWh' }, dal: { unit: 'EUR/kWh' } },
sell: { normal: { unit: 'EUR/kWh' }, dal: { unit: 'EUR/kWh' } },
energy_tax: { unit: 'EUR/kWh' },
ode: { unit: 'EUR/kWh', default: 0 },
},
standing: {
network_fee: { unit: 'EUR/month' },
management_fee: { unit: 'EUR/month' },
},
credits: {
heffingskorting: { unit: 'EUR/year' },
},
},
],
}
const CREATED_CONTRACT = {
id: 1,
name: 'Test Contract',
kind: 'manual',
active: false,
currency: 'EUR',
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
versions: [],
}
// ---------------------------------------------------------------------------
// Import component
// ---------------------------------------------------------------------------
import { ContractForm } from './ContractForm'
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ContractForm', () => {
beforeEach(() => vi.clearAllMocks())
it('renders profile fields dynamically from profile structure (not hardcoded)', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/profiles') {
return Promise.resolve({ data: PROFILES_RESPONSE })
}
return Promise.resolve({ data: null })
})
const onClose = vi.fn()
const onSaved = vi.fn()
// Pass defaultKind so the profile fields render automatically in create mode
renderWithProviders(
<ContractForm defaultKind="manual" onClose={onClose} onSaved={onSaved} />,
)
// Wait for profiles to load and fields to appear
await waitFor(() => {
expect(screen.getByTestId('contract-section-energy')).toBeInTheDocument()
}, { timeout: 3000 })
// Verify sections are rendered from profile structure (not hardcoded)
expect(screen.getByTestId('contract-section-energy')).toBeInTheDocument()
expect(screen.getByTestId('contract-section-standing')).toBeInTheDocument()
expect(screen.getByTestId('contract-section-credits')).toBeInTheDocument()
// Verify specific fields exist from the profile structure
// "energy.buy.normal" → label "Buy Normal"
expect(screen.getByTestId('contract-field-energy.buy.normal')).toBeInTheDocument()
expect(screen.getByTestId('contract-field-energy.buy.dal')).toBeInTheDocument()
expect(screen.getByTestId('contract-field-standing.network_fee')).toBeInTheDocument()
})
it('calls POST /api/energy/contracts in create mode', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/profiles') {
return Promise.resolve({ data: PROFILES_RESPONSE })
}
return Promise.resolve({ data: null })
})
mockPost.mockResolvedValue({ data: CREATED_CONTRACT })
const onClose = vi.fn()
const onSaved = vi.fn()
// Use defaultKind to ensure fields load without needing to interact with Select
renderWithProviders(
<ContractForm defaultKind="manual" onClose={onClose} onSaved={onSaved} />,
)
// Wait for form to render
await waitFor(() => {
expect(screen.getByTestId('contract-form')).toBeInTheDocument()
})
// Wait for profile fields to appear
await waitFor(() => {
expect(screen.getByTestId('contract-name')).toBeInTheDocument()
})
// Fill in contract name
await user.type(screen.getByTestId('contract-name'), 'Test Contract')
// Submit form
await user.click(screen.getByTestId('contract-form-submit'))
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/api/energy/contracts',
expect.objectContaining({
body: expect.objectContaining({
name: 'Test Contract',
kind: 'manual',
currency: 'EUR',
}),
}),
)
}, { timeout: 3000 })
})
it('calls POST /api/energy/contracts/{id}/versions in add-version mode', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/profiles') {
return Promise.resolve({ data: PROFILES_RESPONSE })
}
return Promise.resolve({ data: null })
})
mockPost.mockResolvedValue({ data: CREATED_CONTRACT })
const onClose = vi.fn()
const onSaved = vi.fn()
renderWithProviders(
<ContractForm contractId={1} defaultKind="manual" onClose={onClose} onSaved={onSaved} />,
)
// Wait for form to be ready (add-version mode)
await waitFor(() => {
expect(screen.getByTestId('contract-form')).toBeInTheDocument()
})
// In add-version mode, no name or kind fields shown
expect(screen.queryByTestId('contract-name')).not.toBeInTheDocument()
expect(screen.queryByTestId('contract-kind')).not.toBeInTheDocument()
// Fill in effective_from (required in add-version mode)
const effectiveDateInput = screen.getByTestId('contract-effective-from')
await user.type(effectiveDateInput, '2026-07-01')
// Submit form
await user.click(screen.getByTestId('contract-form-submit'))
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/api/energy/contracts/{contract_id}/versions',
expect.objectContaining({
params: { path: { contract_id: 1 } },
body: expect.objectContaining({
values: expect.any(Object),
}),
}),
)
}, { timeout: 3000 })
})
it('calls onClose when Cancel button is clicked', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/profiles') {
return Promise.resolve({ data: PROFILES_RESPONSE })
}
return Promise.resolve({ data: null })
})
const onClose = vi.fn()
const onSaved = vi.fn()
renderWithProviders(
<ContractForm onClose={onClose} onSaved={onSaved} />,
)
await waitFor(() => {
expect(screen.getByTestId('contract-form-cancel')).toBeInTheDocument()
})
await user.click(screen.getByTestId('contract-form-cancel'))
expect(onClose).toHaveBeenCalled()
})
})
+386
View File
@@ -0,0 +1,386 @@
/**
* ContractForm — create a new energy contract OR add a version to an existing one.
*
* Form fields are dynamically rendered from the profile structure returned by
* GET /api/energy/profiles — never hardcoded. The profile structure provides
* section names (energy, standing, credits), leaf keys, and their units.
*
* Create mode: show Name, Kind (Select from profiles), Currency, optional
* effective_from, then dynamically rendered profile value fields.
* Add-version mode (contractId provided): show only effective_from (required)
* + profile value fields for the contract's kind.
*/
import { useState } from 'react'
import {
Modal,
Stack,
TextInput,
NumberInput,
Select,
Button,
Group,
Alert,
Loader,
Center,
Text,
Divider,
Title,
} from '@mantine/core'
import { useEnergyProfiles, useCreateContract, useAddContractVersion } from './hooks'
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
export interface ContractFormProps {
/** When provided: add-version mode; contract must exist. */
contractId?: number
/** Existing contract kind (for add-version mode or edit). */
defaultKind?: string
onClose: () => void
onSaved: () => void
}
// ---------------------------------------------------------------------------
// Helpers for dynamic profile field rendering
// ---------------------------------------------------------------------------
/**
* A leaf in the profile structure is any object with a `unit` key.
* Returns true if the value is a leaf (renderable field).
*/
function isLeaf(val: unknown): val is { unit: string; default?: unknown } {
return typeof val === 'object' && val !== null && 'unit' in val
}
/**
* Extract all leaf fields from a profile section, returning
* [dotPath, unit, defaultValue] tuples. E.g.:
* "energy.buy.normal" → "EUR/kWh"
* "energy.energy_tax" → "EUR/kWh"
*/
interface LeafField {
/** Dot-separated path within the section, e.g. "buy.normal" */
fieldPath: string
unit: string
defaultValue?: number
}
function extractLeafFields(obj: Record<string, unknown>, prefix = ''): LeafField[] {
const fields: LeafField[] = []
for (const [key, val] of Object.entries(obj)) {
// Skip non-object values (e.g. dual_tariff: true)
if (typeof val !== 'object' || val === null) continue
const path = prefix ? `${prefix}.${key}` : key
if (isLeaf(val)) {
fields.push({
fieldPath: path,
unit: val.unit,
defaultValue: typeof val.default === 'number' ? val.default : undefined,
})
} else {
fields.push(...extractLeafFields(val as Record<string, unknown>, path))
}
}
return fields
}
/**
* Build a nested values object from flat field values.
* E.g. { "buy.normal": 0.133, "buy.dal": 0.127 } → { buy: { normal: 0.133, dal: 0.127 } }
*/
function buildNestedValues(
sectionFields: Record<string, LeafField[]>,
fieldValues: Record<string, number | string>,
): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [section, fields] of Object.entries(sectionFields)) {
const sectionObj: Record<string, unknown> = {}
for (const field of fields) {
const raw = fieldValues[`${section}.${field.fieldPath}`]
const numVal = typeof raw === 'number' ? raw : parseFloat(String(raw))
// Set nested path
const parts = field.fieldPath.split('.')
let current = sectionObj
for (let i = 0; i < parts.length - 1; i++) {
if (!(parts[i] in current)) current[parts[i]] = {}
current = current[parts[i]] as Record<string, unknown>
}
current[parts[parts.length - 1]] = isNaN(numVal) ? 0 : numVal
}
result[section] = sectionObj
}
return result
}
/**
* Label formatter: converts "buy.normal" → "Buy Normal", "energy_tax" → "Energy Tax"
*/
function formatLabel(path: string): string {
return path
.replace(/\./g, ' ')
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ContractForm({ contractId, defaultKind, onClose, onSaved }: ContractFormProps) {
const isAddVersion = contractId != null
// Profiles query
const profilesQuery = useEnergyProfiles()
// Create mode fields
const [name, setName] = useState('')
const [selectedKind, setSelectedKind] = useState<string | null>(defaultKind ?? null)
const [currency, setCurrency] = useState('EUR')
// ISO date string "YYYY-MM-DD" for effective_from; empty = default to now
const [effectiveFromStr, setEffectiveFromStr] = useState('')
// Dynamic profile field values: key = "<section>.<fieldPath>", value = number
const [fieldValues, setFieldValues] = useState<Record<string, number | string>>({})
const [error, setError] = useState<string | null>(null)
const createMutation = useCreateContract()
const addVersionMutation = useAddContractVersion()
const isBusy = createMutation.isPending || addVersionMutation.isPending
// Resolve the effective kind (add-version mode uses defaultKind)
const effectiveKind = isAddVersion ? (defaultKind ?? null) : selectedKind
// Build profile options from API response
const profiles = profilesQuery.data?.profiles ?? []
const profileOptions = profiles.map((p: Record<string, unknown>) => ({
value: p.kind as string,
label: (p.label as string | undefined) ?? (p.kind as string),
}))
// Find the selected profile structure
const selectedProfile = profiles.find((p: Record<string, unknown>) => p.kind === effectiveKind)
// Extract section → leaf fields mapping from the selected profile
const sectionFields: Record<string, LeafField[]> = {}
if (selectedProfile) {
const profileObj = selectedProfile as Record<string, unknown>
for (const [key, val] of Object.entries(profileObj)) {
// Skip metadata fields
if (key === 'kind' || key === 'label') continue
if (typeof val === 'object' && val !== null && !isLeaf(val)) {
sectionFields[key] = extractLeafFields(val as Record<string, unknown>)
}
}
}
function handleFieldChange(key: string, val: number | string) {
setFieldValues((prev) => ({ ...prev, [key]: val }))
}
// Initialize default values when profile changes
function initDefaults(kind: string | null) {
if (!kind) return
const profile = profiles.find((p: Record<string, unknown>) => p.kind === kind)
if (!profile) return
const profileObj = profile as Record<string, unknown>
const defaults: Record<string, number | string> = {}
for (const [sectionKey, sectionVal] of Object.entries(profileObj)) {
if (sectionKey === 'kind' || sectionKey === 'label') continue
if (typeof sectionVal === 'object' && sectionVal !== null && !isLeaf(sectionVal)) {
const leaves = extractLeafFields(sectionVal as Record<string, unknown>)
for (const leaf of leaves) {
defaults[`${sectionKey}.${leaf.fieldPath}`] = leaf.defaultValue ?? 0
}
}
}
setFieldValues(defaults)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (!isAddVersion && !name.trim()) {
setError('Contract name is required.')
return
}
if (!effectiveKind) {
setError('Contract kind is required.')
return
}
const values = buildNestedValues(sectionFields, fieldValues)
try {
// Convert local date string to ISO datetime if provided
const effectiveFromISO = effectiveFromStr
? new Date(effectiveFromStr).toISOString()
: undefined
if (isAddVersion) {
const body = {
effective_from: effectiveFromISO ?? new Date().toISOString(),
values,
}
await addVersionMutation.mutateAsync({ id: contractId, body })
} else {
const body = {
name: name.trim(),
kind: effectiveKind,
currency,
values,
...(effectiveFromISO ? { effective_from: effectiveFromISO } : {}),
}
await createMutation.mutateAsync(body)
}
onSaved()
onClose()
} catch {
setError('Failed to save. Please try again.')
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
const title = isAddVersion ? 'Add Contract Version' : 'New Energy Contract'
return (
<Modal
opened
onClose={onClose}
title={title}
size="md"
data-testid="contract-form-modal"
>
{profilesQuery.isLoading && (
<Center py="md">
<Loader size="sm" data-testid="contract-form-loading" />
</Center>
)}
{profilesQuery.isError && (
<Alert color="red" mb="sm" data-testid="contract-form-profiles-error">
Failed to load pricing profiles. Cannot continue.
</Alert>
)}
{!profilesQuery.isLoading && (
<form onSubmit={handleSubmit} data-testid="contract-form">
<Stack gap="sm">
{/* Create mode fields */}
{!isAddVersion && (
<>
<TextInput
label="Contract name"
required
value={name}
onChange={(e) => setName(e.currentTarget.value)}
data-testid="contract-name"
/>
<Select
label="Pricing kind"
required
data={profileOptions}
value={selectedKind}
onChange={(val) => {
setSelectedKind(val)
initDefaults(val)
}}
data-testid="contract-kind"
/>
<TextInput
label="Currency"
value={currency}
onChange={(e) => setCurrency(e.currentTarget.value)}
data-testid="contract-currency"
/>
</>
)}
{/* Effective from — required for add-version, optional for create */}
<TextInput
label={isAddVersion ? 'Effective from (required)' : 'Effective from (optional, defaults to now)'}
description="Date in YYYY-MM-DD format"
type="date"
required={isAddVersion}
value={effectiveFromStr}
onChange={(e) => setEffectiveFromStr(e.currentTarget.value)}
data-testid="contract-effective-from"
/>
{/* Dynamic profile fields */}
{effectiveKind && Object.keys(sectionFields).length > 0 && (
<>
<Divider mt="xs" />
{Object.entries(sectionFields).map(([section, fields]) => (
<Stack key={section} gap="xs" data-testid={`contract-section-${section}`}>
<Title order={6} tt="capitalize" c="dimmed">
{section.replace(/_/g, ' ')}
</Title>
{fields.map((field) => {
const key = `${section}.${field.fieldPath}`
const raw = fieldValues[key]
const numVal = typeof raw === 'number' ? raw : parseFloat(String(raw))
return (
<NumberInput
key={key}
label={formatLabel(field.fieldPath)}
description={field.unit}
value={isNaN(numVal) ? 0 : numVal}
onChange={(val) => handleFieldChange(key, val)}
decimalScale={6}
step={0.001}
data-testid={`contract-field-${key}`}
/>
)
})}
</Stack>
))}
</>
)}
{!effectiveKind && !isAddVersion && (
<Text size="sm" c="dimmed" data-testid="contract-form-kind-hint">
Select a pricing kind to see the rate fields.
</Text>
)}
{error && (
<Alert color="red" data-testid="contract-form-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
type="button"
variant="default"
onClick={onClose}
data-testid="contract-form-cancel"
>
Cancel
</Button>
<Button
type="submit"
loading={isBusy}
data-testid="contract-form-submit"
>
{isAddVersion ? 'Add Version' : 'Create Contract'}
</Button>
</Group>
</Stack>
</form>
)}
</Modal>
)
}
@@ -0,0 +1,205 @@
/**
* Tests for ContractManager component.
*
* Coverage:
* 1. Loading state rendering.
* 2. Contract list rendering.
* 3. Empty state rendering.
* 4. Activate button calls PATCH with {active: true}.
* 5. "New Contract" button opens ContractForm.
*/
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'
// ---------------------------------------------------------------------------
// 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_CONTRACT = {
id: 1,
name: 'My Active Contract',
kind: 'manual',
active: true,
currency: 'EUR',
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
}
const INACTIVE_CONTRACT = {
id: 2,
name: 'Inactive Contract',
kind: 'tibber',
active: false,
currency: 'EUR',
created_at: '2026-06-02T00:00:00Z',
updated_at: '2026-06-02T00:00:00Z',
}
const PROFILES_RESPONSE = {
profiles: [
{
kind: 'manual',
label: 'Fixed / Variable Rate (NL, dual-tariff)',
energy: {
dual_tariff: true,
buy: { normal: { unit: 'EUR/kWh' }, dal: { unit: 'EUR/kWh' } },
sell: { normal: { unit: 'EUR/kWh' }, dal: { unit: 'EUR/kWh' } },
energy_tax: { unit: 'EUR/kWh' },
ode: { unit: 'EUR/kWh', default: 0 },
},
standing: {
network_fee: { unit: 'EUR/month' },
management_fee: { unit: 'EUR/month' },
},
credits: {
heffingskorting: { unit: 'EUR/year' },
},
},
],
}
// ---------------------------------------------------------------------------
// Import component
// ---------------------------------------------------------------------------
import { ContractManager } from './ContractManager'
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ContractManager', () => {
beforeEach(() => vi.clearAllMocks())
it('renders loading state initially', () => {
// Never resolve so we stay in loading state
mockGet.mockImplementation(() => new Promise(() => {}))
renderWithProviders(<ContractManager />)
expect(screen.getByTestId('contracts-loading')).toBeInTheDocument()
})
it('renders contract list when data loads', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/contracts') {
return Promise.resolve({
data: { items: [ACTIVE_CONTRACT, INACTIVE_CONTRACT], total: 2 },
})
}
return Promise.resolve({ data: null })
})
renderWithProviders(<ContractManager />)
await waitFor(() => {
expect(screen.getByTestId('contracts-table')).toBeInTheDocument()
})
expect(screen.getByText('My Active Contract')).toBeInTheDocument()
expect(screen.getByText('Inactive Contract')).toBeInTheDocument()
expect(screen.getByTestId('contract-active-badge-1')).toHaveTextContent('active')
expect(screen.getByTestId('contract-active-badge-2')).toHaveTextContent('inactive')
})
it('renders empty state when no contracts exist', async () => {
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
renderWithProviders(<ContractManager />)
await waitFor(() => {
expect(screen.getByTestId('contracts-empty')).toBeInTheDocument()
})
})
it('calls PATCH with active=true when Activate button clicked', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/contracts') {
return Promise.resolve({
data: { items: [INACTIVE_CONTRACT], total: 1 },
})
}
return Promise.resolve({ data: null })
})
mockPatch.mockResolvedValue({
data: { ...INACTIVE_CONTRACT, active: true, versions: [] },
})
renderWithProviders(<ContractManager />)
await waitFor(() => {
expect(screen.getByTestId(`contract-activate-${INACTIVE_CONTRACT.id}`)).toBeInTheDocument()
})
await user.click(screen.getByTestId(`contract-activate-${INACTIVE_CONTRACT.id}`))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalledWith(
'/api/energy/contracts/{contract_id}',
expect.objectContaining({
params: { path: { contract_id: INACTIVE_CONTRACT.id } },
body: { active: true },
}),
)
})
})
it('opens ContractForm when "New Contract" button is clicked', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/contracts') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
if (path === '/api/energy/profiles') {
return Promise.resolve({ data: PROFILES_RESPONSE })
}
return Promise.resolve({ data: null })
})
renderWithProviders(<ContractManager />)
await waitFor(() => {
expect(screen.getByTestId('contract-new-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('contract-new-button'))
await waitFor(() => {
expect(screen.getByTestId('contract-form-modal')).toBeInTheDocument()
})
})
})
+330
View File
@@ -0,0 +1,330 @@
/**
* ContractManager — energy contract list UI.
*
* Features:
* - Table of contracts: name, kind, active badge, currency, created date, actions.
* - Actions per row: Activate (PATCH active=true), Add Version, View History.
* - "New Contract" button at top.
* - Version history modal showing all versions with effective dates and values.
* - Loading / error / empty states.
*/
import { useState } from 'react'
import {
Table,
Button,
Group,
Text,
Loader,
Center,
Alert,
Stack,
Badge,
ScrollArea,
Modal,
Accordion,
Code,
} from '@mantine/core'
import {
useContracts,
useUpdateContract,
type ContractResponse,
type ContractDetailResponse,
} from './hooks'
import { ContractForm } from './ContractForm'
import apiClient from '../api/client'
// ---------------------------------------------------------------------------
// Version history modal
// ---------------------------------------------------------------------------
interface VersionHistoryModalProps {
contractId: number
contractName: string
onClose: () => void
}
function VersionHistoryModal({ contractId, contractName, onClose }: VersionHistoryModalProps) {
const [loading, setLoading] = useState(true)
const [detail, setDetail] = useState<ContractDetailResponse | null>(null)
const [fetchError, setFetchError] = useState<string | null>(null)
// Fetch detail on first render
useState(() => {
apiClient
.GET('/api/energy/contracts/{contract_id}', {
params: { path: { contract_id: contractId } },
})
.then((res) => {
setDetail(res.data ?? null)
setLoading(false)
})
.catch(() => {
setFetchError('Failed to load contract details.')
setLoading(false)
})
})
return (
<Modal
opened
onClose={onClose}
title={`Version history — ${contractName}`}
size="lg"
data-testid="version-history-modal"
>
{loading && (
<Center py="md">
<Loader size="sm" />
</Center>
)}
{fetchError && (
<Alert color="red" data-testid="version-history-error">
{fetchError}
</Alert>
)}
{!loading && !fetchError && detail && (
<>
{detail.versions.length === 0 ? (
<Text c="dimmed" ta="center" size="sm" data-testid="version-history-empty">
No versions found.
</Text>
) : (
<Accordion variant="separated" data-testid="version-history-accordion">
{detail.versions.map((v) => (
<Accordion.Item key={v.id} value={String(v.id)} data-testid={`version-item-${v.id}`}>
<Accordion.Control>
<Group gap="sm">
<Text size="sm" fw={500}>
From: {new Date(v.effective_from).toLocaleDateString()}
</Text>
{v.effective_to ? (
<Text size="xs" c="dimmed">
To: {new Date(v.effective_to).toLocaleDateString()}
</Text>
) : (
<Badge color="green" size="xs">
current
</Badge>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(v.values, null, 2)}
</Code>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
)}
</>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Close
</Button>
</Group>
</Modal>
)
}
// ---------------------------------------------------------------------------
// Contract table
// ---------------------------------------------------------------------------
interface ContractTableProps {
contracts: ContractResponse[]
onActivate: (id: number) => void
onAddVersion: (contract: ContractResponse) => void
onViewHistory: (contract: ContractResponse) => void
activatingId: number | null
}
function ContractTable({
contracts,
onActivate,
onAddVersion,
onViewHistory,
activatingId,
}: ContractTableProps) {
if (contracts.length === 0) {
return (
<Text c="dimmed" ta="center" size="sm" data-testid="contracts-empty">
No contracts configured yet. Click "New Contract" to add one.
</Text>
)
}
return (
<ScrollArea>
<Table striped highlightOnHover withTableBorder data-testid="contracts-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Kind</Table.Th>
<Table.Th>Active</Table.Th>
<Table.Th>Currency</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{contracts.map((contract) => (
<Table.Tr key={contract.id} data-testid={`contract-row-${contract.id}`}>
<Table.Td>
<Text fw={500} size="sm">
{contract.name}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm">
{contract.kind}
</Badge>
</Table.Td>
<Table.Td>
<Badge
color={contract.active ? 'green' : 'gray'}
variant="light"
size="sm"
data-testid={`contract-active-badge-${contract.id}`}
>
{contract.active ? 'active' : 'inactive'}
</Badge>
</Table.Td>
<Table.Td>{contract.currency}</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{new Date(contract.created_at).toLocaleDateString()}
</Text>
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
{!contract.active && (
<Button
size="xs"
variant="outline"
color="green"
loading={activatingId === contract.id}
onClick={() => onActivate(contract.id)}
data-testid={`contract-activate-${contract.id}`}
>
Activate
</Button>
)}
<Button
size="xs"
variant="outline"
onClick={() => onAddVersion(contract)}
data-testid={`contract-add-version-${contract.id}`}
>
Add Version
</Button>
<Button
size="xs"
variant="subtle"
onClick={() => onViewHistory(contract)}
data-testid={`contract-history-${contract.id}`}
>
History
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
)
}
// ---------------------------------------------------------------------------
// ContractManager — top-level
// ---------------------------------------------------------------------------
export function ContractManager() {
const contractsQuery = useContracts()
const updateMutation = useUpdateContract()
const [showCreateForm, setShowCreateForm] = useState(false)
const [addVersionContract, setAddVersionContract] = useState<ContractResponse | null>(null)
const [historyContract, setHistoryContract] = useState<ContractResponse | null>(null)
const [activatingId, setActivatingId] = useState<number | null>(null)
async function handleActivate(id: number) {
setActivatingId(id)
try {
await updateMutation.mutateAsync({ id, body: { active: true } })
} finally {
setActivatingId(null)
}
}
// ---------------------------------------------------------------------------
// Render states
// ---------------------------------------------------------------------------
if (contractsQuery.isLoading) {
return (
<Center py="xl" data-testid="contracts-loading">
<Loader />
</Center>
)
}
if (contractsQuery.isError || !contractsQuery.data) {
return (
<Alert color="red" data-testid="contracts-load-error">
Failed to load contracts. Please refresh.
</Alert>
)
}
const contracts = contractsQuery.data.items
return (
<Stack gap="lg" data-testid="contract-manager">
<Group justify="space-between" align="center">
<Text fw={500}>Energy Contracts</Text>
<Button onClick={() => setShowCreateForm(true)} data-testid="contract-new-button">
New Contract
</Button>
</Group>
<ContractTable
contracts={contracts}
onActivate={handleActivate}
onAddVersion={(c) => setAddVersionContract(c)}
onViewHistory={(c) => setHistoryContract(c)}
activatingId={activatingId}
/>
{/* Create new contract */}
{showCreateForm && (
<ContractForm
onClose={() => setShowCreateForm(false)}
onSaved={() => setShowCreateForm(false)}
/>
)}
{/* Add version to existing contract */}
{addVersionContract && (
<ContractForm
contractId={addVersionContract.id}
defaultKind={addVersionContract.kind}
onClose={() => setAddVersionContract(null)}
onSaved={() => setAddVersionContract(null)}
/>
)}
{/* Version history modal */}
{historyContract && (
<VersionHistoryModal
contractId={historyContract.id}
contractName={historyContract.name}
onClose={() => setHistoryContract(null)}
/>
)}
</Stack>
)
}
+214
View File
@@ -0,0 +1,214 @@
/**
* Tests for CostView component.
*
* Coverage:
* 1. Loading state.
* 2. Empty state.
* 3. Renders costs table with data.
* 4. Shows summary values.
* 5. Recompute button triggers confirmation modal and then recompute call.
*/
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'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: (...args: unknown[]) => mockPost(...args),
PATCH: vi.fn(),
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 COST_PERIOD = {
period_start: '2026-06-22T10:00:00Z',
d1_kwh: 0.1,
d2_kwh: 0.2,
r1_kwh: 0.0,
r2_kwh: 0.05,
import_cost: 0.044,
export_revenue: 0.005,
net_cost: 0.039,
currency: 'EUR',
degraded: false,
contract_version_id: 1,
}
const SUMMARY = {
currency: 'EUR',
metered_import: 10.5,
metered_export: 2.3,
metered_net: 8.2,
fixed_costs: 5.0,
credits: 50.0,
total_payable: 12.5,
}
// ---------------------------------------------------------------------------
// Import component
// ---------------------------------------------------------------------------
import { CostView } from './CostView'
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('CostView', () => {
beforeEach(() => vi.clearAllMocks())
it('renders loading state initially', () => {
mockGet.mockImplementation(() => new Promise(() => {}))
renderWithProviders(<CostView />)
expect(screen.getByTestId('costs-loading')).toBeInTheDocument()
})
it('renders empty state when no cost periods available', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
renderWithProviders(<CostView />)
await waitFor(() => {
expect(screen.getByTestId('costs-empty')).toBeInTheDocument()
})
})
it('renders costs table when data is available', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [COST_PERIOD], total: 1 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
renderWithProviders(<CostView />)
await waitFor(() => {
expect(screen.getByTestId('costs-table')).toBeInTheDocument()
})
expect(screen.getByTestId('cost-row-0')).toBeInTheDocument()
})
it('shows summary values correctly', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
renderWithProviders(<CostView />)
await waitFor(() => {
expect(screen.getByTestId('summary-import')).toBeInTheDocument()
})
expect(screen.getByTestId('summary-import')).toHaveTextContent('10.500')
expect(screen.getByTestId('summary-export')).toHaveTextContent('2.300')
expect(screen.getByTestId('summary-total')).toHaveTextContent('12.50')
})
it('shows recompute confirmation modal on button click', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
renderWithProviders(<CostView />)
await waitFor(() => {
expect(screen.getByTestId('cost-recompute-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('cost-recompute-button'))
await waitFor(() => {
expect(screen.getByTestId('recompute-confirm-modal')).toBeInTheDocument()
})
})
it('calls recompute mutation when confirmed', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
mockPost.mockResolvedValue({ data: { recomputed: 0 } })
renderWithProviders(<CostView />)
await waitFor(() => {
expect(screen.getByTestId('cost-recompute-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('cost-recompute-button'))
await waitFor(() => {
expect(screen.getByTestId('recompute-confirm')).toBeInTheDocument()
})
await user.click(screen.getByTestId('recompute-confirm'))
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/api/energy/costs/recompute',
expect.any(Object),
)
})
})
})
+407
View File
@@ -0,0 +1,407 @@
/**
* CostView — cost trends + detail + summary.
*
* Features:
* - Date range selector: Today / This month / Custom (SegmentedControl + DateInput).
* - Recharts AreaChart showing import_cost, export_revenue, net_cost per period.
* - Table showing per-15min periods with time, kWh values, costs, degraded badge.
* - Summary cards: metered import/export, fixed costs, credits, total payable.
* - "Recompute" button with confirmation.
* - Loading/error/empty states, degraded period highlighting.
*
* Recharts imports are isolated to this file only.
*/
import { useState } from 'react'
import {
Stack,
Text,
TextInput,
Loader,
Center,
Alert,
Table,
Group,
Badge,
ScrollArea,
SegmentedControl,
Button,
Paper,
SimpleGrid,
Modal,
Title,
} from '@mantine/core'
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { useEnergyCosts, useEnergyCostSummary, useRecomputeCosts } from './hooks'
// ---------------------------------------------------------------------------
// Cost limit — prevent accidental full-table pulls
// ---------------------------------------------------------------------------
const COSTS_MAX_LIMIT = 500
// ---------------------------------------------------------------------------
// Date range helpers
// ---------------------------------------------------------------------------
function getTodayRange(): { start: string; end: string } {
const start = new Date()
start.setUTCHours(0, 0, 0, 0)
const end = new Date(start)
end.setUTCDate(end.getUTCDate() + 1)
return { start: start.toISOString(), end: end.toISOString() }
}
function getThisMonthRange(): { start: string; end: string } {
const now = new Date()
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1))
return { start: start.toISOString(), end: end.toISOString() }
}
// ---------------------------------------------------------------------------
// Summary cards
// ---------------------------------------------------------------------------
interface SummaryCardProps {
label: string
value: string
testId?: string
}
function SummaryCard({ label, value, testId }: SummaryCardProps) {
return (
<Paper withBorder p="sm" data-testid={testId}>
<Stack gap={4}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text fw={600} size="lg">
{value}
</Text>
</Stack>
</Paper>
)
}
// ---------------------------------------------------------------------------
// CostView — main component
// ---------------------------------------------------------------------------
type RangePreset = 'today' | 'month' | 'custom'
export function CostView() {
const [rangePreset, setRangePreset] = useState<RangePreset>('today')
// Date strings in YYYY-MM-DD format for custom range
const [customStartStr, setCustomStartStr] = useState('')
const [customEndStr, setCustomEndStr] = useState('')
const [showRecomputeConfirm, setShowRecomputeConfirm] = useState(false)
// Compute effective date range
const { start, end } = (() => {
if (rangePreset === 'today') return getTodayRange()
if (rangePreset === 'month') return getThisMonthRange()
return {
start: customStartStr ? new Date(customStartStr).toISOString() : undefined,
end: customEndStr ? new Date(customEndStr).toISOString() : undefined,
}
})()
const costsQuery = useEnergyCosts(start, end, COSTS_MAX_LIMIT)
const summaryQuery = useEnergyCostSummary(start, end)
const recomputeMutation = useRecomputeCosts()
async function handleRecompute() {
setShowRecomputeConfirm(false)
await recomputeMutation.mutateAsync({ start, end })
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
const currency = costsQuery.data?.items[0]?.currency ?? summaryQuery.data?.currency ?? 'EUR'
return (
<Stack gap="lg" data-testid="cost-view">
{/* Date range selector */}
<Group align="flex-start" gap="md" wrap="wrap">
<Stack gap="xs">
<Text size="sm" fw={500}>
Date range
</Text>
<SegmentedControl
value={rangePreset}
onChange={(v) => setRangePreset(v as RangePreset)}
data={[
{ label: 'Today', value: 'today' },
{ label: 'This month', value: 'month' },
{ label: 'Custom', value: 'custom' },
]}
data-testid="cost-range-control"
/>
</Stack>
{rangePreset === 'custom' && (
<Group gap="sm" align="flex-end">
<TextInput
label="From"
type="date"
value={customStartStr}
onChange={(e) => setCustomStartStr(e.currentTarget.value)}
data-testid="cost-custom-start"
/>
<TextInput
label="To"
type="date"
value={customEndStr}
onChange={(e) => setCustomEndStr(e.currentTarget.value)}
data-testid="cost-custom-end"
/>
</Group>
)}
<Group gap="sm" style={{ marginLeft: 'auto' }} align="flex-end">
<Button
variant="outline"
color="orange"
size="sm"
onClick={() => setShowRecomputeConfirm(true)}
loading={recomputeMutation.isPending}
data-testid="cost-recompute-button"
>
Recompute
</Button>
</Group>
</Group>
{/* Summary cards */}
{summaryQuery.isLoading && (
<Center>
<Loader size="sm" />
</Center>
)}
{summaryQuery.isError && (
<Alert color="red" data-testid="summary-error">
Failed to load cost summary.
</Alert>
)}
{summaryQuery.data && (
<Stack gap="xs">
<Title order={6} c="dimmed">
Summary
</Title>
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm" data-testid="cost-summary">
<SummaryCard
label="Import (kWh)"
value={summaryQuery.data.metered_import.toFixed(3)}
testId="summary-import"
/>
<SummaryCard
label="Export (kWh)"
value={summaryQuery.data.metered_export.toFixed(3)}
testId="summary-export"
/>
<SummaryCard
label={`Fixed costs (${currency})`}
value={summaryQuery.data.fixed_costs.toFixed(2)}
testId="summary-fixed"
/>
<SummaryCard
label={`Credits (${currency})`}
value={summaryQuery.data.credits.toFixed(2)}
testId="summary-credits"
/>
<SummaryCard
label={`Total payable (${currency})`}
value={summaryQuery.data.total_payable.toFixed(2)}
testId="summary-total"
/>
</SimpleGrid>
</Stack>
)}
{/* Cost chart */}
{costsQuery.isLoading && (
<Center py="xl" data-testid="costs-loading">
<Loader />
</Center>
)}
{costsQuery.isError && (
<Alert color="red" data-testid="costs-error">
Failed to load cost periods. Please refresh.
</Alert>
)}
{costsQuery.data && costsQuery.data.items.length === 0 && (
<Alert color="gray" data-testid="costs-empty">
No cost data available for the selected period.
</Alert>
)}
{costsQuery.data && costsQuery.data.items.length > 0 && (
<>
{/* Area chart */}
<Stack gap="xs" data-testid="cost-chart">
<Title order={6} c="dimmed">
Cost trends ({currency})
</Title>
<ResponsiveContainer width="100%" height={220}>
<AreaChart
data={costsQuery.data.items.map((item) => ({
time: new Date(item.period_start).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
import_cost: item.import_cost,
export_revenue: item.export_revenue,
net_cost: item.net_cost,
}))}
margin={{ top: 4, right: 16, left: 0, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v: number) => v.toFixed(2)} />
<Tooltip
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(val: any) =>
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
}
/>
<Legend />
<Area
type="monotone"
dataKey="import_cost"
stroke="#2196f3"
fill="#bbdefb"
name="Import cost"
dot={false}
/>
<Area
type="monotone"
dataKey="export_revenue"
stroke="#4caf50"
fill="#c8e6c9"
name="Export revenue"
dot={false}
/>
<Area
type="monotone"
dataKey="net_cost"
stroke="#ff9800"
fill="#ffe0b2"
name="Net cost"
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</Stack>
{/* Detail table */}
<Stack gap="xs">
<Title order={6} c="dimmed">
Period detail
</Title>
<ScrollArea>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
style={{ fontSize: 12 }}
data-testid="costs-table"
>
<Table.Thead>
<Table.Tr>
<Table.Th>Time</Table.Th>
<Table.Th>Import kWh</Table.Th>
<Table.Th>Export kWh</Table.Th>
<Table.Th>Import cost</Table.Th>
<Table.Th>Export rev.</Table.Th>
<Table.Th>Net cost</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{costsQuery.data.items.map((item, idx) => (
<Table.Tr
key={idx}
style={item.degraded ? { opacity: 0.6 } : undefined}
data-testid={`cost-row-${idx}`}
>
<Table.Td>
<Text size="xs">
{new Date(item.period_start).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</Table.Td>
<Table.Td>{(item.d1_kwh + item.d2_kwh).toFixed(3)}</Table.Td>
<Table.Td>{(item.r1_kwh + item.r2_kwh).toFixed(3)}</Table.Td>
<Table.Td>{item.import_cost.toFixed(4)}</Table.Td>
<Table.Td>{item.export_revenue.toFixed(4)}</Table.Td>
<Table.Td>{item.net_cost.toFixed(4)}</Table.Td>
<Table.Td>
{item.degraded && (
<Badge color="orange" size="xs" data-testid={`cost-degraded-${idx}`}>
degraded
</Badge>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Stack>
</>
)}
{/* Recompute confirmation modal */}
{showRecomputeConfirm && (
<Modal
opened
onClose={() => setShowRecomputeConfirm(false)}
title="Recompute costs?"
size="sm"
data-testid="recompute-confirm-modal"
>
<Stack gap="md">
<Text size="sm">
This will recompute all cost periods for the selected date range. Continue?
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setShowRecomputeConfirm(false)}
data-testid="recompute-cancel"
>
Cancel
</Button>
<Button
color="orange"
onClick={handleRecompute}
data-testid="recompute-confirm"
>
Recompute
</Button>
</Group>
</Stack>
</Modal>
)}
</Stack>
)
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Tests for TibberPrices component.
*
* Coverage:
* 1. Loading state.
* 2. Empty state (no active contract / no kind).
* 3. Renders tibber chart when tibber kind data is available.
* 4. Shows tariff table for manual kind.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: vi.fn(),
PATCH: vi.fn(),
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(),
}))
// ---------------------------------------------------------------------------
// Import component
// ---------------------------------------------------------------------------
import { TibberPrices } from './TibberPrices'
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('TibberPrices', () => {
beforeEach(() => vi.clearAllMocks())
it('renders loading state initially', () => {
mockGet.mockImplementation(() => new Promise(() => {}))
renderWithProviders(<TibberPrices />)
expect(screen.getByTestId('prices-loading')).toBeInTheDocument()
})
it('renders "no active contract" when kind is missing/null', async () => {
mockGet.mockResolvedValue({
data: {
kind: null,
currency: 'EUR',
points: [],
tariff: null,
},
})
renderWithProviders(<TibberPrices />)
await waitFor(() => {
expect(screen.getByTestId('prices-no-contract')).toBeInTheDocument()
})
})
it('renders error state when fetch fails', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
renderWithProviders(<TibberPrices />)
await waitFor(() => {
expect(screen.getByTestId('prices-error')).toBeInTheDocument()
})
})
it('renders tibber chart when tibber data is available', async () => {
mockGet.mockResolvedValue({
data: {
kind: 'tibber',
currency: 'EUR',
points: [
{ starts_at: '2026-06-22T10:00:00Z', buy: 0.133, sell: 0.09, level: 'NORMAL' },
{ starts_at: '2026-06-22T10:15:00Z', buy: 0.140, sell: 0.092, level: 'NORMAL' },
],
tariff: null,
},
})
renderWithProviders(<TibberPrices />)
await waitFor(() => {
expect(screen.getByTestId('tibber-chart')).toBeInTheDocument()
})
// Check badge shows kind
expect(screen.getByText('tibber')).toBeInTheDocument()
})
it('shows tariff table for manual kind', async () => {
mockGet.mockResolvedValue({
data: {
kind: 'manual',
currency: 'EUR',
points: [],
tariff: {
buy_dal: 0.127,
buy_normal: 0.133,
sell_dal: 0.09,
sell_normal: 0.09,
},
},
})
renderWithProviders(<TibberPrices />)
await waitFor(() => {
expect(screen.getByTestId('manual-tariff-table')).toBeInTheDocument()
})
expect(screen.getByTestId('tariff-buy-normal')).toHaveTextContent('0.1330')
expect(screen.getByTestId('tariff-buy-dal')).toHaveTextContent('0.1270')
expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900')
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
})
})
+243
View File
@@ -0,0 +1,243 @@
/**
* TibberPrices — price curve visualization.
*
* - Fetches today + tomorrow price range using useEnergyPrices.
* - For tibber kind: Recharts LineChart showing buy/sell prices over time.
* - For manual kind: shows tariff table (buy_dal, buy_normal, sell_dal, sell_normal).
* - Handles: no active contract, empty data, loading, error.
*
* Recharts imports are isolated to this file only.
*/
import {
Stack,
Text,
Loader,
Center,
Alert,
Table,
Title,
Badge,
Group,
Paper,
} from '@mantine/core'
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { useEnergyPrices } from './hooks'
// ---------------------------------------------------------------------------
// Time range helpers
// ---------------------------------------------------------------------------
function getTodayStart(): string {
const d = new Date()
d.setUTCHours(0, 0, 0, 0)
return d.toISOString()
}
function getTomorrowEnd(): string {
const d = new Date()
d.setUTCHours(0, 0, 0, 0)
d.setUTCDate(d.getUTCDate() + 2)
return d.toISOString()
}
// ---------------------------------------------------------------------------
// Tibber chart
// ---------------------------------------------------------------------------
interface TibberChartProps {
points: Array<{ starts_at: string; buy: number; sell: number; level?: string | null }>
currency: string
}
function TibberChart({ points, currency }: TibberChartProps) {
const data = points.map((p) => ({
time: new Date(p.starts_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
buy: p.buy,
sell: p.sell,
}))
return (
<Stack gap="xs" data-testid="tibber-chart">
<Title order={6} c="dimmed">
Price curve ({currency})
</Title>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="time"
tick={{ fontSize: 10 }}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10 }}
tickFormatter={(v: number) => v.toFixed(3)}
/>
<Tooltip
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(val: any) =>
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
}
/>
<Legend />
<Line
type="monotone"
dataKey="buy"
stroke="#2196f3"
dot={false}
strokeWidth={2}
name="Buy"
/>
<Line
type="monotone"
dataKey="sell"
stroke="#4caf50"
dot={false}
strokeWidth={2}
name="Sell"
/>
</LineChart>
</ResponsiveContainer>
</Stack>
)
}
// ---------------------------------------------------------------------------
// Manual tariff table
// ---------------------------------------------------------------------------
interface ManualTariffTableProps {
tariff: {
buy_dal: number
buy_normal: number
sell_dal: number
sell_normal: number
}
currency: string
}
function ManualTariffTable({ tariff, currency }: ManualTariffTableProps) {
return (
<Stack gap="xs" data-testid="manual-tariff-table">
<Title order={6} c="dimmed">
Fixed tariff ({currency}/kWh)
</Title>
<Table withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Tariff</Table.Th>
<Table.Th>Buy</Table.Th>
<Table.Th>Sell</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Table.Tr>
<Table.Td>Normal (peak)</Table.Td>
<Table.Td data-testid="tariff-buy-normal">{tariff.buy_normal.toFixed(4)}</Table.Td>
<Table.Td data-testid="tariff-sell-normal">{tariff.sell_normal.toFixed(4)}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>Dal (off-peak)</Table.Td>
<Table.Td data-testid="tariff-buy-dal">{tariff.buy_dal.toFixed(4)}</Table.Td>
<Table.Td data-testid="tariff-sell-dal">{tariff.sell_dal.toFixed(4)}</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Stack>
)
}
// ---------------------------------------------------------------------------
// TibberPrices — main component
// ---------------------------------------------------------------------------
export function TibberPrices() {
const start = getTodayStart()
const end = getTomorrowEnd()
const { data, isLoading, isError } = useEnergyPrices(start, end)
if (isLoading) {
return (
<Center py="xl" data-testid="prices-loading">
<Loader />
</Center>
)
}
if (isError) {
return (
<Alert color="red" data-testid="prices-error">
Failed to load energy prices. Please refresh.
</Alert>
)
}
if (!data) {
return (
<Alert color="gray" data-testid="prices-no-data">
No pricing data available.
</Alert>
)
}
// No active contract
if (!data.kind) {
return (
<Paper withBorder p="md" data-testid="prices-no-contract">
<Stack gap="xs">
<Text fw={500}>No active contract</Text>
<Text size="sm" c="dimmed">
Activate an energy contract on the Contracts tab to see pricing data.
</Text>
</Stack>
</Paper>
)
}
const currency = data.currency
return (
<Stack gap="lg" data-testid="tibber-prices">
<Group gap="sm" align="center">
<Text fw={500}>Energy Prices</Text>
<Badge variant="outline" size="sm">
{data.kind}
</Badge>
<Text size="xs" c="dimmed">
{currency}
</Text>
</Group>
{data.kind === 'tibber' && data.points && data.points.length > 0 && (
<TibberChart points={data.points} currency={currency} />
)}
{data.kind === 'tibber' && (!data.points || data.points.length === 0) && (
<Alert color="yellow" data-testid="tibber-no-prices">
No Tibber price points available for the selected time range. Check Tibber configuration.
</Alert>
)}
{data.kind === 'manual' && data.tariff && (
<ManualTariffTable tariff={data.tariff} currency={currency} />
)}
{data.kind === 'manual' && !data.tariff && (
<Alert color="yellow" data-testid="manual-no-tariff">
Manual tariff data not available.
</Alert>
)}
</Stack>
)
}
+293
View File
@@ -0,0 +1,293 @@
/**
* Tests for energy hooks in hooks.ts — energy pricing API wrappers.
*
* Coverage:
* 1. useContracts — GET /api/energy/contracts
* 2. useCreateContract — POST /api/energy/contracts
* 3. useEnergyPrices — GET /api/energy/prices
* 4. useEnergyCosts — GET /api/energy/costs
* 5. useEnergyCostSummary — GET /api/energy/costs/summary
* 6. useRecomputeCosts — POST /api/energy/costs/recompute
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'
// ---------------------------------------------------------------------------
// 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 CONTRACT = {
id: 1,
name: 'Test Contract',
kind: 'manual',
active: true,
currency: 'EUR',
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
}
const PRICE_POINT = {
starts_at: '2026-06-22T10:00:00Z',
buy: 0.133,
sell: 0.09,
level: 'NORMAL',
}
const COST_PERIOD = {
period_start: '2026-06-22T10:00:00Z',
d1_kwh: 0.1,
d2_kwh: 0.2,
r1_kwh: 0.0,
r2_kwh: 0.05,
import_cost: 0.044,
export_revenue: 0.005,
net_cost: 0.039,
currency: 'EUR',
degraded: false,
contract_version_id: 1,
}
const SUMMARY = {
currency: 'EUR',
metered_import: 10.5,
metered_export: 2.3,
metered_net: 8.2,
fixed_costs: 5.0,
credits: 50.0,
total_payable: 12.5,
}
// ---------------------------------------------------------------------------
// Wrapper
// ---------------------------------------------------------------------------
function makeWrapper() {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
return { qc, Wrapper }
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('useContracts', () => {
beforeEach(() => vi.clearAllMocks())
it('calls GET /api/energy/contracts and returns contract list', async () => {
mockGet.mockResolvedValue({ data: { items: [CONTRACT], total: 1 } })
const { Wrapper } = makeWrapper()
const { useContracts } = await import('./hooks')
const { result } = renderHook(() => useContracts(), { wrapper: Wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockGet).toHaveBeenCalledWith('/api/energy/contracts')
expect(result.current.data?.items).toHaveLength(1)
expect(result.current.data?.items[0].id).toBe(1)
})
})
describe('useCreateContract', () => {
beforeEach(() => vi.clearAllMocks())
it('calls POST /api/energy/contracts with the provided body', async () => {
mockPost.mockResolvedValue({ data: { ...CONTRACT, versions: [] } })
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
const { Wrapper } = makeWrapper()
const { useCreateContract } = await import('./hooks')
const { result } = renderHook(() => useCreateContract(), { wrapper: Wrapper })
const body = {
name: 'New Contract',
kind: 'manual',
currency: 'EUR',
values: { energy: { buy: { normal: 0.133 } } },
}
await act(async () => {
await result.current.mutateAsync(body)
})
expect(mockPost).toHaveBeenCalledWith('/api/energy/contracts', { body })
})
})
describe('useEnergyPrices', () => {
beforeEach(() => vi.clearAllMocks())
it('calls GET /api/energy/prices with start and end params', async () => {
mockGet.mockResolvedValue({
data: {
kind: 'tibber',
currency: 'EUR',
points: [PRICE_POINT],
tariff: null,
},
})
const { Wrapper } = makeWrapper()
const { useEnergyPrices } = await import('./hooks')
const start = '2026-06-22T00:00:00Z'
const end = '2026-06-23T00:00:00Z'
const { result } = renderHook(() => useEnergyPrices(start, end), { wrapper: Wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', {
params: { query: { start, end } },
})
expect(result.current.data?.points).toHaveLength(1)
expect(result.current.data?.kind).toBe('tibber')
})
it('calls GET /api/energy/prices without params when none provided', async () => {
mockGet.mockResolvedValue({
data: { kind: 'manual', currency: 'EUR', points: [], tariff: null },
})
const { Wrapper } = makeWrapper()
const { useEnergyPrices } = await import('./hooks')
const { result } = renderHook(() => useEnergyPrices(), { wrapper: Wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', {
params: { query: {} },
})
})
})
describe('useEnergyCosts', () => {
beforeEach(() => vi.clearAllMocks())
it('calls GET /api/energy/costs with start, end, and limit', async () => {
mockGet.mockResolvedValue({
data: { items: [COST_PERIOD], total: 1 },
})
const { Wrapper } = makeWrapper()
const { useEnergyCosts } = await import('./hooks')
const start = '2026-06-22T00:00:00Z'
const end = '2026-06-23T00:00:00Z'
const limit = 100
const { result } = renderHook(
() => useEnergyCosts(start, end, limit),
{ wrapper: Wrapper },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockGet).toHaveBeenCalledWith('/api/energy/costs', {
params: { query: { start, end, limit } },
})
expect(result.current.data?.items).toHaveLength(1)
})
})
describe('useEnergyCostSummary', () => {
beforeEach(() => vi.clearAllMocks())
it('calls GET /api/energy/costs/summary with date range', async () => {
mockGet.mockResolvedValue({ data: SUMMARY })
const { Wrapper } = makeWrapper()
const { useEnergyCostSummary } = await import('./hooks')
const start = '2026-06-01T00:00:00Z'
const end = '2026-06-30T23:59:59Z'
const { result } = renderHook(
() => useEnergyCostSummary(start, end),
{ wrapper: Wrapper },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockGet).toHaveBeenCalledWith('/api/energy/costs/summary', {
params: { query: { start, end } },
})
expect(result.current.data?.total_payable).toBe(12.5)
})
})
describe('useRecomputeCosts', () => {
beforeEach(() => vi.clearAllMocks())
it('calls POST /api/energy/costs/recompute and invalidates cost queries', async () => {
mockPost.mockResolvedValue({ data: { recomputed: 10 } })
// Mock GET for invalidation queries (costs and summary)
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
const { Wrapper } = makeWrapper()
const { useRecomputeCosts } = await import('./hooks')
const { result } = renderHook(() => useRecomputeCosts(), { wrapper: Wrapper })
await act(async () => {
await result.current.mutateAsync({
start: '2026-06-22T00:00:00Z',
end: '2026-06-23T00:00:00Z',
})
})
expect(mockPost).toHaveBeenCalledWith('/api/energy/costs/recompute', {
params: {
query: {
start: '2026-06-22T00:00:00Z',
end: '2026-06-23T00:00:00Z',
},
},
})
})
it('calls POST /api/energy/costs/recompute without params when none provided', async () => {
mockPost.mockResolvedValue({ data: { recomputed: 0 } })
const { Wrapper } = makeWrapper()
const { useRecomputeCosts } = await import('./hooks')
const { result } = renderHook(() => useRecomputeCosts(), { wrapper: Wrapper })
await act(async () => {
await result.current.mutateAsync({})
})
expect(mockPost).toHaveBeenCalledWith('/api/energy/costs/recompute', {
params: { query: {} },
})
})
})
+229
View File
@@ -191,6 +191,235 @@ export function useMetrics(uuid: string) {
})
}
// ===========================================================================
// Energy / Pricing hooks — typed TanStack Query wrappers for /api/energy/*.
//
// Query-key conventions:
// ['energy-contracts'] — contract list
// ['energy-contract', id] — single contract with versions
// ['energy-profiles'] — pricing profile structures
// ['energy-prices', start, end] — price curve
// ['energy-costs', start, end, limit] — cost periods
// ['energy-costs-summary', start, end] — summary
// ['dsmr-latest'] — DSMR latest
// ===========================================================================
// Re-exported energy types for consumers
export type ContractResponse = components['schemas']['ContractResponse']
export type ContractDetailResponse = components['schemas']['ContractDetailResponse']
export type ContractVersionResponse = components['schemas']['ContractVersionResponse']
export type ContractListResponse = components['schemas']['ContractListResponse']
export type ContractCreate = components['schemas']['ContractCreate']
export type ContractPatch = components['schemas']['ContractPatch']
export type VersionCreate = components['schemas']['VersionCreate']
export type ProfilesResponse = components['schemas']['ProfilesResponse']
export type PricesResponse = components['schemas']['PricesResponse']
export type PricePointSchema = components['schemas']['PricePointSchema']
export type ManualTariffSchema = components['schemas']['ManualTariffSchema']
export type CostsResponse = components['schemas']['CostsResponse']
export type CostPeriodSchema = components['schemas']['CostPeriodSchema']
export type SummaryResponse = components['schemas']['SummaryResponse']
export type DsmrLatestResponse = components['schemas']['DsmrLatestResponse']
export type TibberTestResponse = components['schemas']['TibberTestResponse']
export type TibberTestPriceSchema = components['schemas']['TibberTestPriceSchema']
// ---------------------------------------------------------------------------
// Query: list all energy contracts
// ---------------------------------------------------------------------------
export function useContracts() {
return useQuery({
queryKey: ['energy-contracts'],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/contracts')
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Query: single contract with full version history
// ---------------------------------------------------------------------------
export function useContractDetail(id: number) {
return useQuery({
queryKey: ['energy-contract', id],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/contracts/{contract_id}', {
params: { path: { contract_id: id } },
})
return res.data
},
enabled: !!id,
})
}
// ---------------------------------------------------------------------------
// Query: energy pricing profile structures
// ---------------------------------------------------------------------------
export function useEnergyProfiles() {
return useQuery({
queryKey: ['energy-profiles'],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/profiles')
return res.data
},
staleTime: 5 * 60 * 1000,
})
}
// ---------------------------------------------------------------------------
// Mutation: create energy contract
// ---------------------------------------------------------------------------
export function useCreateContract() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: ContractCreate) =>
apiClient.POST('/api/energy/contracts', { body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['energy-contracts'] }),
})
}
// ---------------------------------------------------------------------------
// Mutation: update (PATCH) energy contract
// ---------------------------------------------------------------------------
export function useUpdateContract() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: number; body: ContractPatch }) =>
apiClient.PATCH('/api/energy/contracts/{contract_id}', {
params: { path: { contract_id: id } },
body,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['energy-contracts'] }),
})
}
// ---------------------------------------------------------------------------
// Mutation: add a new version to an existing contract
// ---------------------------------------------------------------------------
export function useAddContractVersion() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: number; body: VersionCreate }) =>
apiClient.POST('/api/energy/contracts/{contract_id}/versions', {
params: { path: { contract_id: id } },
body,
}),
onSuccess: (_data, vars) => {
void qc.invalidateQueries({ queryKey: ['energy-contracts'] })
void qc.invalidateQueries({ queryKey: ['energy-contract', vars.id] })
},
})
}
// ---------------------------------------------------------------------------
// Query: energy price curve (Tibber 15-min / manual tariff)
// ---------------------------------------------------------------------------
export function useEnergyPrices(start?: string, end?: string) {
return useQuery({
queryKey: ['energy-prices', start, end],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/prices', {
params: {
query: {
...(start ? { start } : {}),
...(end ? { end } : {}),
},
},
})
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Query: cost periods
// ---------------------------------------------------------------------------
export function useEnergyCosts(start?: string, end?: string, limit?: number) {
return useQuery({
queryKey: ['energy-costs', start, end, limit],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/costs', {
params: {
query: {
...(start ? { start } : {}),
...(end ? { end } : {}),
...(limit != null ? { limit } : {}),
},
},
})
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Query: cost summary
// ---------------------------------------------------------------------------
export function useEnergyCostSummary(start?: string, end?: string) {
return useQuery({
queryKey: ['energy-costs-summary', start, end],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/costs/summary', {
params: {
query: {
...(start ? { start } : {}),
...(end ? { end } : {}),
},
},
})
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Query: DSMR latest reading
// ---------------------------------------------------------------------------
export function useDsmrLatest() {
return useQuery({
queryKey: ['dsmr-latest'],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/dsmr/latest')
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Mutation: recompute costs
// ---------------------------------------------------------------------------
export function useRecomputeCosts() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ start, end }: { start?: string; end?: string } = {}) => {
// The schema marks start/end as required, but the backend accepts them as
// optional query params; we spread only defined values.
const query = {
...(start ? { start } : {}),
...(end ? { end } : {}),
} as { start: string; end: string }
return apiClient.POST('/api/energy/costs/recompute', {
params: { query },
})
},
onSuccess: () => {
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)
// ---------------------------------------------------------------------------
@@ -0,0 +1,212 @@
/**
* Tests for Tibber test button in ConfigPage.
*
* Coverage:
* 1. Tibber test button appears when Tibber section is in config.
* 2. Success tri-state shows green alert.
* 3. Config-error tri-state shows orange alert.
* 4. Failed tri-state shows red alert.
*/
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'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: (...args: unknown[]) => mockPost(...args),
PUT: (...args: unknown[]) => mockPut(...args),
PATCH: vi.fn(),
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 CONFIG_WITH_TIBBER = {
sections: [
{
name: 'Tibber',
fields: [
{
env_name: 'TIBBER_TOKEN',
label: 'Tibber API Token',
secret: true,
input_type: 'text',
configured: true,
value: null,
},
],
},
],
}
// ---------------------------------------------------------------------------
// Import component
// ---------------------------------------------------------------------------
import { ConfigPage } from './ConfigPage'
// ---------------------------------------------------------------------------
// Helper to suppress TOTP and Expose Settings sub-queries
// ---------------------------------------------------------------------------
function mockConfigDependencies() {
mockGet.mockImplementation((path: string) => {
if (path === '/api/config') {
return Promise.resolve({ data: CONFIG_WITH_TIBBER })
}
if (path === '/api/expose') {
return Promise.resolve({
data: {
catalog: [],
mqtt_status: { connected: false, broker: null },
},
})
}
if (path === '/api/auth/totp/status') {
return Promise.resolve({ data: { enabled: false } })
}
return Promise.resolve({ data: null })
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ConfigPage — Tibber test button', () => {
beforeEach(() => vi.clearAllMocks())
it('renders Tibber test button when Tibber section is present', async () => {
mockConfigDependencies()
renderWithProviders(<ConfigPage />)
await waitFor(() => {
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
})
})
it('shows success alert on successful Tibber test', async () => {
const user = userEvent.setup()
mockConfigDependencies()
mockPost.mockImplementation((path: string) => {
if (path === '/api/energy/tibber/test') {
return Promise.resolve({
data: {
result: 'success',
message: 'Connected to Tibber API',
price: {
starts_at: '2026-06-22T10:00:00Z',
total: 0.1337,
energy: 0.08,
tax: 0.0537,
currency: 'EUR',
level: 'NORMAL',
},
},
})
}
return Promise.resolve({ data: null })
})
renderWithProviders(<ConfigPage />)
await waitFor(() => {
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('tibber-test-button'))
await waitFor(() => {
expect(screen.getByTestId('tibber-result-success')).toBeInTheDocument()
})
expect(screen.getByTestId('tibber-result-success')).toHaveTextContent(
'Tibber connection successful',
)
})
it('shows config-error alert when Tibber is misconfigured', async () => {
const user = userEvent.setup()
mockConfigDependencies()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ApiErrorClass = (await import('../api/client')).ApiError as any
mockPost.mockImplementation((path: string) => {
if (path === '/api/energy/tibber/test') {
throw new ApiErrorClass(400, {
result: 'config-error',
message: 'Tibber token not configured',
})
}
return Promise.resolve({ data: null })
})
renderWithProviders(<ConfigPage />)
await waitFor(() => {
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('tibber-test-button'))
await waitFor(() => {
expect(screen.getByTestId('tibber-result-config-error')).toBeInTheDocument()
})
})
it('shows failed alert when Tibber test fails', async () => {
const user = userEvent.setup()
mockConfigDependencies()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ApiErrorClass = (await import('../api/client')).ApiError as any
mockPost.mockImplementation((path: string) => {
if (path === '/api/energy/tibber/test') {
throw new ApiErrorClass(500, {
result: 'failed',
message: 'Connection refused',
})
}
return Promise.resolve({ data: null })
})
renderWithProviders(<ConfigPage />)
await waitFor(() => {
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('tibber-test-button'))
await waitFor(() => {
expect(screen.getByTestId('tibber-result-failed')).toBeInTheDocument()
})
})
})
+96
View File
@@ -49,6 +49,7 @@ import { TotpSettings } from './TotpSettings'
type ConfigField = components['schemas']['ConfigField']
type ConfigSection = components['schemas']['ConfigSection']
type TibberTestPriceSchema = components['schemas']['TibberTestPriceSchema']
/** SMTP test result tri-state. */
type SmtpResult =
@@ -64,6 +65,13 @@ type MqttResult =
| { kind: 'failed'; message: string }
| null
/** Tibber test result tri-state. */
type TibberResult =
| { kind: 'success'; message: string; price?: TibberTestPriceSchema | null }
| { kind: 'config-error'; message: string }
| { kind: 'failed'; message: string }
| null
// ---------------------------------------------------------------------------
// Hook: load config
// ---------------------------------------------------------------------------
@@ -339,6 +347,85 @@ function MqttTestButton({ mqttResult, setMqttResult }: MqttTestButtonProps) {
)
}
// ---------------------------------------------------------------------------
// TibberTestButton — sends POST /api/energy/tibber/test and displays tri-state result
// ---------------------------------------------------------------------------
interface TibberTestButtonProps {
tibberResult: TibberResult
setTibberResult: (r: TibberResult) => void
}
function TibberTestButton({ tibberResult, setTibberResult }: TibberTestButtonProps) {
const [testing, setTesting] = useState(false)
async function handleTest() {
setTibberResult(null)
setTesting(true)
try {
const res = await apiClient.POST('/api/energy/tibber/test')
if (res.data) {
setTibberResult({
kind: 'success',
message: res.data.message,
price: res.data.price,
})
}
} catch (err) {
if (err instanceof ApiError) {
const body = err.body as { result?: string; message?: string } | null
const result = body?.result
const message = body?.message ?? 'Unknown error'
if (result === 'config-error') {
setTibberResult({ kind: 'config-error', message })
} else {
setTibberResult({ kind: 'failed', message })
}
} else {
setTibberResult({ kind: 'failed', message: 'Unexpected error during Tibber test.' })
}
} finally {
setTesting(false)
}
}
return (
<Stack gap="xs">
<Button
type="button"
variant="outline"
onClick={handleTest}
loading={testing}
data-testid="tibber-test-button"
>
Test Tibber Connection
</Button>
{tibberResult?.kind === 'success' && (
<Alert color="green" data-testid="tibber-result-success">
Tibber connection successful. {tibberResult.message}
{tibberResult.price && (
<Text size="xs" mt={4}>
Current price: {tibberResult.price.total} {tibberResult.price.currency}/kWh
{tibberResult.price.level ? ` (${tibberResult.price.level})` : ''}
</Text>
)}
</Alert>
)}
{tibberResult?.kind === 'config-error' && (
<Alert color="orange" data-testid="tibber-result-config-error">
Tibber configuration error check your Tibber API token. {tibberResult.message}
</Alert>
)}
{tibberResult?.kind === 'failed' && (
<Alert color="red" data-testid="tibber-result-failed">
Tibber test failed. {tibberResult.message}
</Alert>
)}
</Stack>
)
}
// ---------------------------------------------------------------------------
// ConfigPage — main component
// ---------------------------------------------------------------------------
@@ -374,6 +461,9 @@ export function ConfigPage() {
// MQTT test tri-state
const [mqttResult, setMqttResult] = useState<MqttResult>(null)
// Tibber test tri-state
const [tibberResult, setTibberResult] = useState<TibberResult>(null)
function handleChange(envName: string, value: string) {
setLocalValues((prev) => ({ ...prev, [envName]: value }))
setSaveStatus(null)
@@ -432,6 +522,9 @@ export function ConfigPage() {
// Detect if there is an MQTT section (to show the MQTT test button).
const hasMqttSection = data.sections.some((s) => s.name.toLowerCase() === 'mqtt')
// Detect if there is a Tibber section (to show the Tibber test button).
const hasTibberSection = data.sections.some((s) => s.name.toLowerCase() === 'tibber')
// Default: open the first section so users immediately see content.
const defaultAccordionValue = data.sections[0]?.name ?? null
@@ -512,6 +605,9 @@ export function ConfigPage() {
{hasMqttSection && (
<MqttTestButton mqttResult={mqttResult} setMqttResult={setMqttResult} />
)}
{hasTibberSection && (
<TibberTestButton tibberResult={tibberResult} setTibberResult={setTibberResult} />
)}
</Group>
</Group>
</Stack>
+68 -31
View File
@@ -11,6 +11,8 @@
* - Latest readings card per device (T07): fetches /latest and /metrics; tolerates
* missing payload keys.
* - Trend charts per device (T07): EnergyCharts component (Recharts isolated inside).
*
* M6-T10: added Tabs layout with Devices / Contracts / Prices / Costs tabs.
*/
import { useState } from 'react'
@@ -34,10 +36,14 @@ import {
SimpleGrid,
Divider,
Switch,
Tabs,
} from '@mantine/core'
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
import { DeviceForm } from '../energy/DeviceForm'
import { EnergyCharts } from '../energy/EnergyCharts'
import { ContractManager } from '../energy/ContractManager'
import { TibberPrices } from '../energy/TibberPrices'
import { CostView } from '../energy/CostView'
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
import { ApiError } from '../api/client'
import { formatMetricValue } from '../energy/format'
@@ -488,10 +494,10 @@ function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) {
}
// ---------------------------------------------------------------------------
// EnergyPage — top-level
// DevicesTab — self-contained devices management tab (extracted from original EnergyPage)
// ---------------------------------------------------------------------------
export function EnergyPage() {
function DevicesTab() {
const devicesQuery = useDevices()
const deleteMutation = useDeleteDevice()
@@ -534,10 +540,6 @@ export function EnergyPage() {
}
}
// ---------------------------------------------------------------------------
// Loading / error states
// ---------------------------------------------------------------------------
if (devicesQuery.isLoading) {
return (
<Center pt="xl" data-testid="energy-loading">
@@ -548,39 +550,31 @@ export function EnergyPage() {
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>
<Alert color="red" data-testid="energy-load-error">
Failed to load devices. Please refresh.
</Alert>
)
}
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>
<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}
/>
<DeviceTable
devices={devices}
onEdit={openEdit}
onDelete={openDelete}
/>
{/* Latest readings cards + trend charts (T07) */}
<DeviceReadingsSection devices={devices} />
</Stack>
{/* Latest readings cards + trend charts (T07) */}
<DeviceReadingsSection devices={devices} />
{/* Create form */}
{showCreateForm && (
@@ -609,6 +603,49 @@ export function EnergyPage() {
has409Error={delete409}
/>
)}
</Stack>
)
}
// ---------------------------------------------------------------------------
// EnergyPage — top-level with Tabs
// ---------------------------------------------------------------------------
export function EnergyPage() {
return (
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
<Tabs defaultValue="devices">
<Tabs.List mb="lg">
<Tabs.Tab value="devices" data-testid="tab-devices">
Devices
</Tabs.Tab>
<Tabs.Tab value="contracts" data-testid="tab-contracts">
Contracts
</Tabs.Tab>
<Tabs.Tab value="prices" data-testid="tab-prices">
Prices
</Tabs.Tab>
<Tabs.Tab value="costs" data-testid="tab-costs">
Costs
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="devices" data-testid="panel-devices">
<DevicesTab />
</Tabs.Panel>
<Tabs.Panel value="contracts" data-testid="panel-contracts">
<ContractManager />
</Tabs.Panel>
<Tabs.Panel value="prices" data-testid="panel-prices">
<TibberPrices />
</Tabs.Panel>
<Tabs.Panel value="costs" data-testid="panel-costs">
<CostView />
</Tabs.Panel>
</Tabs>
</Container>
)
}