Files
home-automation/frontend/src/energy/ContractForm.tsx
T

467 lines
17 KiB
TypeScript
Raw Normal View History

/**
* 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<string, unknown>, 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<string, unknown>, 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<string, LeafField[]>,
fieldValues: Record<string, number | string>,
): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [section, fields] of Object.entries(sectionFields)) {
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
for (let i = 0; i < parts.length - 1; i++) {
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
}
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<string | null>(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 = "<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)
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<string, unknown>) => ({
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<string, unknown>) => p.kind === effectiveKind)
// 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)) {
if (key === 'kind' || key === 'label') continue
if (typeof val === 'object' && val !== null && !isLeaf(val)) {
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) {
// 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<string, unknown>) => p.kind === kind)
if (!profile) return
const profileObj = profile as Record<string, unknown>
const defaults: Record<string, number | string> = {}
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<string, unknown>)
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 (
<Modal
opened
onClose={onClose}
title={title}
size="md"
data-testid="contract-form-modal"
>
{(profilesQuery.isLoading || (isAddVersion && contractDetailQuery.isLoading)) && (
<Center py="md">
<Loader size="sm" data-testid="contract-form-loading" />
</Center>
)}
{profilesQuery.isError && (
<Alert color="red" mb="sm" data-testid="contract-form-profiles-error">
Failed to load pricing profiles. Cannot continue.
</Alert>
)}
{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 */}
{!isAddVersion && (
<>
<TextInput
label="Contract name"
required
value={name}
onChange={(e) => setName(e.currentTarget.value)}
data-testid="contract-name"
/>
<Select
label="Pricing kind"
required
data={profileOptions}
value={selectedKind}
onChange={(val) => {
setSelectedKind(val)
initDefaults(val)
}}
data-testid="contract-kind"
/>
<TextInput
label="Currency"
value={currency}
onChange={(e) => setCurrency(e.currentTarget.value)}
data-testid="contract-currency"
/>
</>
)}
{/* Effective from — required for add-version, optional for create */}
<TextInput
label={isAddVersion ? 'Effective from (required)' : 'Effective from (optional, defaults to now)'}
description="Date in YYYY-MM-DD format"
type="date"
required={isAddVersion}
value={effectiveFromStr}
onChange={(e) => setEffectiveFromStr(e.currentTarget.value)}
data-testid="contract-effective-from"
/>
{/* Dynamic profile fields */}
{effectiveKind && Object.keys(sectionFields).length > 0 && (
<>
<Divider mt="xs" />
{Object.entries(sectionFields).map(([section, fields]) => (
<Stack key={section} gap="xs" data-testid={`contract-section-${section}`}>
<Title order={6} tt="capitalize" c="dimmed">
{section.replace(/_/g, ' ')}
</Title>
{fields.map((field) => {
const key = `${section}.${field.fieldPath}`
const raw = fieldValues[key]
const numVal = typeof raw === 'number' ? raw : parseFloat(String(raw))
return (
<NumberInput
key={key}
label={formatLabel(field.fieldPath)}
description={field.unit}
value={isNaN(numVal) ? 0 : numVal}
onChange={(val) => handleFieldChange(key, val)}
decimalScale={6}
step={0.001}
data-testid={`contract-field-${key}`}
/>
)
})}
</Stack>
))}
</>
)}
{!effectiveKind && !isAddVersion && (
<Text size="sm" c="dimmed" data-testid="contract-form-kind-hint">
Select a pricing kind to see the rate fields.
</Text>
)}
{error && (
<Alert color="red" data-testid="contract-form-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
type="button"
variant="default"
onClick={onClose}
data-testid="contract-form-cancel"
>
Cancel
</Button>
<Button
type="submit"
loading={isBusy}
data-testid="contract-form-submit"
>
{isAddVersion ? 'Add Version' : 'Create Contract'}
</Button>
</Group>
</Stack>
</form>
)}
</Modal>
)
}