/** * ContractForm — create a new energy contract OR add a version to an existing one. * * Form fields are dynamically rendered from the profile structure returned by * GET /api/energy/profiles — never hardcoded. The profile structure provides * section names (energy, standing, credits), leaf keys, and their units. * * Create mode: show Name, Kind (Select from profiles), Currency, optional * effective_from, then dynamically rendered profile value fields. * Add-version mode (contractId provided): show only effective_from (required) * + profile value fields for the contract's kind. */ import { useState, useMemo } from 'react' import { Modal, Stack, TextInput, NumberInput, Select, Button, Group, Alert, Loader, Center, Text, Divider, Title, } from '@mantine/core' import { useEnergyProfiles, useCreateContract, useAddContractVersion, useContractDetail } from './hooks' // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- export interface ContractFormProps { /** When provided: add-version mode; contract must exist. */ contractId?: number /** Existing contract kind (for add-version mode or edit). */ defaultKind?: string onClose: () => void onSaved: () => void } // --------------------------------------------------------------------------- // Helpers for dynamic profile field rendering // --------------------------------------------------------------------------- /** * A leaf in the profile structure is any object with a `unit` key. * Returns true if the value is a leaf (renderable field). */ function isLeaf(val: unknown): val is { unit: string; default?: unknown } { return typeof val === 'object' && val !== null && 'unit' in val } /** * Extract all leaf fields from a profile section, returning * [dotPath, unit, defaultValue] tuples. E.g.: * "energy.buy.normal" → "EUR/kWh" * "energy.energy_tax" → "EUR/kWh" */ interface LeafField { /** Dot-separated path within the section, e.g. "buy.normal" */ fieldPath: string unit: string defaultValue?: number } function extractLeafFields(obj: Record, prefix = ''): LeafField[] { const fields: LeafField[] = [] for (const [key, val] of Object.entries(obj)) { // Skip non-object values (e.g. dual_tariff: true) if (typeof val !== 'object' || val === null) continue const path = prefix ? `${prefix}.${key}` : key if (isLeaf(val)) { fields.push({ fieldPath: path, unit: val.unit, defaultValue: typeof val.default === 'number' ? val.default : undefined, }) } else { fields.push(...extractLeafFields(val as Record, path)) } } return fields } /** * Build a nested values object from flat field values. * E.g. { "buy.normal": 0.133, "buy.dal": 0.127 } → { buy: { normal: 0.133, dal: 0.127 } } */ function buildNestedValues( sectionFields: Record, fieldValues: Record, ): Record { const result: Record = {} for (const [section, fields] of Object.entries(sectionFields)) { const sectionObj: Record = {} 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 for (let i = 0; i < parts.length - 1; i++) { if (!(parts[i] in current)) current[parts[i]] = {} current = current[parts[i]] as Record } current[parts[parts.length - 1]] = isNaN(numVal) ? 0 : numVal } result[section] = sectionObj } return result } /** * Label formatter: converts "buy.normal" → "Buy Normal", "energy_tax" → "Energy Tax" */ function formatLabel(path: string): string { return path .replace(/\./g, ' ') .replace(/_/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()) } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function ContractForm({ contractId, defaultKind, onClose, onSaved }: ContractFormProps) { const isAddVersion = contractId != null // 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(defaultKind ?? null) const [currency, setCurrency] = useState('EUR') // ISO date string "YYYY-MM-DD" for effective_from; empty = default to now const [effectiveFromStr, setEffectiveFromStr] = useState('') // Dynamic profile field values: key = "
.", value = number. // User edits are stored here; for add-version mode this is merged with prefillValues. const [userEdits, setUserEdits] = useState>({}) const [error, setError] = useState(null) const createMutation = useCreateContract() const addVersionMutation = useAddContractVersion() const isBusy = createMutation.isPending || addVersionMutation.isPending // Resolve the effective kind (add-version mode uses defaultKind) const effectiveKind = isAddVersion ? (defaultKind ?? null) : selectedKind // Build profile options from API response const profiles = profilesQuery.data?.profiles ?? [] const profileOptions = profiles.map((p: Record) => ({ value: p.kind as string, label: (p.label as string | undefined) ?? (p.kind as string), })) // Find the selected profile structure const selectedProfile = profiles.find((p: Record) => p.kind === effectiveKind) // Extract section → leaf fields mapping from the selected profile (memoized for stable reference). const sectionFields = useMemo>(() => { if (!selectedProfile) return {} const profileObj = selectedProfile as Record const result: Record = {} for (const [key, val] of Object.entries(profileObj)) { if (key === 'kind' || key === 'label') continue if (typeof val === 'object' && val !== null && !isLeaf(val)) { result[key] = extractLeafFields(val as Record) } } 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 | 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 }> 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 = {} for (const [section, fields] of Object.entries(sectionFields)) { const sectionVal = (openVersion.values as Record)[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)[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 = isAddVersion && prefillValues !== null && Object.keys(userEdits).length === 0 ? prefillValues : userEdits function handleFieldChange(key: string, val: number | string) { // 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 (create mode only) function initDefaults(kind: string | null) { if (!kind) return const profile = profiles.find((p: Record) => p.kind === kind) if (!profile) return const profileObj = profile as Record const defaults: Record = {} for (const [sectionKey, sectionVal] of Object.entries(profileObj)) { if (sectionKey === 'kind' || sectionKey === 'label') continue if (typeof sectionVal === 'object' && sectionVal !== null && !isLeaf(sectionVal)) { const leaves = extractLeafFields(sectionVal as Record) for (const leaf of leaves) { defaults[`${sectionKey}.${leaf.fieldPath}`] = leaf.defaultValue ?? 0 } } } setUserEdits(defaults) } async function handleSubmit(e: React.FormEvent) { e.preventDefault() setError(null) if (!isAddVersion && !name.trim()) { setError('Contract name is required.') return } if (!effectiveKind) { setError('Contract kind is required.') return } const values = buildNestedValues(sectionFields, fieldValues) try { // Convert local date string to a naive local-midnight datetime string (no Z). // The backend interprets naive datetimes as server local wall-clock time, // so "2026-06-25T00:00:00" → CEST local midnight → stored as UTC 22:00Z. // When effectiveFromStr is empty, fall back to the current instant (aware, // sent as ISO with Z) so the backend stores it unchanged. const effectiveFromISO = effectiveFromStr ? `${effectiveFromStr}T00:00:00` : undefined if (isAddVersion) { const body = { effective_from: effectiveFromISO ?? new Date().toISOString(), values, } await addVersionMutation.mutateAsync({ id: contractId, body }) } else { const body = { name: name.trim(), kind: effectiveKind, currency, values, ...(effectiveFromISO ? { effective_from: effectiveFromISO } : {}), } await createMutation.mutateAsync(body) } onSaved() onClose() } catch { setError('Failed to save. Please try again.') } } // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- const title = isAddVersion ? 'Add Contract Version' : 'New Energy Contract' return ( {(profilesQuery.isLoading || (isAddVersion && contractDetailQuery.isLoading)) && (
)} {profilesQuery.isError && ( Failed to load pricing profiles. Cannot continue. )} {isAddVersion && contractDetailQuery.isError && !contractDetailQuery.isLoading && ( Could not load existing contract values for prefill — fields start at 0. )} {!profilesQuery.isLoading && !(isAddVersion && contractDetailQuery.isLoading) && (
{/* Create mode fields */} {!isAddVersion && ( <> setName(e.currentTarget.value)} data-testid="contract-name" />