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:
@@ -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