M8-T19: add scope-aware energy contract and cost UI

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent 963e43e3e4
commit 5b9d60e80a
10 changed files with 532 additions and 51 deletions
+29 -17
View File
@@ -38,6 +38,8 @@ export interface ContractFormProps {
contractId?: number
/** Existing contract kind (for add-version mode or edit). */
defaultKind?: string
/** The list/create scope currently selected by the parent. */
scope?: 'electricity' | 'thermal'
onClose: () => void
onSaved: () => void
}
@@ -64,7 +66,7 @@ interface LeafField {
/** Dot-separated path within the section, e.g. "buy.normal" */
fieldPath: string
unit: string
defaultValue?: number
defaultValue?: number | string
}
function extractLeafFields(obj: Record<string, unknown>, prefix = ''): LeafField[] {
@@ -77,7 +79,9 @@ function extractLeafFields(obj: Record<string, unknown>, prefix = ''): LeafField
fields.push({
fieldPath: path,
unit: val.unit,
defaultValue: typeof val.default === 'number' ? val.default : undefined,
defaultValue: typeof val.default === 'number' || typeof val.default === 'string'
? val.default
: undefined,
})
} else {
fields.push(...extractLeafFields(val as Record<string, unknown>, path))
@@ -93,6 +97,7 @@ function extractLeafFields(obj: Record<string, unknown>, prefix = ''): LeafField
function buildNestedValues(
sectionFields: Record<string, LeafField[]>,
fieldValues: Record<string, number | string>,
decimalStrings: boolean,
): Record<string, unknown> {
const result: Record<string, unknown> = {}
@@ -100,7 +105,6 @@ function buildNestedValues(
const sectionObj: Record<string, unknown> = {}
for (const field of fields) {
const raw = fieldValues[`${section}.${field.fieldPath}`]
const numVal = typeof raw === 'number' ? raw : parseFloat(String(raw))
// Set nested path
const parts = field.fieldPath.split('.')
let current = sectionObj
@@ -108,7 +112,10 @@ function buildNestedValues(
if (!(parts[i] in current)) current[parts[i]] = {}
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
}
@@ -130,7 +137,7 @@ function formatLabel(path: string): string {
// Component
// ---------------------------------------------------------------------------
export function ContractForm({ contractId, defaultKind, onClose, onSaved }: ContractFormProps) {
export function ContractForm({ contractId, defaultKind, scope, onClose, onSaved }: ContractFormProps) {
const isAddVersion = contractId != null
// Profiles query
@@ -161,7 +168,9 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
const effectiveKind = isAddVersion ? (defaultKind ?? null) : selectedKind
// 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>) => ({
value: 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]
}
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
seeded[`${section}.${leaf.fieldPath}`] = effectiveKind === 'district_heating' ? String(cursor) : Number(cursor)
}
}
}
@@ -231,6 +239,7 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
contractDetailQuery.isError,
contractDetailQuery.data,
sectionFields,
effectiveKind,
])
// 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
}
const values = buildNestedValues(sectionFields, fieldValues)
const values = buildNestedValues(sectionFields, fieldValues, effectiveKind === 'district_heating')
try {
// 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 = {
name: name.trim(),
kind: effectiveKind,
...(scope ? { scope } : {}),
currency,
values,
...(effectiveFromISO ? { effective_from: effectiveFromISO } : {}),
@@ -409,19 +419,21 @@ export function ContractForm({ contractId, defaultKind, onClose, onSaved }: Cont
</Title>
{fields.map((field) => {
const key = `${section}.${field.fieldPath}`
const raw = fieldValues[key]
const numVal = typeof raw === 'number' ? raw : parseFloat(String(raw))
return (
<NumberInput
return effectiveKind === 'district_heating' ? (
<TextInput
key={key}
label={formatLabel(field.fieldPath)}
description={field.unit}
value={isNaN(numVal) ? 0 : numVal}
onChange={(val) => handleFieldChange(key, val)}
decimalScale={6}
step={0.001}
inputMode="decimal"
value={String(fieldValues[key] ?? '0')}
onChange={(event) => handleFieldChange(key, event.currentTarget.value)}
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>