M8-T19: add scope-aware energy contract and cost UI
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user