FU11: anchor cumulative cost at recording start; add daily-reset cost entities; prefill add-version form
- 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:
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user