M8-T19: add scope-aware energy contract and cost UI
This commit is contained in:
@@ -1023,7 +1023,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
|||||||
|
|
||||||
### M8-T19 — Scope-aware Contracts / Prices / Costs UI [structural]
|
### M8-T19 — Scope-aware Contracts / Prices / Costs UI [structural]
|
||||||
|
|
||||||
- **Status**: `todo`
|
- **Status**: `done`
|
||||||
- **Depends**: M8-T18
|
- **Depends**: M8-T18
|
||||||
- **Context**: 完成 electricity/thermal 双 scope 的合同录入、价格快照和成本审计体验。
|
- **Context**: 完成 electricity/thermal 双 scope 的合同录入、价格快照和成本审计体验。
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ const PROFILES_RESPONSE = {
|
|||||||
heffingskorting: { unit: 'EUR/year' },
|
heffingskorting: { unit: 'EUR/year' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'district_heating', label: 'District heating',
|
||||||
|
variable: { heating: { unit: 'EUR/GJ', default: 0 } },
|
||||||
|
standing: { delivery_set: { unit: 'EUR/year', default: 0 } },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +84,21 @@ const CREATED_CONTRACT = {
|
|||||||
versions: [],
|
versions: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const D11_PROFILE_RESPONSE = {
|
||||||
|
profiles: [{
|
||||||
|
kind: 'district_heating', label: 'District heating',
|
||||||
|
variable: {
|
||||||
|
heating: { unit: 'EUR/GJ' }, hot_water_heating: { unit: 'EUR/m³' },
|
||||||
|
hot_water: { unit: 'EUR/m³' }, hot_water_tax: { unit: 'EUR/m³' },
|
||||||
|
},
|
||||||
|
standing: {
|
||||||
|
heating_network: { unit: 'EUR/year' }, metering: { unit: 'EUR/year' },
|
||||||
|
delivery_set: { unit: 'EUR/year' }, hot_water_network: { unit: 'EUR/year' },
|
||||||
|
other: { unit: 'EUR/year' },
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Import component
|
// Import component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -174,6 +194,49 @@ describe('ContractForm', () => {
|
|||||||
}, { timeout: 3000 })
|
}, { timeout: 3000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('limits a thermal create form to its compatible profile and posts its scope', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockResolvedValue({ data: PROFILES_RESPONSE })
|
||||||
|
mockPost.mockResolvedValue({ data: CREATED_CONTRACT })
|
||||||
|
renderWithProviders(<ContractForm scope="thermal" defaultKind="district_heating" onClose={vi.fn()} onSaved={vi.fn()} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('contract-field-variable.heating')).toBeInTheDocument())
|
||||||
|
await user.type(screen.getByTestId('contract-name'), 'Heat')
|
||||||
|
await user.click(screen.getByTestId('contract-form-submit'))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/contracts', expect.objectContaining({ body: expect.objectContaining({ kind: 'district_heating', scope: 'thermal' }) })))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('posts all nine D11 values as unrounded Decimal strings, including zero standing fees', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockResolvedValue({ data: D11_PROFILE_RESPONSE })
|
||||||
|
mockPost.mockResolvedValue({ data: CREATED_CONTRACT })
|
||||||
|
renderWithProviders(<ContractForm scope="thermal" defaultKind="district_heating" onClose={vi.fn()} onSaved={vi.fn()} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('contract-field-variable.heating')).toBeInTheDocument())
|
||||||
|
await user.type(screen.getByTestId('contract-name'), 'Precise heat')
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
'variable.heating': '20.123456789123456789', 'variable.hot_water_heating': '8.200000000000000001',
|
||||||
|
'variable.hot_water': '1.234567890123456789', 'variable.hot_water_tax': '0.456789012345678901',
|
||||||
|
'standing.heating_network': '0', 'standing.metering': '0', 'standing.delivery_set': '0',
|
||||||
|
'standing.hot_water_network': '0', 'standing.other': '0',
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
const input = screen.getByTestId(`contract-field-${key}`)
|
||||||
|
await user.clear(input)
|
||||||
|
await user.type(input, value)
|
||||||
|
}
|
||||||
|
await user.click(screen.getByTestId('contract-form-submit'))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalled())
|
||||||
|
const body = mockPost.mock.calls[0][1].body
|
||||||
|
expect(body).toMatchObject({ scope: 'thermal', values: {
|
||||||
|
variable: {
|
||||||
|
heating: values['variable.heating'], hot_water_heating: values['variable.hot_water_heating'],
|
||||||
|
hot_water: values['variable.hot_water'], hot_water_tax: values['variable.hot_water_tax'],
|
||||||
|
},
|
||||||
|
standing: {
|
||||||
|
heating_network: '0', metering: '0', delivery_set: '0', hot_water_network: '0', other: '0',
|
||||||
|
},
|
||||||
|
} })
|
||||||
|
})
|
||||||
|
|
||||||
it('calls POST /api/energy/contracts/{id}/versions in add-version mode', async () => {
|
it('calls POST /api/energy/contracts/{id}/versions in add-version mode', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export interface ContractFormProps {
|
|||||||
contractId?: number
|
contractId?: number
|
||||||
/** Existing contract kind (for add-version mode or edit). */
|
/** Existing contract kind (for add-version mode or edit). */
|
||||||
defaultKind?: string
|
defaultKind?: string
|
||||||
|
/** The list/create scope currently selected by the parent. */
|
||||||
|
scope?: 'electricity' | 'thermal'
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
}
|
}
|
||||||
@@ -64,7 +66,7 @@ interface LeafField {
|
|||||||
/** Dot-separated path within the section, e.g. "buy.normal" */
|
/** Dot-separated path within the section, e.g. "buy.normal" */
|
||||||
fieldPath: string
|
fieldPath: string
|
||||||
unit: string
|
unit: string
|
||||||
defaultValue?: number
|
defaultValue?: number | string
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractLeafFields(obj: Record<string, unknown>, prefix = ''): LeafField[] {
|
function extractLeafFields(obj: Record<string, unknown>, prefix = ''): LeafField[] {
|
||||||
@@ -77,7 +79,9 @@ function extractLeafFields(obj: Record<string, unknown>, prefix = ''): LeafField
|
|||||||
fields.push({
|
fields.push({
|
||||||
fieldPath: path,
|
fieldPath: path,
|
||||||
unit: val.unit,
|
unit: val.unit,
|
||||||
defaultValue: typeof val.default === 'number' ? val.default : undefined,
|
defaultValue: typeof val.default === 'number' || typeof val.default === 'string'
|
||||||
|
? val.default
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
fields.push(...extractLeafFields(val as Record<string, unknown>, path))
|
fields.push(...extractLeafFields(val as Record<string, unknown>, path))
|
||||||
@@ -93,6 +97,7 @@ function extractLeafFields(obj: Record<string, unknown>, prefix = ''): LeafField
|
|||||||
function buildNestedValues(
|
function buildNestedValues(
|
||||||
sectionFields: Record<string, LeafField[]>,
|
sectionFields: Record<string, LeafField[]>,
|
||||||
fieldValues: Record<string, number | string>,
|
fieldValues: Record<string, number | string>,
|
||||||
|
decimalStrings: boolean,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const result: Record<string, unknown> = {}
|
const result: Record<string, unknown> = {}
|
||||||
|
|
||||||
@@ -100,7 +105,6 @@ function buildNestedValues(
|
|||||||
const sectionObj: Record<string, unknown> = {}
|
const sectionObj: Record<string, unknown> = {}
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
const raw = fieldValues[`${section}.${field.fieldPath}`]
|
const raw = fieldValues[`${section}.${field.fieldPath}`]
|
||||||
const numVal = typeof raw === 'number' ? raw : parseFloat(String(raw))
|
|
||||||
// Set nested path
|
// Set nested path
|
||||||
const parts = field.fieldPath.split('.')
|
const parts = field.fieldPath.split('.')
|
||||||
let current = sectionObj
|
let current = sectionObj
|
||||||
@@ -108,7 +112,10 @@ function buildNestedValues(
|
|||||||
if (!(parts[i] in current)) current[parts[i]] = {}
|
if (!(parts[i] in current)) current[parts[i]] = {}
|
||||||
current = current[parts[i]] as Record<string, unknown>
|
current = current[parts[i]] as Record<string, unknown>
|
||||||
}
|
}
|
||||||
current[parts[parts.length - 1]] = isNaN(numVal) ? 0 : numVal
|
// Values are Decimal JSON strings. Do not round-trip user money through JS Number.
|
||||||
|
current[parts[parts.length - 1]] = decimalStrings
|
||||||
|
? (raw === undefined || raw === '' ? '0' : String(raw))
|
||||||
|
: (Number.isFinite(Number(raw)) ? Number(raw) : 0)
|
||||||
}
|
}
|
||||||
result[section] = sectionObj
|
result[section] = sectionObj
|
||||||
}
|
}
|
||||||
@@ -130,7 +137,7 @@ function formatLabel(path: string): string {
|
|||||||
// Component
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function ContractForm({ contractId, defaultKind, onClose, onSaved }: ContractFormProps) {
|
export function ContractForm({ contractId, defaultKind, scope, onClose, onSaved }: ContractFormProps) {
|
||||||
const isAddVersion = contractId != null
|
const isAddVersion = contractId != null
|
||||||
|
|
||||||
// Profiles query
|
// Profiles query
|
||||||
@@ -161,7 +168,9 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
|
|||||||
const effectiveKind = isAddVersion ? (defaultKind ?? null) : selectedKind
|
const effectiveKind = isAddVersion ? (defaultKind ?? null) : selectedKind
|
||||||
|
|
||||||
// Build profile options from API response
|
// Build profile options from API response
|
||||||
const profiles = profilesQuery.data?.profiles ?? []
|
const profiles = (profilesQuery.data?.profiles ?? []).filter((p: Record<string, unknown>) =>
|
||||||
|
scope === 'thermal' ? p.kind === 'district_heating' : p.kind !== 'district_heating',
|
||||||
|
)
|
||||||
const profileOptions = profiles.map((p: Record<string, unknown>) => ({
|
const profileOptions = profiles.map((p: Record<string, unknown>) => ({
|
||||||
value: p.kind as string,
|
value: p.kind as string,
|
||||||
label: (p.label as string | undefined) ?? (p.kind as string),
|
label: (p.label as string | undefined) ?? (p.kind as string),
|
||||||
@@ -219,8 +228,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
|
|||||||
cursor = (cursor as Record<string, unknown>)[part]
|
cursor = (cursor as Record<string, unknown>)[part]
|
||||||
}
|
}
|
||||||
if (cursor != null && (typeof cursor === 'number' || typeof cursor === 'string')) {
|
if (cursor != null && (typeof cursor === 'number' || typeof cursor === 'string')) {
|
||||||
const numVal = typeof cursor === 'number' ? cursor : parseFloat(String(cursor))
|
seeded[`${section}.${leaf.fieldPath}`] = effectiveKind === 'district_heating' ? String(cursor) : Number(cursor)
|
||||||
seeded[`${section}.${leaf.fieldPath}`] = isNaN(numVal) ? 0 : numVal
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,6 +239,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
|
|||||||
contractDetailQuery.isError,
|
contractDetailQuery.isError,
|
||||||
contractDetailQuery.data,
|
contractDetailQuery.data,
|
||||||
sectionFields,
|
sectionFields,
|
||||||
|
effectiveKind,
|
||||||
])
|
])
|
||||||
|
|
||||||
// The effective field values: user edits override prefill; prefill is the base.
|
// The effective field values: user edits override prefill; prefill is the base.
|
||||||
@@ -285,7 +294,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = buildNestedValues(sectionFields, fieldValues)
|
const values = buildNestedValues(sectionFields, fieldValues, effectiveKind === 'district_heating')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert local date string to a naive local-midnight datetime string (no Z).
|
// Convert local date string to a naive local-midnight datetime string (no Z).
|
||||||
@@ -307,6 +316,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
|
|||||||
const body = {
|
const body = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
kind: effectiveKind,
|
kind: effectiveKind,
|
||||||
|
...(scope ? { scope } : {}),
|
||||||
currency,
|
currency,
|
||||||
values,
|
values,
|
||||||
...(effectiveFromISO ? { effective_from: effectiveFromISO } : {}),
|
...(effectiveFromISO ? { effective_from: effectiveFromISO } : {}),
|
||||||
@@ -409,19 +419,21 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
|
|||||||
</Title>
|
</Title>
|
||||||
{fields.map((field) => {
|
{fields.map((field) => {
|
||||||
const key = `${section}.${field.fieldPath}`
|
const key = `${section}.${field.fieldPath}`
|
||||||
const raw = fieldValues[key]
|
return effectiveKind === 'district_heating' ? (
|
||||||
const numVal = typeof raw === 'number' ? raw : parseFloat(String(raw))
|
<TextInput
|
||||||
return (
|
|
||||||
<NumberInput
|
|
||||||
key={key}
|
key={key}
|
||||||
label={formatLabel(field.fieldPath)}
|
label={formatLabel(field.fieldPath)}
|
||||||
description={field.unit}
|
description={field.unit}
|
||||||
value={isNaN(numVal) ? 0 : numVal}
|
inputMode="decimal"
|
||||||
onChange={(val) => handleFieldChange(key, val)}
|
value={String(fieldValues[key] ?? '0')}
|
||||||
decimalScale={6}
|
onChange={(event) => handleFieldChange(key, event.currentTarget.value)}
|
||||||
step={0.001}
|
|
||||||
data-testid={`contract-field-${key}`}
|
data-testid={`contract-field-${key}`}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<NumberInput key={key} label={formatLabel(field.fieldPath)} description={field.unit}
|
||||||
|
value={typeof fieldValues[key] === 'number' ? fieldValues[key] : Number(fieldValues[key] ?? 0)}
|
||||||
|
onChange={(value) => handleFieldChange(key, value)} decimalScale={6} step={0.001}
|
||||||
|
data-testid={`contract-field-${key}`} />
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ const INACTIVE_CONTRACT = {
|
|||||||
updated_at: '2026-06-02T00:00:00Z',
|
updated_at: '2026-06-02T00:00:00Z',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACTIVE_THERMAL_CONTRACT = {
|
||||||
|
id: 3, name: 'Active Heat Contract', kind: 'district_heating', active: true, currency: 'EUR',
|
||||||
|
created_at: '2026-06-03T00:00:00Z', updated_at: '2026-06-03T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
const PROFILES_RESPONSE = {
|
const PROFILES_RESPONSE = {
|
||||||
profiles: [
|
profiles: [
|
||||||
{
|
{
|
||||||
@@ -202,4 +207,39 @@ describe('ContractManager', () => {
|
|||||||
expect(screen.getByTestId('contract-form-modal')).toBeInTheDocument()
|
expect(screen.getByTestId('contract-form-modal')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the selector available while thermal data loads and requests each scope separately', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockImplementation((path: string, options?: { params?: { query?: { scope?: string } } }) => {
|
||||||
|
if (path === '/api/energy/contracts' && options?.params?.query?.scope === 'thermal') return new Promise(() => {})
|
||||||
|
if (path === '/api/energy/contracts') return Promise.resolve({ data: { items: [ACTIVE_CONTRACT], total: 1 } })
|
||||||
|
return Promise.resolve({ data: PROFILES_RESPONSE })
|
||||||
|
})
|
||||||
|
renderWithProviders(<ContractManager />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('contracts-scope-selector')).toBeInTheDocument())
|
||||||
|
await user.click(screen.getByText('Thermal'))
|
||||||
|
expect(screen.getByTestId('contracts-loading')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('contracts-scope-selector')).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByText('Electricity'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('contracts-table')).toBeInTheDocument())
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/contracts', { params: { query: { scope: 'thermal' } } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps simultaneous active electricity and thermal contracts isolated across repeated switches', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockImplementation((_path: string, options?: { params?: { query?: { scope?: string } } }) =>
|
||||||
|
Promise.resolve({ data: { items: options?.params?.query?.scope === 'thermal'
|
||||||
|
? [ACTIVE_THERMAL_CONTRACT] : [ACTIVE_CONTRACT], total: 1 } }),
|
||||||
|
)
|
||||||
|
renderWithProviders(<ContractManager />)
|
||||||
|
await waitFor(() => expect(screen.getByText('My Active Contract')).toBeInTheDocument())
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
await user.click(screen.getByText('Thermal'))
|
||||||
|
await waitFor(() => expect(screen.getByText('Active Heat Contract')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByText('My Active Contract')).not.toBeInTheDocument()
|
||||||
|
await user.click(screen.getByText('Electricity'))
|
||||||
|
await waitFor(() => expect(screen.getByText('My Active Contract')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByText('Active Heat Contract')).not.toBeInTheDocument()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
Button,
|
Button,
|
||||||
@@ -24,9 +25,9 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Accordion,
|
Accordion,
|
||||||
Code,
|
Code,
|
||||||
|
SegmentedControl,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import {
|
import {
|
||||||
useContracts,
|
|
||||||
useUpdateContract,
|
useUpdateContract,
|
||||||
type ContractResponse,
|
type ContractResponse,
|
||||||
type ContractDetailResponse,
|
type ContractDetailResponse,
|
||||||
@@ -244,7 +245,14 @@ function ContractTable({
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function ContractManager() {
|
export function ContractManager() {
|
||||||
const contractsQuery = useContracts()
|
const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity')
|
||||||
|
const contractsQuery = useQuery({
|
||||||
|
queryKey: ['energy-contracts', scope],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/energy/contracts', { params: { query: { scope } } })
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
const updateMutation = useUpdateContract()
|
const updateMutation = useUpdateContract()
|
||||||
|
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
@@ -252,6 +260,13 @@ export function ContractManager() {
|
|||||||
const [historyContract, setHistoryContract] = useState<ContractResponse | null>(null)
|
const [historyContract, setHistoryContract] = useState<ContractResponse | null>(null)
|
||||||
const [activatingId, setActivatingId] = useState<number | null>(null)
|
const [activatingId, setActivatingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
function handleScopeChange(nextScope: 'electricity' | 'thermal') {
|
||||||
|
setScope(nextScope)
|
||||||
|
setShowCreateForm(false)
|
||||||
|
setAddVersionContract(null)
|
||||||
|
setHistoryContract(null)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleActivate(id: number) {
|
async function handleActivate(id: number) {
|
||||||
setActivatingId(id)
|
setActivatingId(id)
|
||||||
try {
|
try {
|
||||||
@@ -265,44 +280,40 @@ export function ContractManager() {
|
|||||||
// Render states
|
// Render states
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
if (contractsQuery.isLoading) {
|
const contracts = contractsQuery.data?.items ?? []
|
||||||
return (
|
|
||||||
<Center py="xl" data-testid="contracts-loading">
|
|
||||||
<Loader />
|
|
||||||
</Center>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contractsQuery.isError || !contractsQuery.data) {
|
|
||||||
return (
|
|
||||||
<Alert color="red" data-testid="contracts-load-error">
|
|
||||||
Failed to load contracts. Please refresh.
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const contracts = contractsQuery.data.items
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg" data-testid="contract-manager">
|
<Stack gap="lg" data-testid="contract-manager">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
|
<Group gap="sm">
|
||||||
<Text fw={500}>Energy Contracts</Text>
|
<Text fw={500}>Energy Contracts</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
value={scope}
|
||||||
|
onChange={(value) => handleScopeChange(value as 'electricity' | 'thermal')}
|
||||||
|
data={[{ label: 'Electricity', value: 'electricity' }, { label: 'Thermal', value: 'thermal' }]}
|
||||||
|
data-testid="contracts-scope-selector"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
<Button onClick={() => setShowCreateForm(true)} data-testid="contract-new-button">
|
<Button onClick={() => setShowCreateForm(true)} data-testid="contract-new-button">
|
||||||
New Contract
|
New Contract
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ContractTable
|
{contractsQuery.isLoading && <Center py="xl" data-testid="contracts-loading"><Loader /></Center>}
|
||||||
|
{contractsQuery.isError && <Alert color="red" data-testid="contracts-load-error">Failed to load contracts. Please refresh.</Alert>}
|
||||||
|
{!contractsQuery.isLoading && !contractsQuery.isError && <ContractTable
|
||||||
contracts={contracts}
|
contracts={contracts}
|
||||||
onActivate={handleActivate}
|
onActivate={handleActivate}
|
||||||
onAddVersion={(c) => setAddVersionContract(c)}
|
onAddVersion={(c) => setAddVersionContract(c)}
|
||||||
onViewHistory={(c) => setHistoryContract(c)}
|
onViewHistory={(c) => setHistoryContract(c)}
|
||||||
activatingId={activatingId}
|
activatingId={activatingId}
|
||||||
/>
|
/>}
|
||||||
|
|
||||||
{/* Create new contract */}
|
{/* Create new contract */}
|
||||||
{showCreateForm && (
|
{showCreateForm && (
|
||||||
<ContractForm
|
<ContractForm
|
||||||
|
defaultKind={scope === 'thermal' ? 'district_heating' : undefined}
|
||||||
|
scope={scope}
|
||||||
onClose={() => setShowCreateForm(false)}
|
onClose={() => setShowCreateForm(false)}
|
||||||
onSaved={() => setShowCreateForm(false)}
|
onSaved={() => setShowCreateForm(false)}
|
||||||
/>
|
/>
|
||||||
@@ -313,6 +324,7 @@ export function ContractManager() {
|
|||||||
<ContractForm
|
<ContractForm
|
||||||
contractId={addVersionContract.id}
|
contractId={addVersionContract.id}
|
||||||
defaultKind={addVersionContract.kind}
|
defaultKind={addVersionContract.kind}
|
||||||
|
scope={scope}
|
||||||
onClose={() => setAddVersionContract(null)}
|
onClose={() => setAddVersionContract(null)}
|
||||||
onSaved={() => setAddVersionContract(null)}
|
onSaved={() => setAddVersionContract(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -73,6 +73,48 @@ const SUMMARY = {
|
|||||||
total_payable: 12.5,
|
total_payable: 12.5,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THERMAL_SUMMARY = {
|
||||||
|
currency: 'EUR', heating: '1.10', hot_water_heating: '2.20', hot_water: '3.30',
|
||||||
|
hot_water_tax: '0.40', variable_subtotal: '7.00', fixed_subtotal: '0.50', all_in: '7.50',
|
||||||
|
period_count: 4, degraded_count: 2,
|
||||||
|
fixed_breakdown: { heating_network: '0.1', metering: '0.1', delivery_set: '0', hot_water_network: '0.1', other: '0.2' },
|
||||||
|
}
|
||||||
|
const THERMAL_VALUES = {
|
||||||
|
variable: {
|
||||||
|
heating: '20.123456789123456789', hot_water_heating: '8.200000000000000001',
|
||||||
|
hot_water: '1.234567890123456789', hot_water_tax: '0.456789012345678901',
|
||||||
|
},
|
||||||
|
standing: {
|
||||||
|
heating_network: '100.000000000000000001', metering: '0', delivery_set: '20.2',
|
||||||
|
hot_water_network: '30.3', other: '40.4',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const THERMAL_ROW = {
|
||||||
|
commodity: 'heating', period_start: '2026-06-22T10:00:00Z', period_end: '2026-06-22T10:15:00Z',
|
||||||
|
meter_id: 1, source_binding_id: 2, contract_version_id: 99, quantity: '1.2', cost: '0.123456789', currency: 'EUR',
|
||||||
|
cost_breakdown: { heating: '0.123456789' }, pricing_snapshot: THERMAL_VALUES,
|
||||||
|
quality: 'unverifiable', degraded: false, degraded_reason: null,
|
||||||
|
}
|
||||||
|
const THERMAL_ROW_OTHER_VERSION = {
|
||||||
|
...THERMAL_ROW, commodity: 'hot_water', period_start: '2026-06-22T10:15:00Z', period_end: '2026-06-22T10:30:00Z',
|
||||||
|
contract_version_id: 100, quantity: '2.3', cost: '4.339506172839506170',
|
||||||
|
cost_breakdown: { hot_water_heating: '1.2', hot_water: '2.8', hot_water_tax: '0.339506172839506170' },
|
||||||
|
pricing_snapshot: { ...THERMAL_VALUES, variable: { ...THERMAL_VALUES.variable, hot_water: '1.234567890123456789' } },
|
||||||
|
}
|
||||||
|
const THERMAL_DEGRADED_MISSING_CONTRACT = {
|
||||||
|
commodity: 'heating', period_start: '2026-06-22T10:30:00Z', period_end: '2026-06-22T10:45:00Z',
|
||||||
|
meter_id: 1, source_binding_id: 2, contract_version_id: null, quantity: '0', cost: '0', currency: 'EUR',
|
||||||
|
cost_breakdown: {}, pricing_snapshot: {}, quality: 'invalid', degraded: true, degraded_reason: 'missing_contract',
|
||||||
|
}
|
||||||
|
const THERMAL_DEGRADED_CROSS_EPOCH = {
|
||||||
|
commodity: 'hot_water', period_start: '2026-06-22T10:45:00Z', period_end: '2026-06-22T11:00:00Z',
|
||||||
|
meter_id: 2, source_binding_id: null, contract_version_id: null, quantity: '0', cost: '0', currency: 'EUR',
|
||||||
|
cost_breakdown: {}, pricing_snapshot: {}, quality: 'invalid', degraded: true, degraded_reason: 'cross_meter_epoch',
|
||||||
|
}
|
||||||
|
const ACTIVE_HEATING_METER = { id: 10, commodity: 'heating', ended_at: null }
|
||||||
|
const ACTIVE_HOT_WATER_METER = { id: 11, commodity: 'hot_water', ended_at: null }
|
||||||
|
const ENDED_HEATING_METER = { id: 9, commodity: 'heating', ended_at: '2026-06-01T00:00:00Z' }
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Import component
|
// Import component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -220,4 +262,126 @@ describe('CostView', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('audits thermal rows and recomputes only a closed UTC quarter through the typed client', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meter-costs') return Promise.resolve({ data: { items: [THERMAL_ROW, THERMAL_ROW_OTHER_VERSION, THERMAL_DEGRADED_MISSING_CONTRACT, THERMAL_DEGRADED_CROSS_EPOCH], total: 4 } })
|
||||||
|
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
|
||||||
|
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: { processed: 1, normal: 0, degraded: 1 } })
|
||||||
|
renderWithProviders(<CostView />)
|
||||||
|
await user.click(screen.getByTestId('costs-scope-selector'))
|
||||||
|
await user.click(screen.getByText('Thermal'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('thermal-cost-summary')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('thermal-period-count')).toHaveTextContent('4 periods; 2 degraded')
|
||||||
|
expect(screen.getByTestId('thermal-summary-degraded')).toHaveTextContent('Expand a row to see its recorded reason')
|
||||||
|
expect(screen.queryByTestId('thermal-degraded-0')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('thermal-degraded-1')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('thermal-degraded-2')).toHaveTextContent('missing_contract')
|
||||||
|
expect(screen.getByTestId('thermal-degraded-3')).toHaveTextContent('cross_meter_epoch')
|
||||||
|
await user.click(screen.getByTestId('thermal-cost-expand-0'))
|
||||||
|
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('Contract version: 99')
|
||||||
|
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('20.123456789123456789')
|
||||||
|
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('heating_network')
|
||||||
|
await user.click(screen.getByTestId('thermal-cost-expand-1'))
|
||||||
|
expect(screen.getByTestId('thermal-cost-audit-1')).toHaveTextContent('Contract version: 100')
|
||||||
|
expect(screen.getByTestId('thermal-cost-audit-1')).toHaveTextContent('1.234567890123456789')
|
||||||
|
expect(screen.getByTestId('thermal-cost-audit-1')).toHaveTextContent('hot_water_tax')
|
||||||
|
await user.click(screen.getByTestId('thermal-cost-expand-2'))
|
||||||
|
expect(screen.getByTestId('thermal-cost-audit-2')).toHaveTextContent('Contract version: none')
|
||||||
|
expect(screen.getByTestId('thermal-cost-audit-2')).toHaveTextContent('Pricing snapshot: {}')
|
||||||
|
expect(screen.getByTestId('thermal-fixed-breakdown')).toHaveTextContent('summary only')
|
||||||
|
expect(screen.getAllByText(/Fixed subtotal|All-in total/)).toHaveLength(2)
|
||||||
|
expect(screen.getByTestId('thermal-costs-table')).not.toHaveTextContent('Fixed')
|
||||||
|
await user.click(screen.getByTestId('thermal-recompute-button'))
|
||||||
|
expect(screen.getByTestId('thermal-recompute-confirm-modal')).toHaveTextContent('closed 15-minute')
|
||||||
|
await user.click(screen.getByTestId('thermal-recompute-confirm'))
|
||||||
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meter-costs/recompute', expect.any(Object)))
|
||||||
|
const options = mockPost.mock.calls.find(([path]) => path === '/api/energy/meter-costs/recompute')?.[1] as { params: { query: { end: string } } }
|
||||||
|
expect(new Date(options.params.query.end).getTime()).toBeLessThanOrEqual(Date.now())
|
||||||
|
expect(new Date(options.params.query.end).getUTCMinutes() % 15).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the thermal confirmation cancellable and surfaces a 422 recompute failure', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meter-costs') return Promise.resolve({ data: { items: [THERMAL_ROW], total: 1 } })
|
||||||
|
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
|
||||||
|
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
|
||||||
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||||
|
})
|
||||||
|
mockPost.mockRejectedValue(new Error('422'))
|
||||||
|
renderWithProviders(<CostView />)
|
||||||
|
await user.click(screen.getByTestId('costs-scope-selector')); await user.click(screen.getByText('Thermal'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('thermal-recompute-button')).toBeInTheDocument())
|
||||||
|
await user.click(screen.getByTestId('thermal-recompute-button')); await user.click(screen.getByTestId('thermal-recompute-cancel'))
|
||||||
|
expect(screen.queryByTestId('thermal-recompute-confirm-modal')).not.toBeInTheDocument()
|
||||||
|
await user.click(screen.getByTestId('thermal-recompute-button')); await user.click(screen.getByTestId('thermal-recompute-confirm'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('thermal-recompute-error')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['only heating', [ACTIVE_HEATING_METER], 'hot-water meter is not configured', 'Not configured'],
|
||||||
|
['only hot water', [ACTIVE_HOT_WATER_METER], 'heating meter is not configured', 'Not configured'],
|
||||||
|
['both current meters', [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'],
|
||||||
|
['a replaced heating meter plus its current epoch', [ENDED_HEATING_METER, ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'],
|
||||||
|
])('uses active meter epochs for %s without treating zero amounts as missing', async (_name, meterItems, missingText, heatingValue) => {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: meterItems, total: meterItems.length } })
|
||||||
|
if (path === '/api/energy/meter-costs') return Promise.resolve({ data: { items: [THERMAL_ROW], total: 1 } })
|
||||||
|
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: { ...THERMAL_SUMMARY, heating: heatingValue === 'Not configured' ? '0' : THERMAL_SUMMARY.heating } })
|
||||||
|
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
|
||||||
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||||
|
})
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderWithProviders(<CostView />)
|
||||||
|
await user.click(screen.getByTestId('costs-scope-selector'))
|
||||||
|
await user.click(screen.getByText('Thermal'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('thermal-cost-summary')).toBeInTheDocument())
|
||||||
|
if (missingText) {
|
||||||
|
expect(screen.getByTestId('thermal-missing-current-meter')).toHaveTextContent(missingText)
|
||||||
|
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Not configured')
|
||||||
|
} else {
|
||||||
|
expect(screen.queryByTestId('thermal-missing-current-meter')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent(heatingValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('paginates the complete thermal ledger and resets offset when its range or scope changes', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const lastRow = { ...THERMAL_ROW, period_start: '2026-06-22T12:00:00Z', quantity: '501' }
|
||||||
|
mockGet.mockImplementation((path: string, options?: { params?: { query?: { offset?: number } } }) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], total: 2 } })
|
||||||
|
if (path === '/api/energy/meter-costs') {
|
||||||
|
const offset = options?.params?.query?.offset ?? 0
|
||||||
|
return Promise.resolve({ data: offset === 0 ? { items: Array.from({ length: 500 }, () => THERMAL_ROW), total: 501 } : { items: [lastRow], total: 501 } })
|
||||||
|
}
|
||||||
|
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
|
||||||
|
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
|
||||||
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||||
|
})
|
||||||
|
renderWithProviders(<CostView />)
|
||||||
|
await user.click(screen.getByTestId('costs-scope-selector'))
|
||||||
|
await user.click(screen.getByText('Thermal'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 1-500 of 501'))
|
||||||
|
expect(screen.getByTestId('thermal-ledger-prev')).toBeDisabled()
|
||||||
|
expect(screen.getByTestId('thermal-ledger-next')).toBeEnabled()
|
||||||
|
await user.click(screen.getByTestId('thermal-ledger-next'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 501-501 of 501'))
|
||||||
|
expect(screen.getByTestId('thermal-ledger-prev')).toBeEnabled()
|
||||||
|
expect(screen.getByTestId('thermal-ledger-next')).toBeDisabled()
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/meter-costs', expect.objectContaining({ params: { query: expect.objectContaining({ scope: 'thermal', offset: 500, limit: 500 }) } }))
|
||||||
|
await user.click(screen.getByTestId('thermal-cost-range-control'))
|
||||||
|
await user.click(screen.getByText('This month'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 1-500 of 501'))
|
||||||
|
await user.click(screen.getByTestId('costs-scope-selector'))
|
||||||
|
await user.click(screen.getByText('Electricity'))
|
||||||
|
await user.click(screen.getByTestId('costs-scope-selector'))
|
||||||
|
await user.click(screen.getByText('Thermal'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 1-500 of 501'))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { useEnergyCosts, useEnergyCostSummary, useRecomputeCosts } from './hooks'
|
import { useEnergyCosts, useEnergyCostSummary, useRecomputeCosts } from './hooks'
|
||||||
import { formatLocalTime } from '../utils/datetime'
|
import { formatLocalTime } from '../utils/datetime'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Cost limit — prevent accidental full-table pulls
|
// Cost limit — prevent accidental full-table pulls
|
||||||
@@ -109,6 +111,16 @@ function SummaryCard({ label, value, sub, testId }: SummaryCardProps) {
|
|||||||
type RangePreset = 'today' | 'month' | 'custom'
|
type RangePreset = 'today' | 'month' | 'custom'
|
||||||
|
|
||||||
export function CostView() {
|
export function CostView() {
|
||||||
|
const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity')
|
||||||
|
if (scope === 'thermal') return <ThermalCostView onScopeChange={setScope} />
|
||||||
|
return <ElectricityCostView onScopeChange={setScope} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeSelector({ scope, onScopeChange }: { scope: 'electricity' | 'thermal'; onScopeChange: (scope: 'electricity' | 'thermal') => void }) {
|
||||||
|
return <SegmentedControl value={scope} onChange={(value) => onScopeChange(value as 'electricity' | 'thermal')} data={[{ label: 'Electricity', value: 'electricity' }, { label: 'Thermal', value: 'thermal' }]} data-testid="costs-scope-selector" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ElectricityCostView({ onScopeChange }: { onScopeChange: (scope: 'electricity' | 'thermal') => void }) {
|
||||||
const [rangePreset, setRangePreset] = useState<RangePreset>('today')
|
const [rangePreset, setRangePreset] = useState<RangePreset>('today')
|
||||||
// Date strings in YYYY-MM-DD format for custom range
|
// Date strings in YYYY-MM-DD format for custom range
|
||||||
const [customStartStr, setCustomStartStr] = useState('')
|
const [customStartStr, setCustomStartStr] = useState('')
|
||||||
@@ -144,6 +156,7 @@ export function CostView() {
|
|||||||
<Stack gap="lg" data-testid="cost-view">
|
<Stack gap="lg" data-testid="cost-view">
|
||||||
{/* Date range selector */}
|
{/* Date range selector */}
|
||||||
<Group align="flex-start" gap="md" wrap="wrap">
|
<Group align="flex-start" gap="md" wrap="wrap">
|
||||||
|
<ScopeSelector scope="electricity" onScopeChange={onScopeChange} />
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
Date range
|
Date range
|
||||||
@@ -410,3 +423,76 @@ export function CostView() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThermalCostView({ onScopeChange }: { onScopeChange: (scope: 'electricity' | 'thermal') => void }) {
|
||||||
|
const [rangePreset, setRangePreset] = useState<RangePreset>('today')
|
||||||
|
const [customStartStr, setCustomStartStr] = useState('')
|
||||||
|
const [customEndStr, setCustomEndStr] = useState('')
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
|
const [recomputeError, setRecomputeError] = useState<string | null>(null)
|
||||||
|
const [recomputeSuccess, setRecomputeSuccess] = useState<string | null>(null)
|
||||||
|
const [expandedRows, setExpandedRows] = useState<Set<number>>(() => new Set())
|
||||||
|
const [ledgerOffset, setLedgerOffset] = useState(0)
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
// The server only accepts complete UTC quarters. Never send a future end.
|
||||||
|
const closedEnd = (() => {
|
||||||
|
const now = new Date()
|
||||||
|
now.setUTCMinutes(Math.floor(now.getUTCMinutes() / 15) * 15, 0, 0)
|
||||||
|
const selectedEnd = end ? new Date(end) : now
|
||||||
|
return new Date(Math.min(selectedEnd.getTime(), now.getTime())).toISOString()
|
||||||
|
})()
|
||||||
|
const recomputeStart = start
|
||||||
|
const recomputeAvailable = !!recomputeStart && new Date(recomputeStart) < new Date(closedEnd)
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const resetLedgerPage = () => {
|
||||||
|
setLedgerOffset(0)
|
||||||
|
setExpandedRows(new Set())
|
||||||
|
}
|
||||||
|
const rows = useQuery({ queryKey: ['meter-costs', 'thermal', start, end, ledgerOffset], queryFn: async () => {
|
||||||
|
const result = await apiClient.GET('/api/energy/meter-costs', { params: { query: { scope: 'thermal', start, end, limit: COSTS_MAX_LIMIT, offset: ledgerOffset } } })
|
||||||
|
return result.data
|
||||||
|
} })
|
||||||
|
const meters = useQuery({ queryKey: ['energy-meters', 'thermal'], queryFn: async () => {
|
||||||
|
const result = await apiClient.GET('/api/energy/meters')
|
||||||
|
return result.data
|
||||||
|
} })
|
||||||
|
const summary = useQuery({ queryKey: ['meter-cost-summary', 'thermal', start, end], queryFn: async () => {
|
||||||
|
const result = await apiClient.GET('/api/energy/meter-costs/summary', { params: { query: { scope: 'thermal', start, end } } })
|
||||||
|
return result.data
|
||||||
|
} })
|
||||||
|
const recompute = useMutation({ mutationFn: () => apiClient.POST('/api/energy/meter-costs/recompute', { params: { query: { scope: 'thermal', start: recomputeStart!, end: closedEnd } } }), onSuccess: (result) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['meter-costs', 'thermal'] }); void qc.invalidateQueries({ queryKey: ['meter-cost-summary', 'thermal'] })
|
||||||
|
setRecomputeSuccess(`Recomputed ${result.data?.processed ?? 0} closed periods.`)
|
||||||
|
} })
|
||||||
|
const currency = summary.data?.currency ?? rows.data?.items[0]?.currency ?? 'EUR'
|
||||||
|
const fixed = summary.data?.fixed_breakdown
|
||||||
|
const hasCurrentHeatingMeter = meters.data?.items.some((meter) => meter.commodity === 'heating' && meter.ended_at === null)
|
||||||
|
const hasCurrentHotWaterMeter = meters.data?.items.some((meter) => meter.commodity === 'hot_water' && meter.ended_at === null)
|
||||||
|
const missingCurrentMeters = [
|
||||||
|
...(hasCurrentHeatingMeter === false ? ['heating'] : []),
|
||||||
|
...(hasCurrentHotWaterMeter === false ? ['hot-water'] : []),
|
||||||
|
]
|
||||||
|
const totalRows = rows.data?.total ?? 0
|
||||||
|
const shownStart = totalRows === 0 ? 0 : ledgerOffset + 1
|
||||||
|
const shownEnd = Math.min(ledgerOffset + (rows.data?.items.length ?? 0), totalRows)
|
||||||
|
return <Stack gap="lg" data-testid="thermal-cost-view">
|
||||||
|
<Group align="flex-start" gap="md" wrap="wrap"><ScopeSelector scope="thermal" onScopeChange={onScopeChange} /><Stack gap="xs"><Text size="sm" fw={500}>Date range</Text><SegmentedControl value={rangePreset} onChange={(value) => { resetLedgerPage(); setRangePreset(value as RangePreset) }} data={[{ label: 'Today', value: 'today' }, { label: 'This month', value: 'month' }, { label: 'Custom', value: 'custom' }]} data-testid="thermal-cost-range-control" /></Stack>{rangePreset === 'custom' && <Group gap="sm" align="flex-end"><TextInput label="From" type="date" value={customStartStr} onChange={(event) => { resetLedgerPage(); setCustomStartStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-start" /><TextInput label="To" type="date" value={customEndStr} onChange={(event) => { resetLedgerPage(); setCustomEndStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-end" /></Group>}<Button variant="outline" color="orange" onClick={() => { setRecomputeError(null); setRecomputeSuccess(null); setShowConfirm(true) }} disabled={!recomputeAvailable} data-testid="thermal-recompute-button">Recompute</Button></Group>
|
||||||
|
{(rows.isLoading || summary.isLoading) && <Center><Loader size="sm" /></Center>}
|
||||||
|
{(rows.isError || summary.isError) && <Alert color="red">Failed to load thermal costs.</Alert>}
|
||||||
|
{recomputeError && <Alert color="red" data-testid="thermal-recompute-error">{recomputeError}</Alert>}
|
||||||
|
{recomputeSuccess && <Alert color="green" data-testid="thermal-recompute-success">{recomputeSuccess}</Alert>}
|
||||||
|
{summary.data && <Stack gap="xs" data-testid="thermal-cost-summary"><Title order={6}>{rangePreset === 'today' ? 'Today' : rangePreset === 'month' ? 'This month' : 'Custom range'} ({currency})</Title><Text size="sm" data-testid="thermal-cost-range">{start ?? 'Select a start date'} — {end ?? 'Select an end date'}</Text><SimpleGrid cols={{ base: 2, sm: 3 }}>
|
||||||
|
<SummaryCard label="Heating" value={hasCurrentHeatingMeter === false ? 'Not configured' : summary.data.heating} /><SummaryCard label="Hot-water heating" value={hasCurrentHotWaterMeter === false ? 'Not configured' : summary.data.hot_water_heating} /><SummaryCard label="Hot water" value={hasCurrentHotWaterMeter === false ? 'Not configured' : summary.data.hot_water} /><SummaryCard label="Hot-water tax" value={hasCurrentHotWaterMeter === false ? 'Not configured' : summary.data.hot_water_tax} /><SummaryCard label="Variable subtotal" value={summary.data.variable_subtotal} /><SummaryCard label="Fixed subtotal" value={summary.data.fixed_subtotal} /><SummaryCard label="All-in total" value={summary.data.all_in} />
|
||||||
|
</SimpleGrid>{missingCurrentMeters.length > 0 && <Alert color="yellow" data-testid="thermal-missing-current-meter">Current {missingCurrentMeters.join(' and ')} meter{missingCurrentMeters.length > 1 ? 's are' : ' is'} not configured. Historical ledger rows do not establish a current meter.</Alert>}<Text size="sm" data-testid="thermal-period-count">{summary.data.period_count} periods; {summary.data.degraded_count} degraded</Text>{summary.data.degraded_count > 0 && <Alert color="orange" data-testid="thermal-summary-degraded">Some totals include degraded periods. Expand a row to see its recorded reason.</Alert>}{fixed && <Text size="sm" data-testid="thermal-fixed-breakdown">Fixed once per settled local day (summary only): {Object.entries(fixed).map(([key, value]) => `${key} ${value}`).join(' · ')}</Text>}</Stack>}
|
||||||
|
{rows.data?.items.length === 0 && <Alert color="gray" data-testid="thermal-costs-empty">No thermal cost data for this range. Check that heating or hot-water meters are bound and have settled readings.</Alert>}
|
||||||
|
{rows.data && <Stack gap="xs"><Text size="sm" c="dimmed" data-testid="thermal-ledger-count">Showing {shownStart}-{shownEnd} of {totalRows}</Text>{rows.data.items.length > 0 && <ScrollArea><Table striped withTableBorder data-testid="thermal-costs-table"><Table.Thead><Table.Tr><Table.Th>Time</Table.Th><Table.Th>Commodity</Table.Th><Table.Th>Quantity</Table.Th><Table.Th>Cost</Table.Th><Table.Th>Breakdown</Table.Th><Table.Th>Status</Table.Th><Table.Th></Table.Th></Table.Tr></Table.Thead><Table.Tbody>{rows.data.items.flatMap((item, index) => [<Table.Tr key={`${item.commodity}-${item.period_start}`} data-testid={`thermal-cost-row-${index}`}><Table.Td>{formatLocalTime(item.period_start)}</Table.Td><Table.Td>{item.commodity}</Table.Td><Table.Td>{item.quantity}</Table.Td><Table.Td>{item.cost} {item.currency}</Table.Td><Table.Td>{Object.entries(item.cost_breakdown).map(([key, value]) => `${key} ${value}`).join(', ')}</Table.Td><Table.Td>{item.degraded ? <Badge color="orange" data-testid={`thermal-degraded-${index}`}>{item.degraded_reason ?? 'degraded'}</Badge> : 'normal'}</Table.Td><Table.Td><Button size="xs" variant="subtle" onClick={() => setExpandedRows((current) => { const next = new Set(current); if (next.has(index)) next.delete(index); else next.add(index); return next })} data-testid={`thermal-cost-expand-${index}`}>{expandedRows.has(index) ? 'Hide audit' : 'Audit'}</Button></Table.Td></Table.Tr>, ...(expandedRows.has(index) ? [<Table.Tr key={`audit-${item.commodity}-${item.period_start}`} data-testid={`thermal-cost-audit-${index}`}><Table.Td colSpan={7}><Text size="xs">Contract version: {item.contract_version_id ?? 'none'}</Text><Text size="xs">Pricing snapshot: {JSON.stringify(item.pricing_snapshot)}</Text></Table.Td></Table.Tr>] : [])])}</Table.Tbody></Table></ScrollArea>}<Group justify="flex-end"><Button size="xs" variant="default" disabled={ledgerOffset === 0} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => Math.max(0, current - COSTS_MAX_LIMIT)) }} data-testid="thermal-ledger-prev">Previous</Button><Button size="xs" variant="default" disabled={ledgerOffset + (rows.data.items.length ?? 0) >= totalRows} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => current + COSTS_MAX_LIMIT) }} data-testid="thermal-ledger-next">Next</Button></Group></Stack>}
|
||||||
|
{showConfirm && <Modal opened onClose={() => setShowConfirm(false)} title="Recompute thermal costs?" data-testid="thermal-recompute-confirm-modal"><Stack><Text>This explicitly overwrites closed 15-minute thermal ledger rows for {recomputeStart ?? 'the selected start'} — {closedEnd}. Continue?</Text>{!recomputeAvailable && <Alert color="yellow">Select a range containing at least one closed UTC quarter.</Alert>}<Group justify="flex-end"><Button variant="default" onClick={() => setShowConfirm(false)} data-testid="thermal-recompute-cancel">Cancel</Button><Button color="orange" loading={recompute.isPending} disabled={!recomputeAvailable} onClick={async () => { try { await recompute.mutateAsync(); setShowConfirm(false) } catch { setRecomputeError('Failed to recompute thermal costs. Please try again.'); setShowConfirm(false) } }} data-testid="thermal-recompute-confirm">Recompute</Button></Group></Stack></Modal>}
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import { renderWithProviders } from '../test-utils'
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -244,6 +245,53 @@ describe('TibberPrices', () => {
|
|||||||
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
|
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps thermal prices scoped and renders the complete D11 Decimal snapshot with units', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const thermal = {
|
||||||
|
kind: 'district_heating', currency: 'EUR', points: [], tariff: null,
|
||||||
|
contract_version_id: 42, effective_from: '2026-01-01T00:00:00Z', effective_to: '2026-12-31T00:00:00Z',
|
||||||
|
values: {
|
||||||
|
variable: {
|
||||||
|
heating: '20.123456789123456789', hot_water_heating: '8.200000000000000001',
|
||||||
|
hot_water: '1.234567890123456789', hot_water_tax: '0.456789012345678901',
|
||||||
|
},
|
||||||
|
standing: {
|
||||||
|
heating_network: '100.000000000000000001', metering: '0', delivery_set: '20.2',
|
||||||
|
hot_water_network: '30.3', other: '40.4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockGet.mockImplementation((_path: string, options?: { params?: { query?: { scope?: string } } }) =>
|
||||||
|
Promise.resolve({ data: options?.params?.query?.scope === 'thermal'
|
||||||
|
? thermal
|
||||||
|
: { kind: 'manual', currency: 'EUR', points: [], tariff: { buy_dal: 0.1, buy_normal: 0.2, sell_dal: 0.03, sell_normal: 0.04 } } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderWithProviders(<TibberPrices />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('manual-tariff-table')).toBeInTheDocument())
|
||||||
|
await user.click(screen.getByText('Thermal'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('thermal-price-snapshot')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const snapshot = screen.getByTestId('thermal-price-snapshot')
|
||||||
|
expect(snapshot).toHaveTextContent('Version 42')
|
||||||
|
expect(snapshot).toHaveTextContent('effective 2026-01-01T00:00:00Z to 2026-12-31T00:00:00Z')
|
||||||
|
expect(snapshot).toHaveTextContent('not a 15-minute market spot price')
|
||||||
|
expect(snapshot).toHaveTextContent('20.123456789123456789 EUR/GJ')
|
||||||
|
expect(snapshot).toHaveTextContent('8.200000000000000001 EUR/m³')
|
||||||
|
expect(snapshot).toHaveTextContent('1.234567890123456789 EUR/m³')
|
||||||
|
expect(snapshot).toHaveTextContent('0.456789012345678901 EUR/m³')
|
||||||
|
for (const value of Object.values(thermal.values.standing)) {
|
||||||
|
expect(snapshot).toHaveTextContent(`${value} EUR/year`)
|
||||||
|
}
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', expect.objectContaining({
|
||||||
|
params: { query: expect.objectContaining({ scope: 'thermal' }) },
|
||||||
|
}))
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Electricity'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('manual-tariff-table')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByTestId('thermal-price-snapshot')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('marks the currently active price slot with a dot and a caption', async () => {
|
it('marks the currently active price slot with a dot and a caption', async () => {
|
||||||
installChartSize()
|
installChartSize()
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
|
SegmentedControl,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
@@ -34,7 +35,8 @@ import {
|
|||||||
ReferenceDot,
|
ReferenceDot,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { useEnergyPrices } from './hooks'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import apiClient from '../api/client'
|
||||||
import { formatLocalDate, formatLocalTime, parseBackendTimestamp } from '../utils/datetime'
|
import { formatLocalDate, formatLocalTime, parseBackendTimestamp } from '../utils/datetime'
|
||||||
|
|
||||||
const BUY_COLOR = '#2196f3'
|
const BUY_COLOR = '#2196f3'
|
||||||
@@ -46,6 +48,18 @@ const FALLBACK_SLOT_MS = 60 * 60 * 1000
|
|||||||
/** How often the "current price" marker re-evaluates which slot is active. */
|
/** How often the "current price" marker re-evaluates which slot is active. */
|
||||||
const NOW_TICK_MS = 30 * 1000
|
const NOW_TICK_MS = 30 * 1000
|
||||||
|
|
||||||
|
/** D11 thermal profile units. The API snapshot is Decimal strings, while the
|
||||||
|
* currency comes from its contract metadata. Keep this display-only: no rates
|
||||||
|
* are derived or prefilled in the browser. */
|
||||||
|
function thermalUnit(section: string, key: string, currency: string): string {
|
||||||
|
if (section === 'standing') return `${currency}/year`
|
||||||
|
if (key === 'heating') return `${currency}/GJ`
|
||||||
|
if (key === 'hot_water_heating' || key === 'hot_water' || key === 'hot_water_tax') {
|
||||||
|
return `${currency}/m³`
|
||||||
|
}
|
||||||
|
return currency
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Time range helpers
|
// Time range helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -288,46 +302,59 @@ function ManualTariffTable({ tariff, currency }: ManualTariffTableProps) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function TibberPrices() {
|
export function TibberPrices() {
|
||||||
|
const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity')
|
||||||
const start = getTodayStart()
|
const start = getTodayStart()
|
||||||
const end = getTomorrowEnd()
|
const end = getTomorrowEnd()
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['energy-prices', scope, start, end],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/energy/prices', { params: { query: { scope, start, end } } })
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const { data, isLoading, isError } = useEnergyPrices(start, end)
|
const selector = (
|
||||||
|
<SegmentedControl
|
||||||
|
value={scope}
|
||||||
|
onChange={(value) => setScope(value as 'electricity' | 'thermal')}
|
||||||
|
data={[{ label: 'Electricity', value: 'electricity' }, { label: 'Thermal', value: 'thermal' }]}
|
||||||
|
data-testid="prices-scope-selector"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Center py="xl" data-testid="prices-loading">
|
<Stack><Group><Text fw={500}>Energy Prices</Text>{selector}</Group><Center py="xl" data-testid="prices-loading"><Loader /></Center></Stack>
|
||||||
<Loader />
|
|
||||||
</Center>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<Alert color="red" data-testid="prices-error">
|
<Stack><Group><Text fw={500}>Energy Prices</Text>{selector}</Group><Alert color="red" data-testid="prices-error">
|
||||||
Failed to load energy prices. Please refresh.
|
Failed to load energy prices. Please refresh.
|
||||||
</Alert>
|
</Alert></Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<Alert color="gray" data-testid="prices-no-data">
|
<Stack><Group><Text fw={500}>Energy Prices</Text>{selector}</Group><Alert color="gray" data-testid="prices-no-data">
|
||||||
No pricing data available.
|
No pricing data available.
|
||||||
</Alert>
|
</Alert></Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No active contract
|
// No active contract
|
||||||
if (!data.kind) {
|
if (!data.kind) {
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p="md" data-testid="prices-no-contract">
|
<Stack><Group><Text fw={500}>Energy Prices</Text>{selector}</Group><Paper withBorder p="md" data-testid="prices-no-contract">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text fw={500}>No active contract</Text>
|
<Text fw={500}>No active contract</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Activate an energy contract on the Contracts tab to see pricing data.
|
Activate an energy contract on the Contracts tab to see pricing data.
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper></Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +364,7 @@ export function TibberPrices() {
|
|||||||
<Stack gap="lg" data-testid="tibber-prices">
|
<Stack gap="lg" data-testid="tibber-prices">
|
||||||
<Group gap="sm" align="center">
|
<Group gap="sm" align="center">
|
||||||
<Text fw={500}>Energy Prices</Text>
|
<Text fw={500}>Energy Prices</Text>
|
||||||
|
{selector}
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm">
|
||||||
{data.kind}
|
{data.kind}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -364,6 +392,23 @@ export function TibberPrices() {
|
|||||||
Manual tariff data not available.
|
Manual tariff data not available.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{scope === 'thermal' && data.values && (
|
||||||
|
<Paper withBorder p="md" data-testid="thermal-price-snapshot">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text fw={500}>Thermal contract snapshot</Text>
|
||||||
|
<Text size="sm">Version {data.contract_version_id ?? '—'} · effective {data.effective_from ?? '—'} to {data.effective_to ?? 'open'}</Text>
|
||||||
|
<Text size="sm" c="dimmed">This is a contract snapshot, not a 15-minute market spot price.</Text>
|
||||||
|
{Object.entries(data.values).map(([section, values]) => (
|
||||||
|
<Text size="sm" key={section} data-testid={`thermal-price-section-${section}`}>
|
||||||
|
{section}: {Object.entries(values).map(([key, value]) =>
|
||||||
|
`${key} ${value} ${thermalUnit(section, key, currency)}`,
|
||||||
|
).join(', ')}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,17 @@ describe('useCreateContract', () => {
|
|||||||
|
|
||||||
expect(mockPost).toHaveBeenCalledWith('/api/energy/contracts', { body })
|
expect(mockPost).toHaveBeenCalledWith('/api/energy/contracts', { body })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves the caller-selected thermal scope in the typed create payload', async () => {
|
||||||
|
mockPost.mockResolvedValue({ data: { id: 9 } })
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useCreateContract } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useCreateContract(), { wrapper: Wrapper })
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({ name: 'Heat', kind: 'district_heating', scope: 'thermal', currency: 'EUR', values: {} })
|
||||||
|
})
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/energy/contracts', expect.objectContaining({ body: expect.objectContaining({ scope: 'thermal' }) }))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useEnergyPrices', () => {
|
describe('useEnergyPrices', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user