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:
@@ -416,7 +416,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: 查询有时间窗+上限;recompute 复用 T07、无破坏删;test 不泄 token。
|
- **Reviewer checklist**: 查询有时间窗+上限;recompute 复用 T07、无破坏删;test 不泄 token。
|
||||||
|
|
||||||
### M6-T10 — 前端:合同管理 + 价格/费用视图 + Tibber 测试
|
### 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`
|
- **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 容错。
|
- **Steps**: 合同列表/新建/编辑/激活/加版本(**表单字段按 `/profiles` 结构渲染**,manual 双费率/税/固定费/抵扣)+ 版本历史只读;价格曲线 + 费用走势/明细 + 汇总卡片;Tibber 测试三态;全走类型化 client;空/加载/错误态 + 缺 key 容错。
|
||||||
- **Out of scope**: 不做服务端降采样;不改后端。
|
- **Out of scope**: 不做服务端降采样;不改后端。
|
||||||
|
|||||||
Vendored
+1164
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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: {} },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
// 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -49,6 +49,7 @@ import { TotpSettings } from './TotpSettings'
|
|||||||
|
|
||||||
type ConfigField = components['schemas']['ConfigField']
|
type ConfigField = components['schemas']['ConfigField']
|
||||||
type ConfigSection = components['schemas']['ConfigSection']
|
type ConfigSection = components['schemas']['ConfigSection']
|
||||||
|
type TibberTestPriceSchema = components['schemas']['TibberTestPriceSchema']
|
||||||
|
|
||||||
/** SMTP test result tri-state. */
|
/** SMTP test result tri-state. */
|
||||||
type SmtpResult =
|
type SmtpResult =
|
||||||
@@ -64,6 +65,13 @@ type MqttResult =
|
|||||||
| { kind: 'failed'; message: string }
|
| { kind: 'failed'; message: string }
|
||||||
| null
|
| 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
|
// 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
|
// ConfigPage — main component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -374,6 +461,9 @@ export function ConfigPage() {
|
|||||||
// MQTT test tri-state
|
// MQTT test tri-state
|
||||||
const [mqttResult, setMqttResult] = useState<MqttResult>(null)
|
const [mqttResult, setMqttResult] = useState<MqttResult>(null)
|
||||||
|
|
||||||
|
// Tibber test tri-state
|
||||||
|
const [tibberResult, setTibberResult] = useState<TibberResult>(null)
|
||||||
|
|
||||||
function handleChange(envName: string, value: string) {
|
function handleChange(envName: string, value: string) {
|
||||||
setLocalValues((prev) => ({ ...prev, [envName]: value }))
|
setLocalValues((prev) => ({ ...prev, [envName]: value }))
|
||||||
setSaveStatus(null)
|
setSaveStatus(null)
|
||||||
@@ -432,6 +522,9 @@ export function ConfigPage() {
|
|||||||
// Detect if there is an MQTT section (to show the MQTT test button).
|
// Detect if there is an MQTT section (to show the MQTT test button).
|
||||||
const hasMqttSection = data.sections.some((s) => s.name.toLowerCase() === 'mqtt')
|
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.
|
// Default: open the first section so users immediately see content.
|
||||||
const defaultAccordionValue = data.sections[0]?.name ?? null
|
const defaultAccordionValue = data.sections[0]?.name ?? null
|
||||||
|
|
||||||
@@ -512,6 +605,9 @@ export function ConfigPage() {
|
|||||||
{hasMqttSection && (
|
{hasMqttSection && (
|
||||||
<MqttTestButton mqttResult={mqttResult} setMqttResult={setMqttResult} />
|
<MqttTestButton mqttResult={mqttResult} setMqttResult={setMqttResult} />
|
||||||
)}
|
)}
|
||||||
|
{hasTibberSection && (
|
||||||
|
<TibberTestButton tibberResult={tibberResult} setTibberResult={setTibberResult} />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
* - Latest readings card per device (T07): fetches /latest and /metrics; tolerates
|
* - Latest readings card per device (T07): fetches /latest and /metrics; tolerates
|
||||||
* missing payload keys.
|
* missing payload keys.
|
||||||
* - Trend charts per device (T07): EnergyCharts component (Recharts isolated inside).
|
* - 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'
|
import { useState } from 'react'
|
||||||
@@ -34,10 +36,14 @@ import {
|
|||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Divider,
|
Divider,
|
||||||
Switch,
|
Switch,
|
||||||
|
Tabs,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
|
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
|
||||||
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 { TibberPrices } from '../energy/TibberPrices'
|
||||||
|
import { CostView } from '../energy/CostView'
|
||||||
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
|
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import { formatMetricValue } from '../energy/format'
|
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 devicesQuery = useDevices()
|
||||||
const deleteMutation = useDeleteDevice()
|
const deleteMutation = useDeleteDevice()
|
||||||
|
|
||||||
@@ -534,10 +540,6 @@ export function EnergyPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Loading / error states
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if (devicesQuery.isLoading) {
|
if (devicesQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<Center pt="xl" data-testid="energy-loading">
|
<Center pt="xl" data-testid="energy-loading">
|
||||||
@@ -548,22 +550,15 @@ export function EnergyPage() {
|
|||||||
|
|
||||||
if (devicesQuery.isError || !devicesQuery.data) {
|
if (devicesQuery.isError || !devicesQuery.data) {
|
||||||
return (
|
return (
|
||||||
<Container size="xl" pt="xl">
|
|
||||||
<Alert color="red" data-testid="energy-load-error">
|
<Alert color="red" data-testid="energy-load-error">
|
||||||
Failed to load devices. Please refresh.
|
Failed to load devices. Please refresh.
|
||||||
</Alert>
|
</Alert>
|
||||||
</Container>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const devices = devicesQuery.data.items
|
const devices = devicesQuery.data.items
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main render
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
|
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Title order={2}>Energy — Devices</Title>
|
<Title order={2}>Energy — Devices</Title>
|
||||||
@@ -580,7 +575,6 @@ export function EnergyPage() {
|
|||||||
|
|
||||||
{/* Latest readings cards + trend charts (T07) */}
|
{/* Latest readings cards + trend charts (T07) */}
|
||||||
<DeviceReadingsSection devices={devices} />
|
<DeviceReadingsSection devices={devices} />
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Create form */}
|
{/* Create form */}
|
||||||
{showCreateForm && (
|
{showCreateForm && (
|
||||||
@@ -609,6 +603,49 @@ export function EnergyPage() {
|
|||||||
has409Error={delete409}
|
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>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user