FU11: anchor cumulative cost at recording start; add daily-reset cost entities; prefill add-version form
frontend / frontend (push) Successful in 2m6s
pytest / test (push) Successful in 7m32s

- MQTT import_cost_total / export_revenue_total now anchor fixed-fee / tax-credit
  accrual at max(contract start, first recorded period), so standing charges for
  untracked pre-recording days are no longer folded into the running total.
- Add energy.import_cost_today / energy.export_revenue_today (monetary,
  total_increasing) that reset at server-local midnight, for per-day cost
  aggregation in Home Assistant alongside the running totals.
- ContractForm add-version mode prefills the contract's open version values, so
  only the fields that actually change need editing.
This commit is contained in:
2026-06-25 12:45:24 +02:00
parent b71009620a
commit 96e85fb48e
5 changed files with 689 additions and 63 deletions
+89
View File
@@ -246,4 +246,93 @@ describe('ContractForm', () => {
expect(onClose).toHaveBeenCalled()
})
it('prefills fields from the open version when in add-version mode', async () => {
// FU11: add-version mode should auto-prefill rate fields from the contract's
// current open version so the user only has to change what's different.
const CONTRACT_DETAIL = {
id: 42,
name: 'My Contract',
kind: 'manual',
active: true,
currency: 'EUR',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
versions: [
{
id: 1,
contract_id: 42,
effective_from: '2026-01-01T00:00:00Z',
effective_to: null, // open version
values: {
energy: {
buy: { normal: 0.133, dal: 0.127 },
sell: { normal: 0.05, dal: 0.05 },
energy_tax: 0.11,
ode: 0.0,
},
standing: {
network_fee: 30.0,
management_fee: 60.0,
},
credits: {
heffingskorting: 365.0,
},
},
created_at: '2026-01-01T00:00:00Z',
},
],
}
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/profiles') {
return Promise.resolve({ data: PROFILES_RESPONSE })
}
if (path === '/api/energy/contracts/{contract_id}') {
return Promise.resolve({ data: CONTRACT_DETAIL })
}
return Promise.resolve({ data: null })
})
const onClose = vi.fn()
const onSaved = vi.fn()
renderWithProviders(
<ContractForm contractId={42} defaultKind="manual" onClose={onClose} onSaved={onSaved} />,
)
const user = userEvent.setup()
// Wait for the form to render and profile fields to appear
await waitFor(() => {
expect(screen.getByTestId('contract-section-energy')).toBeInTheDocument()
}, { timeout: 3000 })
// Wait for prefill to apply: both profile and contract detail queries must resolve.
// Fill in the required effective_from date and submit; the submitted values
// body.values should contain the prefilled rates from the open version.
mockPost.mockResolvedValue({ data: {} })
const effectiveInput = screen.getByTestId('contract-effective-from')
await user.type(effectiveInput, '2026-07-01')
await user.click(screen.getByTestId('contract-form-submit'))
// The submitted body should contain the prefilled values from CONTRACT_DETAIL
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/api/energy/contracts/{contract_id}/versions',
expect.objectContaining({
body: expect.objectContaining({
values: expect.objectContaining({
standing: expect.objectContaining({
network_fee: 30,
management_fee: 60,
}),
}),
}),
}),
)
}, { timeout: 3000 })
})
})
+91 -15
View File
@@ -11,7 +11,7 @@
* + profile value fields for the contract's kind.
*/
import { useState } from 'react'
import { useState, useMemo } from 'react'
import {
Modal,
Stack,
@@ -27,7 +27,7 @@ import {
Divider,
Title,
} from '@mantine/core'
import { useEnergyProfiles, useCreateContract, useAddContractVersion } from './hooks'
import { useEnergyProfiles, useCreateContract, useAddContractVersion, useContractDetail } from './hooks'
// ---------------------------------------------------------------------------
// Props
@@ -136,6 +136,9 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
// Profiles query
const profilesQuery = useEnergyProfiles()
// Contract detail query — only used in add-version mode to prefill from the open version.
const contractDetailQuery = useContractDetail(contractId ?? 0)
// Create mode fields
const [name, setName] = useState('')
const [selectedKind, setSelectedKind] = useState<string | null>(defaultKind ?? null)
@@ -143,8 +146,9 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
// ISO date string "YYYY-MM-DD" for effective_from; empty = default to now
const [effectiveFromStr, setEffectiveFromStr] = useState('')
// Dynamic profile field values: key = "<section>.<fieldPath>", value = number
const [fieldValues, setFieldValues] = useState<Record<string, number | string>>({})
// Dynamic profile field values: key = "<section>.<fieldPath>", value = number.
// User edits are stored here; for add-version mode this is merged with prefillValues.
const [userEdits, setUserEdits] = useState<Record<string, number | string>>({})
const [error, setError] = useState<string | null>(null)
@@ -166,24 +170,90 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
// Find the selected profile structure
const selectedProfile = profiles.find((p: Record<string, unknown>) => p.kind === effectiveKind)
// Extract section → leaf fields mapping from the selected profile
const sectionFields: Record<string, LeafField[]> = {}
if (selectedProfile) {
// Extract section → leaf fields mapping from the selected profile (memoized for stable reference).
const sectionFields = useMemo<Record<string, LeafField[]>>(() => {
if (!selectedProfile) return {}
const profileObj = selectedProfile as Record<string, unknown>
const result: Record<string, LeafField[]> = {}
for (const [key, val] of Object.entries(profileObj)) {
// Skip metadata fields
if (key === 'kind' || key === 'label') continue
if (typeof val === 'object' && val !== null && !isLeaf(val)) {
sectionFields[key] = extractLeafFields(val as Record<string, unknown>)
result[key] = extractLeafFields(val as Record<string, unknown>)
}
}
}
return result
}, [selectedProfile])
// Add-version prefill: compute the seeded values from the contract's open version.
// Returns the flat fieldValues dict or null when data is not ready.
const prefillValues = useMemo<Record<string, number | string> | null>(() => {
if (!isAddVersion) return null
if (contractDetailQuery.isLoading || contractDetailQuery.isError) return null
if (Object.keys(sectionFields).length === 0) return null
const detail = contractDetailQuery.data
if (!detail || !detail.versions || detail.versions.length === 0) return null
const versions = detail.versions as Array<{
effective_from: string
effective_to: string | null
values: Record<string, unknown>
}>
let openVersion = versions.find((v) => v.effective_to == null)
if (!openVersion) {
openVersion = [...versions].sort((a, b) =>
a.effective_from < b.effective_from ? 1 : -1
)[0]
}
if (!openVersion?.values) return null
const seeded: Record<string, number | string> = {}
for (const [section, fields] of Object.entries(sectionFields)) {
const sectionVal = (openVersion.values as Record<string, unknown>)[section]
if (typeof sectionVal !== 'object' || sectionVal === null) continue
for (const leaf of fields) {
const parts = leaf.fieldPath.split('.')
let cursor: unknown = sectionVal
for (const part of parts) {
if (typeof cursor !== 'object' || cursor === null) { cursor = undefined; break }
cursor = (cursor as Record<string, unknown>)[part]
}
if (cursor != null && (typeof cursor === 'number' || typeof cursor === 'string')) {
const numVal = typeof cursor === 'number' ? cursor : parseFloat(String(cursor))
seeded[`${section}.${leaf.fieldPath}`] = isNaN(numVal) ? 0 : numVal
}
}
}
return Object.keys(seeded).length > 0 ? seeded : null
}, [
isAddVersion,
contractDetailQuery.isLoading,
contractDetailQuery.isError,
contractDetailQuery.data,
sectionFields,
])
// The effective field values: user edits override prefill; prefill is the base.
// In add-version mode: when the user has not yet edited any field (userEdits is empty),
// prefillValues is used as the initial base. Once the user edits any field, their
// full merged value (prefill + override) is stored in userEdits.
const fieldValues: Record<string, number | string> =
isAddVersion && prefillValues !== null && Object.keys(userEdits).length === 0
? prefillValues
: userEdits
function handleFieldChange(key: string, val: number | string) {
setFieldValues((prev) => ({ ...prev, [key]: val }))
// On first edit in add-version mode, copy prefill into userEdits so we don't
// lose the other prefilled values when the user changes one field.
setUserEdits((prev) => {
const base = isAddVersion && prefillValues !== null && Object.keys(prev).length === 0
? { ...prefillValues }
: prev
return { ...base, [key]: val }
})
}
// Initialize default values when profile changes
// Initialize default values when profile changes (create mode only)
function initDefaults(kind: string | null) {
if (!kind) return
const profile = profiles.find((p: Record<string, unknown>) => p.kind === kind)
@@ -199,7 +269,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
}
}
}
setFieldValues(defaults)
setUserEdits(defaults)
}
async function handleSubmit(e: React.FormEvent) {
@@ -264,7 +334,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
size="md"
data-testid="contract-form-modal"
>
{profilesQuery.isLoading && (
{(profilesQuery.isLoading || (isAddVersion && contractDetailQuery.isLoading)) && (
<Center py="md">
<Loader size="sm" data-testid="contract-form-loading" />
</Center>
@@ -276,7 +346,13 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
</Alert>
)}
{!profilesQuery.isLoading && (
{isAddVersion && contractDetailQuery.isError && !contractDetailQuery.isLoading && (
<Alert color="yellow" mb="sm" data-testid="contract-form-detail-error">
Could not load existing contract values for prefill fields start at 0.
</Alert>
)}
{!profilesQuery.isLoading && !(isAddVersion && contractDetailQuery.isLoading) && (
<form onSubmit={handleSubmit} data-testid="contract-form">
<Stack gap="sm">
{/* Create mode fields */}