387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
/**
|
|||
|
|
* 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 } from 'react'
|
||
|
|
import {
|
||
|
|
Modal,
|
||
|
|
Stack,
|
||
|
|
TextInput,
|
||
|
|
NumberInput,
|
||
|
|
Select,
|
||
|
|
Button,
|
||
|
|
Group,
|
||
|
|
Alert,
|
||
|
|
Loader,
|
||
|
|
Center,
|
||
|
|
Text,
|
||
|
|
Divider,
|
||
|
|
Title,
|
||
|
|
} from '@mantine/core'
|
||
|
|
import { useEnergyProfiles, useCreateContract, useAddContractVersion } 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()
|
||
|
|
|
||
|
|
// 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
|
||
|
|
const [fieldValues, setFieldValues] = 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
|
||
|
|
const sectionFields: Record<string, LeafField[]> = {}
|
||
|
|
if (selectedProfile) {
|
||
|
|
const profileObj = selectedProfile as Record<string, unknown>
|
||
|
|
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>)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleFieldChange(key: string, val: number | string) {
|
||
|
|
setFieldValues((prev) => ({ ...prev, [key]: val }))
|
||
|
|
}
|
||
|
|
|
||
|
|
// Initialize default values when profile changes
|
||
|
|
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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
setFieldValues(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 ISO datetime if provided
|
||
|
|
const effectiveFromISO = effectiveFromStr
|
||
|
|
? new Date(effectiveFromStr).toISOString()
|
||
|
|
: 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 && (
|
||
|
|
<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>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{!profilesQuery.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>
|
||
|
|
)
|
||
|
|
}
|