diff --git a/docs/design/m6-tibber-dynamic-energy.md b/docs/design/m6-tibber-dynamic-energy.md
index dac29ed..aa178d9 100644
--- a/docs/design/m6-tibber-dynamic-energy.md
+++ b/docs/design/m6-tibber-dynamic-energy.md
@@ -416,7 +416,7 @@ Phase D(API + 前端)
- **Reviewer checklist**: 查询有时间窗+上限;recompute 复用 T07、无破坏删;test 不泄 token。
### M6-T10 — 前端:合同管理 + 价格/费用视图 + Tibber 测试
-- **Status**: `todo` · **Depends**: M6-T04, M6-T09
+- **Status**: `done` · **Depends**: M6-T04, M6-T09
- **Files**: `modify frontend/src/pages/EnergyPage.tsx`;`create frontend/src/energy/ContractManager.tsx`、`ContractForm.tsx`、`TibberPrices.tsx`、`CostView.tsx`、扩 `hooks.ts`;`modify` Config 页 Tibber 测试入口;`create` 对应 `*.test.tsx`
- **Steps**: 合同列表/新建/编辑/激活/加版本(**表单字段按 `/profiles` 结构渲染**,manual 双费率/税/固定费/抵扣)+ 版本历史只读;价格曲线 + 费用走势/明细 + 汇总卡片;Tibber 测试三态;全走类型化 client;空/加载/错误态 + 缺 key 容错。
- **Out of scope**: 不做服务端降采样;不改后端。
diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts
index fd14a4d..214ca8c 100644
--- a/frontend/src/api/schema.d.ts
+++ b/frontend/src/api/schema.d.ts
@@ -242,6 +242,323 @@ export interface paths {
patch: operations["patch_poo_api_poo__timestamp__patch"];
trace?: never;
};
+ "/api/energy/prices": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Prices
+ * @description Return the price curve for the active contract.
+ *
+ * **Tibber contracts** (kind="tibber"):
+ * Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending
+ * by ``starts_at``. At most ``limit`` rows are returned (most recent first
+ * within the window, then reversed to ascending order — identical to the
+ * modbus readings pattern).
+ *
+ * Response ``points`` carries per-slot:
+ * - ``buy = total`` (Tibber all-inclusive price)
+ * - ``sell = total − energy_tax − sell_adjust`` (from active version values)
+ * - ``level`` (Tibber price level, may be null)
+ *
+ * ``tariff`` is null.
+ *
+ * **Manual contracts** (kind="manual"):
+ * ``points`` is empty. ``tariff`` carries the four effective prices
+ * derived using the billing engine formula:
+ * - ``buy_dal = energy.buy.dal + energy_tax + ode``
+ * - ``buy_normal = energy.buy.normal + energy_tax + ode``
+ * - ``sell_dal = energy.sell.dal``
+ * - ``sell_normal = energy.sell.normal``
+ *
+ * **No active contract**: returns kind=null, currency="EUR", points=[], tariff=null (200).
+ */
+ get: operations["get_prices_api_energy_prices_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/energy/costs": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Costs
+ * @description Return energy_cost_period rows within a time window.
+ *
+ * Rows are ordered by ``period_start`` ascending. When the window contains
+ * more rows than ``limit``, the **most recent** N rows are returned (DESC LIMIT),
+ * then reversed to ascending order — identical to the modbus readings pattern.
+ *
+ * Query parameters:
+ * - ``start``: inclusive lower bound on ``period_start`` (ISO 8601 datetime).
+ * - ``end``: inclusive upper bound on ``period_start`` (ISO 8601 datetime).
+ * - ``limit``: max rows to return (default 500, max {_COSTS_LIMIT_MAX}).
+ */
+ get: operations["get_costs_api_energy_costs_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/energy/costs/summary": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Costs Summary
+ * @description Aggregate billing for a time interval.
+ *
+ * Calls ``energy_cost.summarize(session, start, end)`` which computes:
+ *
+ * total_payable = Σ(net_cost) + fixed_costs − credits
+ *
+ * where ``fixed_costs`` is (network_fee + management_fee) apportioned to the
+ * interval length in days (÷30 per month), and ``credits`` is heffingskorting
+ * apportioned similarly (÷365 per year).
+ *
+ * Both ``fixed_costs`` and ``credits`` are derived from the **currently active
+ * contract version at ``end``**. When no active contract exists they are 0.
+ */
+ get: operations["get_costs_summary_api_energy_costs_summary_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/energy/dsmr/latest": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Dsmr Latest
+ * @description Return the most recent dsmr_reading row.
+ *
+ * Returns ``{"found": false, "recorded_at": null, "payload": null}`` (200, not
+ * 404) when no rows exist yet, so the front-end can distinguish "no data" from
+ * a server error.
+ */
+ get: operations["get_dsmr_latest_api_energy_dsmr_latest_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/energy/costs/recompute": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Post Recompute
+ * @description Idempotently recompute billing records in a time window.
+ *
+ * Calls ``energy_cost.recompute_range(session, start, end)`` which overwrites
+ * existing rows (including successful ones) for every UTC quarter-hour boundary
+ * in ``[start, end)``.
+ *
+ * **Idempotency**: repeated calls with the same window produce the same
+ * outcome. No rows are deleted; only upserted.
+ *
+ * **Window constraint**: the maximum allowed range is {_RECOMPUTE_MAX_DAYS} days.
+ * Requests exceeding this return 422.
+ *
+ * Returns the number of periods for which a billing record was written.
+ * Periods skipped due to missing contract or missing Tibber price are not counted.
+ */
+ post: operations["post_recompute_api_energy_costs_recompute_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/energy/tibber/test": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Post Tibber Test
+ * @description Test Tibber API connectivity by fetching the current price point.
+ *
+ * Three possible outcomes:
+ *
+ * - **200** ``{ result: "success", message: ..., price: {...} }``
+ * The Tibber API responded with a valid current price. ``price`` contains
+ * starts_at, total, energy, tax, currency, and level.
+ *
+ * - **400** ``{ result: "config-error", message: ... }``
+ * The Tibber API token is empty or not configured.
+ *
+ * - **502** ``{ result: "failed", message: ... }``
+ * The API call failed (authentication rejected, network error, timeout,
+ * unexpected response, etc.).
+ *
+ * The API token is **never** included in the response body or logged.
+ */
+ post: operations["post_tibber_test_api_energy_tibber_test_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/energy/profiles": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Profiles
+ * @description List all available pricing profile structures.
+ *
+ * Returns the full profile structure for each supported contract kind
+ * (``manual`` and ``tibber``). The front-end uses this to dynamically
+ * render the correct fields and labels for the contract creation/editing form.
+ */
+ get: operations["get_profiles_api_energy_profiles_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/energy/contracts": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List Energy Contracts
+ * @description List all energy contracts with their active status.
+ *
+ * Returns a flat list (no embedded version history); use
+ * GET /api/energy/contracts/{id} to fetch the full version history for a
+ * specific contract.
+ */
+ get: operations["list_energy_contracts_api_energy_contracts_get"];
+ put?: never;
+ /**
+ * Create Energy Contract
+ * @description Create a new energy contract with an initial pricing version.
+ *
+ * The ``values`` dict is validated against the YAML profile for the given
+ * ``kind``; non-conforming values result in 422 Unprocessable Entity.
+ * The new contract is created with ``active=False``; use
+ * PATCH /api/energy/contracts/{id} with ``active=true`` to activate it.
+ */
+ post: operations["create_energy_contract_api_energy_contracts_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/energy/contracts/{contract_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Energy Contract
+ * @description Return a single energy contract with its full version history.
+ *
+ * Versions are ordered by ``effective_from`` ascending so the caller can
+ * easily inspect the pricing timeline.
+ */
+ get: operations["get_energy_contract_api_energy_contracts__contract_id__get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ /**
+ * Patch Energy Contract
+ * @description Partially update a contract: rename or change activation status.
+ *
+ * - ``name``: updates the human-readable label.
+ * - ``active=true``: activates this contract (all others are deactivated).
+ * - ``active=false``: deactivates this contract (no effect on others).
+ *
+ * At most one contract may be active at any time; the service layer enforces
+ * mutual exclusion.
+ */
+ patch: operations["patch_energy_contract_api_energy_contracts__contract_id__patch"];
+ trace?: never;
+ };
+ "/api/energy/contracts/{contract_id}/versions": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Add Contract Version
+ * @description Add a new pricing version to an existing contract.
+ *
+ * This is how price changes are recorded: the current open version is
+ * automatically closed (its ``effective_to`` is set to ``body.effective_from``)
+ * and a new version is created starting at ``body.effective_from``.
+ *
+ * The ``values`` dict must conform to the contract's pricing profile.
+ * Non-conforming values return 422. If ``effective_from`` is not strictly
+ * after the previous version's ``effective_from``, 422 is returned without
+ * writing any rows.
+ *
+ * Historical versions are never modified; this endpoint is append-only.
+ */
+ post: operations["add_contract_version_api_energy_contracts__contract_id__versions_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/expose": {
parameters: {
query?: never;
@@ -874,6 +1191,216 @@ export interface components {
/** Sections */
sections: components["schemas"]["ConfigSection"][];
};
+ /**
+ * ContractCreate
+ * @description Request body for POST /api/energy/contracts.
+ *
+ * ``effective_from`` defaults to the current UTC time if not provided,
+ * giving the first version an open-ended start from "now".
+ * ``kind`` is validated at the application layer against the profile registry;
+ * clients should send ``"manual"`` or ``"tibber"``.
+ */
+ ContractCreate: {
+ /** Name */
+ name: string;
+ /** Kind */
+ kind: string;
+ /**
+ * Currency
+ * @default EUR
+ */
+ currency: string;
+ /** Values */
+ values: {
+ [key: string]: unknown;
+ };
+ /**
+ * Effective From
+ * @description UTC datetime from which the first pricing version is effective. Defaults to the current UTC time when omitted.
+ */
+ effective_from?: string | null;
+ };
+ /**
+ * ContractDetailResponse
+ * @description Response schema for a single EnergyContract with full version history.
+ *
+ * Returned by GET /api/energy/contracts/{id} and by successful POST / PATCH
+ * operations where the caller needs to see all version data.
+ */
+ ContractDetailResponse: {
+ /** Id */
+ id: number;
+ /** Name */
+ name: string;
+ /** Kind */
+ kind: string;
+ /** Active */
+ active: boolean;
+ /** Currency */
+ currency: string;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ /**
+ * Updated At
+ * Format: date-time
+ */
+ updated_at: string;
+ /** Versions */
+ versions: components["schemas"]["ContractVersionResponse"][];
+ };
+ /**
+ * ContractListResponse
+ * @description Response schema for GET /api/energy/contracts.
+ */
+ ContractListResponse: {
+ /** Items */
+ items: components["schemas"]["ContractResponse"][];
+ /** Total */
+ total: number;
+ };
+ /**
+ * ContractPatch
+ * @description Request body for PATCH /api/energy/contracts/{id}.
+ *
+ * All fields are optional. Sending ``active=true`` activates this contract
+ * (deactivating all others); ``active=false`` deactivates it without affecting
+ * other contracts.
+ */
+ ContractPatch: {
+ /** Name */
+ name?: string | null;
+ /** Active */
+ active?: boolean | null;
+ };
+ /**
+ * ContractResponse
+ * @description Response schema for a single EnergyContract (without embedded versions).
+ *
+ * Used for list responses where embedding all versions would be expensive.
+ */
+ ContractResponse: {
+ /** Id */
+ id: number;
+ /** Name */
+ name: string;
+ /** Kind */
+ kind: string;
+ /** Active */
+ active: boolean;
+ /** Currency */
+ currency: string;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ /**
+ * Updated At
+ * Format: date-time
+ */
+ updated_at: string;
+ };
+ /**
+ * ContractVersionResponse
+ * @description Response schema for a single EnergyContractVersion row.
+ */
+ ContractVersionResponse: {
+ /** Id */
+ id: number;
+ /**
+ * Effective From
+ * Format: date-time
+ */
+ effective_from: string;
+ /** Effective To */
+ effective_to: string | null;
+ /** Values */
+ values: {
+ [key: string]: unknown;
+ };
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ };
+ /**
+ * CostPeriodSchema
+ * @description One 15-minute billing record from the energy_cost_period table.
+ */
+ CostPeriodSchema: {
+ /**
+ * Period Start
+ * Format: date-time
+ */
+ period_start: string;
+ /**
+ * D1 Kwh
+ * @description Delivered low-tariff kWh for this period.
+ */
+ d1_kwh: number;
+ /**
+ * D2 Kwh
+ * @description Delivered normal-tariff kWh for this period.
+ */
+ d2_kwh: number;
+ /**
+ * R1 Kwh
+ * @description Returned low-tariff kWh for this period.
+ */
+ r1_kwh: number;
+ /**
+ * R2 Kwh
+ * @description Returned normal-tariff kWh for this period.
+ */
+ r2_kwh: number;
+ /**
+ * Import Cost
+ * @description Cost of electricity drawn from grid (EUR).
+ */
+ import_cost: number;
+ /**
+ * Export Revenue
+ * @description Revenue from electricity fed to grid (EUR).
+ */
+ export_revenue: number;
+ /**
+ * Net Cost
+ * @description import_cost − export_revenue (EUR).
+ */
+ net_cost: number;
+ /**
+ * Currency
+ * @description ISO 4217 currency code.
+ */
+ currency: string;
+ /**
+ * Degraded
+ * @description True when the period was computed with incomplete data.
+ */
+ degraded: boolean;
+ /**
+ * Contract Version Id
+ * @description FK to the contract version used for this billing period (null when degraded).
+ */
+ contract_version_id?: number | null;
+ };
+ /**
+ * CostsResponse
+ * @description Response for GET /api/energy/costs.
+ */
+ CostsResponse: {
+ /** Items */
+ items: components["schemas"]["CostPeriodSchema"][];
+ /**
+ * Total
+ * @description Number of items returned.
+ */
+ total: number;
+ };
/**
* DeviceInfoSchema
* @description HA device grouping info for an exposable entity.
@@ -884,6 +1411,23 @@ export interface components {
/** Name */
name: string;
};
+ /**
+ * DsmrLatestResponse
+ * @description Response for GET /api/energy/dsmr/latest.
+ *
+ * ``found`` is False when no dsmr_reading rows exist yet. The front-end
+ * should check ``found`` before reading ``recorded_at`` or ``payload``.
+ */
+ DsmrLatestResponse: {
+ /** Found */
+ found: boolean;
+ /** Recorded At */
+ recorded_at?: string | null;
+ /** Payload */
+ payload?: {
+ [key: string]: unknown;
+ } | null;
+ };
/**
* ExposableEntitySchema
* @description One exposable entity in the catalog.
@@ -985,6 +1529,35 @@ export interface components {
/** Totp Code */
totp_code?: string | null;
};
+ /**
+ * ManualTariffSchema
+ * @description Fixed-tariff breakdown for manual contracts.
+ *
+ * Prices are the *effective* buy prices as used by the billing engine
+ * (energy_buy_x + energy_tax + ode) and the raw sell prices.
+ */
+ ManualTariffSchema: {
+ /**
+ * Buy Dal
+ * @description Effective buy price, low-tariff / dal (EUR/kWh).
+ */
+ buy_dal: number;
+ /**
+ * Buy Normal
+ * @description Effective buy price, normal / high-tariff (EUR/kWh).
+ */
+ buy_normal: number;
+ /**
+ * Sell Dal
+ * @description Sell price, low-tariff / dal (EUR/kWh).
+ */
+ sell_dal: number;
+ /**
+ * Sell Normal
+ * @description Sell price, normal / high-tariff (EUR/kWh).
+ */
+ sell_normal: number;
+ };
/**
* MetricInfo
* @description Metadata for a single measurable quantity in a device's profile.
@@ -1247,6 +1820,63 @@ export interface components {
/** Longitude */
longitude?: number | null;
};
+ /**
+ * PricePointSchema
+ * @description A single 15-minute price point (tibber) or placeholder entry.
+ */
+ PricePointSchema: {
+ /**
+ * Starts At
+ * Format: date-time
+ */
+ starts_at: string;
+ /**
+ * Buy
+ * @description All-in buy price in EUR/kWh (including taxes).
+ */
+ buy: number;
+ /**
+ * Sell
+ * @description Net sell price in EUR/kWh.
+ */
+ sell: number;
+ /**
+ * Level
+ * @description Tibber price level (CHEAP / NORMAL / EXPENSIVE); null for manual.
+ */
+ level?: string | null;
+ };
+ /**
+ * PricesResponse
+ * @description Response for GET /api/energy/prices.
+ *
+ * ``kind`` mirrors the active contract kind:
+ * - ``"tibber"`` → ``points`` has actual 15-min price entries; ``tariff`` is null.
+ * - ``"manual"`` → ``points`` is empty; ``tariff`` carries the fixed-rate table.
+ * - ``None`` → no active contract; both ``points`` and ``tariff`` are empty/null.
+ *
+ * ``currency`` comes from the active contract (or "EUR" fallback).
+ * ``points`` is always ascending by ``starts_at``.
+ */
+ PricesResponse: {
+ /**
+ * Kind
+ * @description Active contract kind ('tibber' or 'manual'), or null if no active contract.
+ */
+ kind: string | null;
+ /**
+ * Currency
+ * @description ISO 4217 currency code.
+ */
+ currency: string;
+ /**
+ * Points
+ * @description 15-minute price points for tibber contracts (ascending by starts_at). Empty for manual contracts or when no active contract exists.
+ */
+ points: components["schemas"]["PricePointSchema"][];
+ /** @description Fixed tariff table for manual contracts. Null for tibber contracts and when no active contract exists. */
+ tariff?: components["schemas"]["ManualTariffSchema"] | null;
+ };
/**
* ProfileSummary
* @description One entry in the GET /api/modbus/profiles response.
@@ -1257,6 +1887,21 @@ export interface components {
/** Description */
description: string;
};
+ /**
+ * ProfilesResponse
+ * @description Response schema for GET /api/energy/profiles.
+ *
+ * ``profiles`` is a list of raw profile dicts as produced by
+ * ``list_profiles()`` (Pydantic model dumps). Each entry contains at minimum
+ * ``kind`` and ``label``; the full nested structure allows the front-end to
+ * render a type-appropriate form for each pricing profile.
+ */
+ ProfilesResponse: {
+ /** Profiles */
+ profiles: {
+ [key: string]: unknown;
+ }[];
+ };
/** PublicIPCheckResponse */
PublicIPCheckResponse: {
/**
@@ -1321,6 +1966,17 @@ export interface components {
/** Last Provider */
last_provider: string | null;
};
+ /**
+ * RecomputeResponse
+ * @description Response for POST /api/energy/costs/recompute.
+ */
+ RecomputeResponse: {
+ /**
+ * Recomputed
+ * @description Number of 15-minute periods for which a billing record was written (inserted or updated). Periods skipped due to missing contract or missing Tibber price are not counted.
+ */
+ recomputed: number;
+ };
/**
* RepublishResponse
* @description Response for POST /api/expose/republish.
@@ -1362,6 +2018,110 @@ export interface components {
/** Status */
status: string;
};
+ /**
+ * SummaryResponse
+ * @description Response for GET /api/energy/costs/summary.
+ *
+ * All monetary values are in ``currency``.
+ *
+ * ``total_payable = metered_net + fixed_costs − credits``
+ */
+ SummaryResponse: {
+ /** Currency */
+ currency: string;
+ /**
+ * Metered Import
+ * @description Σ import_cost for non-degraded periods.
+ */
+ metered_import: number;
+ /**
+ * Metered Export
+ * @description Σ export_revenue for non-degraded periods.
+ */
+ metered_export: number;
+ /**
+ * Metered Net
+ * @description Σ net_cost for non-degraded periods.
+ */
+ metered_net: number;
+ /**
+ * Fixed Costs
+ * @description Standing charges (network_fee + management_fee) apportioned over the interval.
+ */
+ fixed_costs: number;
+ /**
+ * Credits
+ * @description Energy-tax credit (heffingskorting) apportioned over the interval.
+ */
+ credits: number;
+ /**
+ * Total Payable
+ * @description metered_net + fixed_costs − credits (actual amount owed).
+ */
+ total_payable: number;
+ /**
+ * Period Count
+ * @description Number of non-degraded billing periods in range.
+ */
+ period_count: number;
+ /**
+ * Degraded Count
+ * @description Number of degraded billing periods in range.
+ */
+ degraded_count: number;
+ /**
+ * Days
+ * @description Interval length in days.
+ */
+ days: number;
+ };
+ /**
+ * TibberTestPriceSchema
+ * @description Current Tibber price point returned on a successful test.
+ *
+ * Carries enough fields for the front-end to confirm the API is working and
+ * display the live price. The API token is **never** included.
+ */
+ TibberTestPriceSchema: {
+ /**
+ * Starts At
+ * Format: date-time
+ */
+ starts_at: string;
+ /** Total */
+ total: number;
+ /** Energy */
+ energy: number;
+ /** Tax */
+ tax: number;
+ /** Currency */
+ currency: string;
+ /** Level */
+ level?: string | null;
+ };
+ /**
+ * TibberTestResponse
+ * @description Three-state response for POST /api/energy/tibber/test.
+ *
+ * Possible ``result`` values:
+ *
+ * - ``"success"`` — Tibber API responded with a valid price.
+ * - ``"config-error"`` — Token is missing or not configured.
+ * - ``"failed"`` — API call failed (auth rejected, network error, timeout, etc.).
+ *
+ * ``price`` is populated only on ``"success"``; it is null otherwise.
+ * ``message`` always contains a human-readable explanation.
+ */
+ TibberTestResponse: {
+ /**
+ * Result
+ * @enum {string}
+ */
+ result: "success" | "config-error" | "failed";
+ /** Message */
+ message: string;
+ price?: components["schemas"]["TibberTestPriceSchema"] | null;
+ };
/**
* TotpDisableRequest
* @description Disable TOTP by proving identity.
@@ -1415,6 +2175,21 @@ export interface components {
/** Error Type */
type: string;
};
+ /**
+ * VersionCreate
+ * @description Request body for POST /api/energy/contracts/{id}/versions.
+ */
+ VersionCreate: {
+ /**
+ * Effective From
+ * Format: date-time
+ */
+ effective_from: string;
+ /** Values */
+ values: {
+ [key: string]: unknown;
+ };
+ };
};
responses: never;
parameters: never;
@@ -1832,6 +2607,395 @@ export interface operations {
};
};
};
+ get_prices_api_energy_prices_get: {
+ parameters: {
+ query?: {
+ /** @description Inclusive start of the time window (ISO 8601). Defaults to the start of today UTC when omitted. */
+ start?: string | null;
+ /** @description Inclusive end of the time window (ISO 8601). Defaults to the end of tomorrow UTC when omitted. */
+ end?: string | null;
+ /** @description Maximum number of Tibber price points to return. */
+ limit?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PricesResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_costs_api_energy_costs_get: {
+ parameters: {
+ query?: {
+ /** @description Inclusive lower bound for period_start (ISO 8601). */
+ start?: string | null;
+ /** @description Inclusive upper bound for period_start (ISO 8601). */
+ end?: string | null;
+ /** @description Maximum number of cost periods to return (default 500, max 5000). */
+ limit?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CostsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_costs_summary_api_energy_costs_summary_get: {
+ parameters: {
+ query?: {
+ /** @description Inclusive start of the summary interval (ISO 8601). Defaults to the start of the current UTC day. */
+ start?: string | null;
+ /** @description Exclusive end of the summary interval (ISO 8601). Defaults to the start of the next UTC day (i.e. today's full data). */
+ end?: string | null;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SummaryResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_dsmr_latest_api_energy_dsmr_latest_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["DsmrLatestResponse"];
+ };
+ };
+ };
+ };
+ post_recompute_api_energy_costs_recompute_post: {
+ parameters: {
+ query: {
+ /** @description Inclusive start of the recompute window (ISO 8601). Required. */
+ start: string;
+ /** @description Exclusive end of the recompute window (ISO 8601). Required. */
+ end: string;
+ };
+ header?: {
+ "X-CSRF-Token"?: string | null;
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["RecomputeResponse"];
+ };
+ };
+ /** @description Validation error (missing window or range too large) */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ post_tibber_test_api_energy_tibber_test_post: {
+ parameters: {
+ query?: never;
+ header?: {
+ "X-CSRF-Token"?: string | null;
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TibberTestResponse"];
+ };
+ };
+ /** @description Bad Request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TibberTestResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ /** @description Bad Gateway */
+ 502: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TibberTestResponse"];
+ };
+ };
+ };
+ };
+ get_profiles_api_energy_profiles_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ProfilesResponse"];
+ };
+ };
+ };
+ };
+ list_energy_contracts_api_energy_contracts_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ContractListResponse"];
+ };
+ };
+ };
+ };
+ create_energy_contract_api_energy_contracts_post: {
+ parameters: {
+ query?: never;
+ header?: {
+ "X-CSRF-Token"?: string | null;
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ContractCreate"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ContractDetailResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_energy_contract_api_energy_contracts__contract_id__get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ contract_id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ContractDetailResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ patch_energy_contract_api_energy_contracts__contract_id__patch: {
+ parameters: {
+ query?: never;
+ header?: {
+ "X-CSRF-Token"?: string | null;
+ };
+ path: {
+ contract_id: number;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ContractPatch"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ContractDetailResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ add_contract_version_api_energy_contracts__contract_id__versions_post: {
+ parameters: {
+ query?: never;
+ header?: {
+ "X-CSRF-Token"?: string | null;
+ };
+ path: {
+ contract_id: number;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["VersionCreate"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ContractDetailResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_expose_api_expose_get: {
parameters: {
query?: never;
diff --git a/frontend/src/energy/ContractForm.test.tsx b/frontend/src/energy/ContractForm.test.tsx
new file mode 100644
index 0000000..31f7a95
--- /dev/null
+++ b/frontend/src/energy/ContractForm.test.tsx
@@ -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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ // 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(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByTestId('contract-form-cancel')).toBeInTheDocument()
+ })
+
+ await user.click(screen.getByTestId('contract-form-cancel'))
+
+ expect(onClose).toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/energy/ContractForm.tsx b/frontend/src/energy/ContractForm.tsx
new file mode 100644
index 0000000..7026515
--- /dev/null
+++ b/frontend/src/energy/ContractForm.tsx
@@ -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, 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, 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,
+ fieldValues: Record,
+): Record {
+ const result: Record = {}
+
+ for (const [section, fields] of Object.entries(sectionFields)) {
+ const sectionObj: Record = {}
+ 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
+ }
+ 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(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 = ".", value = number
+ const [fieldValues, setFieldValues] = useState>({})
+
+ const [error, setError] = useState(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) => ({
+ 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) => p.kind === effectiveKind)
+
+ // Extract section → leaf fields mapping from the selected profile
+ const sectionFields: Record = {}
+ if (selectedProfile) {
+ const profileObj = selectedProfile as Record
+ 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)
+ }
+ }
+ }
+
+ 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) => p.kind === kind)
+ if (!profile) return
+ const profileObj = profile as Record
+ const defaults: Record = {}
+ 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)
+ 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 (
+
+ {profilesQuery.isLoading && (
+
+
+
+ )}
+
+ {profilesQuery.isError && (
+
+ Failed to load pricing profiles. Cannot continue.
+
+ )}
+
+ {!profilesQuery.isLoading && (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/energy/ContractManager.test.tsx b/frontend/src/energy/ContractManager.test.tsx
new file mode 100644
index 0000000..24c9730
--- /dev/null
+++ b/frontend/src/energy/ContractManager.test.tsx
@@ -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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+ })
+ })
+})
diff --git a/frontend/src/energy/ContractManager.tsx b/frontend/src/energy/ContractManager.tsx
new file mode 100644
index 0000000..b7df932
--- /dev/null
+++ b/frontend/src/energy/ContractManager.tsx
@@ -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(null)
+ const [fetchError, setFetchError] = useState(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 (
+
+ {loading && (
+
+
+
+ )}
+ {fetchError && (
+
+ {fetchError}
+
+ )}
+ {!loading && !fetchError && detail && (
+ <>
+ {detail.versions.length === 0 ? (
+
+ No versions found.
+
+ ) : (
+
+ {detail.versions.map((v) => (
+
+
+
+
+ From: {new Date(v.effective_from).toLocaleDateString()}
+
+ {v.effective_to ? (
+
+ To: {new Date(v.effective_to).toLocaleDateString()}
+
+ ) : (
+
+ current
+
+ )}
+
+
+
+
+ {JSON.stringify(v.values, null, 2)}
+
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// 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 (
+
+ No contracts configured yet. Click "New Contract" to add one.
+
+ )
+ }
+
+ return (
+
+
+
+
+ Name
+ Kind
+ Active
+ Currency
+ Created
+ Actions
+
+
+
+ {contracts.map((contract) => (
+
+
+
+ {contract.name}
+
+
+
+
+ {contract.kind}
+
+
+
+
+ {contract.active ? 'active' : 'inactive'}
+
+
+ {contract.currency}
+
+
+ {new Date(contract.created_at).toLocaleDateString()}
+
+
+
+
+ {!contract.active && (
+
+ )}
+
+
+
+
+
+ ))}
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// ContractManager — top-level
+// ---------------------------------------------------------------------------
+
+export function ContractManager() {
+ const contractsQuery = useContracts()
+ const updateMutation = useUpdateContract()
+
+ const [showCreateForm, setShowCreateForm] = useState(false)
+ const [addVersionContract, setAddVersionContract] = useState(null)
+ const [historyContract, setHistoryContract] = useState(null)
+ const [activatingId, setActivatingId] = useState(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 (
+
+
+
+ )
+ }
+
+ if (contractsQuery.isError || !contractsQuery.data) {
+ return (
+
+ Failed to load contracts. Please refresh.
+
+ )
+ }
+
+ const contracts = contractsQuery.data.items
+
+ return (
+
+
+ Energy Contracts
+
+
+
+ setAddVersionContract(c)}
+ onViewHistory={(c) => setHistoryContract(c)}
+ activatingId={activatingId}
+ />
+
+ {/* Create new contract */}
+ {showCreateForm && (
+ setShowCreateForm(false)}
+ onSaved={() => setShowCreateForm(false)}
+ />
+ )}
+
+ {/* Add version to existing contract */}
+ {addVersionContract && (
+ setAddVersionContract(null)}
+ onSaved={() => setAddVersionContract(null)}
+ />
+ )}
+
+ {/* Version history modal */}
+ {historyContract && (
+ setHistoryContract(null)}
+ />
+ )}
+
+ )
+}
diff --git a/frontend/src/energy/CostView.test.tsx b/frontend/src/energy/CostView.test.tsx
new file mode 100644
index 0000000..4a441b2
--- /dev/null
+++ b/frontend/src/energy/CostView.test.tsx
@@ -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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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),
+ )
+ })
+ })
+})
diff --git a/frontend/src/energy/CostView.tsx b/frontend/src/energy/CostView.tsx
new file mode 100644
index 0000000..acfd103
--- /dev/null
+++ b/frontend/src/energy/CostView.tsx
@@ -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 (
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// CostView — main component
+// ---------------------------------------------------------------------------
+
+type RangePreset = 'today' | 'month' | 'custom'
+
+export function CostView() {
+ const [rangePreset, setRangePreset] = useState('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 (
+
+ {/* Date range selector */}
+
+
+
+ Date range
+
+ setRangePreset(v as RangePreset)}
+ data={[
+ { label: 'Today', value: 'today' },
+ { label: 'This month', value: 'month' },
+ { label: 'Custom', value: 'custom' },
+ ]}
+ data-testid="cost-range-control"
+ />
+
+
+ {rangePreset === 'custom' && (
+
+ setCustomStartStr(e.currentTarget.value)}
+ data-testid="cost-custom-start"
+ />
+ setCustomEndStr(e.currentTarget.value)}
+ data-testid="cost-custom-end"
+ />
+
+ )}
+
+
+
+
+
+
+ {/* Summary cards */}
+ {summaryQuery.isLoading && (
+
+
+
+ )}
+
+ {summaryQuery.isError && (
+
+ Failed to load cost summary.
+
+ )}
+
+ {summaryQuery.data && (
+
+
+ Summary
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Cost chart */}
+ {costsQuery.isLoading && (
+
+
+
+ )}
+
+ {costsQuery.isError && (
+
+ Failed to load cost periods. Please refresh.
+
+ )}
+
+ {costsQuery.data && costsQuery.data.items.length === 0 && (
+
+ No cost data available for the selected period.
+
+ )}
+
+ {costsQuery.data && costsQuery.data.items.length > 0 && (
+ <>
+ {/* Area chart */}
+
+
+ Cost trends ({currency})
+
+
+ ({
+ 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 }}
+ >
+
+
+ v.toFixed(2)} />
+
+ [`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
+ }
+ />
+
+
+
+
+
+
+
+
+ {/* Detail table */}
+
+
+ Period detail
+
+
+
+
+
+ Time
+ Import kWh
+ Export kWh
+ Import cost
+ Export rev.
+ Net cost
+
+
+
+
+ {costsQuery.data.items.map((item, idx) => (
+
+
+
+ {new Date(item.period_start).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+ {(item.d1_kwh + item.d2_kwh).toFixed(3)}
+ {(item.r1_kwh + item.r2_kwh).toFixed(3)}
+ {item.import_cost.toFixed(4)}
+ {item.export_revenue.toFixed(4)}
+ {item.net_cost.toFixed(4)}
+
+ {item.degraded && (
+
+ degraded
+
+ )}
+
+
+ ))}
+
+
+
+
+ >
+ )}
+
+ {/* Recompute confirmation modal */}
+ {showRecomputeConfirm && (
+ setShowRecomputeConfirm(false)}
+ title="Recompute costs?"
+ size="sm"
+ data-testid="recompute-confirm-modal"
+ >
+
+
+ This will recompute all cost periods for the selected date range. Continue?
+
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/energy/TibberPrices.test.tsx b/frontend/src/energy/TibberPrices.test.tsx
new file mode 100644
index 0000000..da73443
--- /dev/null
+++ b/frontend/src/energy/TibberPrices.test.tsx
@@ -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()
+
+ 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()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('prices-no-contract')).toBeInTheDocument()
+ })
+ })
+
+ it('renders error state when fetch fails', async () => {
+ mockGet.mockRejectedValue(new Error('Network error'))
+
+ renderWithProviders()
+
+ 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()
+
+ 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()
+
+ 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')
+ })
+})
diff --git a/frontend/src/energy/TibberPrices.tsx b/frontend/src/energy/TibberPrices.tsx
new file mode 100644
index 0000000..8832606
--- /dev/null
+++ b/frontend/src/energy/TibberPrices.tsx
@@ -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 (
+
+
+ Price curve ({currency})
+
+
+
+
+
+ v.toFixed(3)}
+ />
+
+ [`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
+ }
+ />
+
+
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// 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 (
+
+
+ Fixed tariff ({currency}/kWh)
+
+
+
+
+ Tariff
+ Buy
+ Sell
+
+
+
+
+ Normal (peak)
+ {tariff.buy_normal.toFixed(4)}
+ {tariff.sell_normal.toFixed(4)}
+
+
+ Dal (off-peak)
+ {tariff.buy_dal.toFixed(4)}
+ {tariff.sell_dal.toFixed(4)}
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// TibberPrices — main component
+// ---------------------------------------------------------------------------
+
+export function TibberPrices() {
+ const start = getTodayStart()
+ const end = getTomorrowEnd()
+
+ const { data, isLoading, isError } = useEnergyPrices(start, end)
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (isError) {
+ return (
+
+ Failed to load energy prices. Please refresh.
+
+ )
+ }
+
+ if (!data) {
+ return (
+
+ No pricing data available.
+
+ )
+ }
+
+ // No active contract
+ if (!data.kind) {
+ return (
+
+
+ No active contract
+
+ Activate an energy contract on the Contracts tab to see pricing data.
+
+
+
+ )
+ }
+
+ const currency = data.currency
+
+ return (
+
+
+ Energy Prices
+
+ {data.kind}
+
+
+ {currency}
+
+
+
+ {data.kind === 'tibber' && data.points && data.points.length > 0 && (
+
+ )}
+
+ {data.kind === 'tibber' && (!data.points || data.points.length === 0) && (
+
+ No Tibber price points available for the selected time range. Check Tibber configuration.
+
+ )}
+
+ {data.kind === 'manual' && data.tariff && (
+
+ )}
+
+ {data.kind === 'manual' && !data.tariff && (
+
+ Manual tariff data not available.
+
+ )}
+
+ )
+}
diff --git a/frontend/src/energy/energy-hooks.test.tsx b/frontend/src/energy/energy-hooks.test.tsx
new file mode 100644
index 0000000..1af9f5b
--- /dev/null
+++ b/frontend/src/energy/energy-hooks.test.tsx
@@ -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 {children}
+ }
+ 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: {} },
+ })
+ })
+})
diff --git a/frontend/src/energy/hooks.ts b/frontend/src/energy/hooks.ts
index 752eca5..5dbf4a0 100644
--- a/frontend/src/energy/hooks.ts
+++ b/frontend/src/energy/hooks.ts
@@ -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)
// ---------------------------------------------------------------------------
diff --git a/frontend/src/pages/ConfigPage.tibber.test.tsx b/frontend/src/pages/ConfigPage.tibber.test.tsx
new file mode 100644
index 0000000..2a639e9
--- /dev/null
+++ b/frontend/src/pages/ConfigPage.tibber.test.tsx
@@ -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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+ })
+ })
+})
diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx
index 66dd5dd..b90f84d 100644
--- a/frontend/src/pages/ConfigPage.tsx
+++ b/frontend/src/pages/ConfigPage.tsx
@@ -49,6 +49,7 @@ import { TotpSettings } from './TotpSettings'
type ConfigField = components['schemas']['ConfigField']
type ConfigSection = components['schemas']['ConfigSection']
+type TibberTestPriceSchema = components['schemas']['TibberTestPriceSchema']
/** SMTP test result tri-state. */
type SmtpResult =
@@ -64,6 +65,13 @@ type MqttResult =
| { kind: 'failed'; message: string }
| null
+/** Tibber test result tri-state. */
+type TibberResult =
+ | { kind: 'success'; message: string; price?: TibberTestPriceSchema | null }
+ | { kind: 'config-error'; message: string }
+ | { kind: 'failed'; message: string }
+ | null
+
// ---------------------------------------------------------------------------
// Hook: load config
// ---------------------------------------------------------------------------
@@ -339,6 +347,85 @@ function MqttTestButton({ mqttResult, setMqttResult }: MqttTestButtonProps) {
)
}
+// ---------------------------------------------------------------------------
+// TibberTestButton — sends POST /api/energy/tibber/test and displays tri-state result
+// ---------------------------------------------------------------------------
+
+interface TibberTestButtonProps {
+ tibberResult: TibberResult
+ setTibberResult: (r: TibberResult) => void
+}
+
+function TibberTestButton({ tibberResult, setTibberResult }: TibberTestButtonProps) {
+ const [testing, setTesting] = useState(false)
+
+ async function handleTest() {
+ setTibberResult(null)
+ setTesting(true)
+ try {
+ const res = await apiClient.POST('/api/energy/tibber/test')
+ if (res.data) {
+ setTibberResult({
+ kind: 'success',
+ message: res.data.message,
+ price: res.data.price,
+ })
+ }
+ } catch (err) {
+ if (err instanceof ApiError) {
+ const body = err.body as { result?: string; message?: string } | null
+ const result = body?.result
+ const message = body?.message ?? 'Unknown error'
+ if (result === 'config-error') {
+ setTibberResult({ kind: 'config-error', message })
+ } else {
+ setTibberResult({ kind: 'failed', message })
+ }
+ } else {
+ setTibberResult({ kind: 'failed', message: 'Unexpected error during Tibber test.' })
+ }
+ } finally {
+ setTesting(false)
+ }
+ }
+
+ return (
+
+
+
+ {tibberResult?.kind === 'success' && (
+
+ Tibber connection successful. {tibberResult.message}
+ {tibberResult.price && (
+
+ Current price: {tibberResult.price.total} {tibberResult.price.currency}/kWh
+ {tibberResult.price.level ? ` (${tibberResult.price.level})` : ''}
+
+ )}
+
+ )}
+ {tibberResult?.kind === 'config-error' && (
+
+ Tibber configuration error — check your Tibber API token. {tibberResult.message}
+
+ )}
+ {tibberResult?.kind === 'failed' && (
+
+ Tibber test failed. {tibberResult.message}
+
+ )}
+
+ )
+}
+
// ---------------------------------------------------------------------------
// ConfigPage — main component
// ---------------------------------------------------------------------------
@@ -374,6 +461,9 @@ export function ConfigPage() {
// MQTT test tri-state
const [mqttResult, setMqttResult] = useState(null)
+ // Tibber test tri-state
+ const [tibberResult, setTibberResult] = useState(null)
+
function handleChange(envName: string, value: string) {
setLocalValues((prev) => ({ ...prev, [envName]: value }))
setSaveStatus(null)
@@ -432,6 +522,9 @@ export function ConfigPage() {
// Detect if there is an MQTT section (to show the MQTT test button).
const hasMqttSection = data.sections.some((s) => s.name.toLowerCase() === 'mqtt')
+ // Detect if there is a Tibber section (to show the Tibber test button).
+ const hasTibberSection = data.sections.some((s) => s.name.toLowerCase() === 'tibber')
+
// Default: open the first section so users immediately see content.
const defaultAccordionValue = data.sections[0]?.name ?? null
@@ -512,6 +605,9 @@ export function ConfigPage() {
{hasMqttSection && (
)}
+ {hasTibberSection && (
+
+ )}
diff --git a/frontend/src/pages/EnergyPage.tsx b/frontend/src/pages/EnergyPage.tsx
index 6dbe2c4..4268ac0 100644
--- a/frontend/src/pages/EnergyPage.tsx
+++ b/frontend/src/pages/EnergyPage.tsx
@@ -11,6 +11,8 @@
* - Latest readings card per device (T07): fetches /latest and /metrics; tolerates
* missing payload keys.
* - Trend charts per device (T07): EnergyCharts component (Recharts isolated inside).
+ *
+ * M6-T10: added Tabs layout with Devices / Contracts / Prices / Costs tabs.
*/
import { useState } from 'react'
@@ -34,10 +36,14 @@ import {
SimpleGrid,
Divider,
Switch,
+ Tabs,
} from '@mantine/core'
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
import { DeviceForm } from '../energy/DeviceForm'
import { EnergyCharts } from '../energy/EnergyCharts'
+import { ContractManager } from '../energy/ContractManager'
+import { TibberPrices } from '../energy/TibberPrices'
+import { CostView } from '../energy/CostView'
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
import { ApiError } from '../api/client'
import { formatMetricValue } from '../energy/format'
@@ -488,10 +494,10 @@ function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) {
}
// ---------------------------------------------------------------------------
-// EnergyPage — top-level
+// DevicesTab — self-contained devices management tab (extracted from original EnergyPage)
// ---------------------------------------------------------------------------
-export function EnergyPage() {
+function DevicesTab() {
const devicesQuery = useDevices()
const deleteMutation = useDeleteDevice()
@@ -534,10 +540,6 @@ export function EnergyPage() {
}
}
- // ---------------------------------------------------------------------------
- // Loading / error states
- // ---------------------------------------------------------------------------
-
if (devicesQuery.isLoading) {
return (
@@ -548,39 +550,31 @@ export function EnergyPage() {
if (devicesQuery.isError || !devicesQuery.data) {
return (
-
-
- Failed to load devices. Please refresh.
-
-
+
+ Failed to load devices. Please refresh.
+
)
}
const devices = devicesQuery.data.items
- // ---------------------------------------------------------------------------
- // Main render
- // ---------------------------------------------------------------------------
-
return (
-
-
-
- Energy — Devices
-
-
+
+
+ Energy — Devices
+
+
-
+
- {/* Latest readings cards + trend charts (T07) */}
-
-
+ {/* Latest readings cards + trend charts (T07) */}
+
{/* Create form */}
{showCreateForm && (
@@ -609,6 +603,49 @@ export function EnergyPage() {
has409Error={delete409}
/>
)}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// EnergyPage — top-level with Tabs
+// ---------------------------------------------------------------------------
+
+export function EnergyPage() {
+ return (
+
+
+
+
+ Devices
+
+
+ Contracts
+
+
+ Prices
+
+
+ Costs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}