2026-06-23 23:32:39 +02:00
|
|
|
/**
|
|
|
|
|
* ContractManager — energy contract list UI.
|
|
|
|
|
*
|
|
|
|
|
* Features:
|
|
|
|
|
* - Table of contracts: name, kind, active badge, currency, created date, actions.
|
|
|
|
|
* - Actions per row: Activate (PATCH active=true), Add Version, View History.
|
|
|
|
|
* - "New Contract" button at top.
|
|
|
|
|
* - Version history modal showing all versions with effective dates and values.
|
|
|
|
|
* - Loading / error / empty states.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { useState } from 'react'
|
|
|
|
|
import {
|
|
|
|
|
Table,
|
|
|
|
|
Button,
|
|
|
|
|
Group,
|
|
|
|
|
Text,
|
|
|
|
|
Loader,
|
|
|
|
|
Center,
|
|
|
|
|
Alert,
|
|
|
|
|
Stack,
|
|
|
|
|
Badge,
|
|
|
|
|
ScrollArea,
|
|
|
|
|
Modal,
|
|
|
|
|
Accordion,
|
|
|
|
|
Code,
|
|
|
|
|
} from '@mantine/core'
|
|
|
|
|
import {
|
|
|
|
|
useContracts,
|
|
|
|
|
useUpdateContract,
|
|
|
|
|
type ContractResponse,
|
|
|
|
|
type ContractDetailResponse,
|
|
|
|
|
} from './hooks'
|
|
|
|
|
import { ContractForm } from './ContractForm'
|
|
|
|
|
import apiClient from '../api/client'
|
2026-06-24 13:22:07 +02:00
|
|
|
import { formatLocalDate } from '../utils/datetime'
|
2026-06-23 23:32:39 +02:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Version history modal
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface VersionHistoryModalProps {
|
|
|
|
|
contractId: number
|
|
|
|
|
contractName: string
|
|
|
|
|
onClose: () => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function VersionHistoryModal({ contractId, contractName, onClose }: VersionHistoryModalProps) {
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
const [detail, setDetail] = useState<ContractDetailResponse | null>(null)
|
|
|
|
|
const [fetchError, setFetchError] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
// Fetch detail on first render
|
|
|
|
|
useState(() => {
|
|
|
|
|
apiClient
|
|
|
|
|
.GET('/api/energy/contracts/{contract_id}', {
|
|
|
|
|
params: { path: { contract_id: contractId } },
|
|
|
|
|
})
|
|
|
|
|
.then((res) => {
|
|
|
|
|
setDetail(res.data ?? null)
|
|
|
|
|
setLoading(false)
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
setFetchError('Failed to load contract details.')
|
|
|
|
|
setLoading(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Modal
|
|
|
|
|
opened
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
title={`Version history — ${contractName}`}
|
|
|
|
|
size="lg"
|
|
|
|
|
data-testid="version-history-modal"
|
|
|
|
|
>
|
|
|
|
|
{loading && (
|
|
|
|
|
<Center py="md">
|
|
|
|
|
<Loader size="sm" />
|
|
|
|
|
</Center>
|
|
|
|
|
)}
|
|
|
|
|
{fetchError && (
|
|
|
|
|
<Alert color="red" data-testid="version-history-error">
|
|
|
|
|
{fetchError}
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
{!loading && !fetchError && detail && (
|
|
|
|
|
<>
|
|
|
|
|
{detail.versions.length === 0 ? (
|
|
|
|
|
<Text c="dimmed" ta="center" size="sm" data-testid="version-history-empty">
|
|
|
|
|
No versions found.
|
|
|
|
|
</Text>
|
|
|
|
|
) : (
|
|
|
|
|
<Accordion variant="separated" data-testid="version-history-accordion">
|
|
|
|
|
{detail.versions.map((v) => (
|
|
|
|
|
<Accordion.Item key={v.id} value={String(v.id)} data-testid={`version-item-${v.id}`}>
|
|
|
|
|
<Accordion.Control>
|
|
|
|
|
<Group gap="sm">
|
|
|
|
|
<Text size="sm" fw={500}>
|
2026-06-24 13:22:07 +02:00
|
|
|
From: {formatLocalDate(v.effective_from)}
|
2026-06-23 23:32:39 +02:00
|
|
|
</Text>
|
|
|
|
|
{v.effective_to ? (
|
|
|
|
|
<Text size="xs" c="dimmed">
|
2026-06-24 13:22:07 +02:00
|
|
|
To: {formatLocalDate(v.effective_to)}
|
2026-06-23 23:32:39 +02:00
|
|
|
</Text>
|
|
|
|
|
) : (
|
|
|
|
|
<Badge color="green" size="xs">
|
|
|
|
|
current
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</Group>
|
|
|
|
|
</Accordion.Control>
|
|
|
|
|
<Accordion.Panel>
|
|
|
|
|
<Code block style={{ fontSize: 11 }}>
|
|
|
|
|
{JSON.stringify(v.values, null, 2)}
|
|
|
|
|
</Code>
|
|
|
|
|
</Accordion.Panel>
|
|
|
|
|
</Accordion.Item>
|
|
|
|
|
))}
|
|
|
|
|
</Accordion>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<Group justify="flex-end" mt="md">
|
|
|
|
|
<Button variant="default" onClick={onClose}>
|
|
|
|
|
Close
|
|
|
|
|
</Button>
|
|
|
|
|
</Group>
|
|
|
|
|
</Modal>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Contract table
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface ContractTableProps {
|
|
|
|
|
contracts: ContractResponse[]
|
|
|
|
|
onActivate: (id: number) => void
|
|
|
|
|
onAddVersion: (contract: ContractResponse) => void
|
|
|
|
|
onViewHistory: (contract: ContractResponse) => void
|
|
|
|
|
activatingId: number | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ContractTable({
|
|
|
|
|
contracts,
|
|
|
|
|
onActivate,
|
|
|
|
|
onAddVersion,
|
|
|
|
|
onViewHistory,
|
|
|
|
|
activatingId,
|
|
|
|
|
}: ContractTableProps) {
|
|
|
|
|
if (contracts.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<Text c="dimmed" ta="center" size="sm" data-testid="contracts-empty">
|
|
|
|
|
No contracts configured yet. Click "New Contract" to add one.
|
|
|
|
|
</Text>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ScrollArea>
|
|
|
|
|
<Table striped highlightOnHover withTableBorder data-testid="contracts-table">
|
|
|
|
|
<Table.Thead>
|
|
|
|
|
<Table.Tr>
|
|
|
|
|
<Table.Th>Name</Table.Th>
|
|
|
|
|
<Table.Th>Kind</Table.Th>
|
|
|
|
|
<Table.Th>Active</Table.Th>
|
|
|
|
|
<Table.Th>Currency</Table.Th>
|
|
|
|
|
<Table.Th>Created</Table.Th>
|
|
|
|
|
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
</Table.Thead>
|
|
|
|
|
<Table.Tbody>
|
|
|
|
|
{contracts.map((contract) => (
|
|
|
|
|
<Table.Tr key={contract.id} data-testid={`contract-row-${contract.id}`}>
|
|
|
|
|
<Table.Td>
|
|
|
|
|
<Text fw={500} size="sm">
|
|
|
|
|
{contract.name}
|
|
|
|
|
</Text>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
<Table.Td>
|
|
|
|
|
<Badge variant="outline" size="sm">
|
|
|
|
|
{contract.kind}
|
|
|
|
|
</Badge>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
<Table.Td>
|
|
|
|
|
<Badge
|
|
|
|
|
color={contract.active ? 'green' : 'gray'}
|
|
|
|
|
variant="light"
|
|
|
|
|
size="sm"
|
|
|
|
|
data-testid={`contract-active-badge-${contract.id}`}
|
|
|
|
|
>
|
|
|
|
|
{contract.active ? 'active' : 'inactive'}
|
|
|
|
|
</Badge>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
<Table.Td>{contract.currency}</Table.Td>
|
|
|
|
|
<Table.Td>
|
|
|
|
|
<Text size="xs" c="dimmed">
|
2026-06-24 13:22:07 +02:00
|
|
|
{formatLocalDate(contract.created_at)}
|
2026-06-23 23:32:39 +02:00
|
|
|
</Text>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
<Table.Td>
|
|
|
|
|
<Group justify="flex-end" gap="xs">
|
|
|
|
|
{!contract.active && (
|
|
|
|
|
<Button
|
|
|
|
|
size="xs"
|
|
|
|
|
variant="outline"
|
|
|
|
|
color="green"
|
|
|
|
|
loading={activatingId === contract.id}
|
|
|
|
|
onClick={() => onActivate(contract.id)}
|
|
|
|
|
data-testid={`contract-activate-${contract.id}`}
|
|
|
|
|
>
|
|
|
|
|
Activate
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
size="xs"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => onAddVersion(contract)}
|
|
|
|
|
data-testid={`contract-add-version-${contract.id}`}
|
|
|
|
|
>
|
|
|
|
|
Add Version
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="xs"
|
|
|
|
|
variant="subtle"
|
|
|
|
|
onClick={() => onViewHistory(contract)}
|
|
|
|
|
data-testid={`contract-history-${contract.id}`}
|
|
|
|
|
>
|
|
|
|
|
History
|
|
|
|
|
</Button>
|
|
|
|
|
</Group>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
))}
|
|
|
|
|
</Table.Tbody>
|
|
|
|
|
</Table>
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// ContractManager — top-level
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export function ContractManager() {
|
|
|
|
|
const contractsQuery = useContracts()
|
|
|
|
|
const updateMutation = useUpdateContract()
|
|
|
|
|
|
|
|
|
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
|
|
|
|
const [addVersionContract, setAddVersionContract] = useState<ContractResponse | null>(null)
|
|
|
|
|
const [historyContract, setHistoryContract] = useState<ContractResponse | null>(null)
|
|
|
|
|
const [activatingId, setActivatingId] = useState<number | null>(null)
|
|
|
|
|
|
|
|
|
|
async function handleActivate(id: number) {
|
|
|
|
|
setActivatingId(id)
|
|
|
|
|
try {
|
|
|
|
|
await updateMutation.mutateAsync({ id, body: { active: true } })
|
|
|
|
|
} finally {
|
|
|
|
|
setActivatingId(null)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Render states
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
if (contractsQuery.isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<Center py="xl" data-testid="contracts-loading">
|
|
|
|
|
<Loader />
|
|
|
|
|
</Center>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (contractsQuery.isError || !contractsQuery.data) {
|
|
|
|
|
return (
|
|
|
|
|
<Alert color="red" data-testid="contracts-load-error">
|
|
|
|
|
Failed to load contracts. Please refresh.
|
|
|
|
|
</Alert>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const contracts = contractsQuery.data.items
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Stack gap="lg" data-testid="contract-manager">
|
|
|
|
|
<Group justify="space-between" align="center">
|
|
|
|
|
<Text fw={500}>Energy Contracts</Text>
|
|
|
|
|
<Button onClick={() => setShowCreateForm(true)} data-testid="contract-new-button">
|
|
|
|
|
New Contract
|
|
|
|
|
</Button>
|
|
|
|
|
</Group>
|
|
|
|
|
|
|
|
|
|
<ContractTable
|
|
|
|
|
contracts={contracts}
|
|
|
|
|
onActivate={handleActivate}
|
|
|
|
|
onAddVersion={(c) => setAddVersionContract(c)}
|
|
|
|
|
onViewHistory={(c) => setHistoryContract(c)}
|
|
|
|
|
activatingId={activatingId}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Create new contract */}
|
|
|
|
|
{showCreateForm && (
|
|
|
|
|
<ContractForm
|
|
|
|
|
onClose={() => setShowCreateForm(false)}
|
|
|
|
|
onSaved={() => setShowCreateForm(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Add version to existing contract */}
|
|
|
|
|
{addVersionContract && (
|
|
|
|
|
<ContractForm
|
|
|
|
|
contractId={addVersionContract.id}
|
|
|
|
|
defaultKind={addVersionContract.kind}
|
|
|
|
|
onClose={() => setAddVersionContract(null)}
|
|
|
|
|
onSaved={() => setAddVersionContract(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Version history modal */}
|
|
|
|
|
{historyContract && (
|
|
|
|
|
<VersionHistoryModal
|
|
|
|
|
contractId={historyContract.id}
|
|
|
|
|
contractName={historyContract.name}
|
|
|
|
|
onClose={() => setHistoryContract(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</Stack>
|
|
|
|
|
)
|
|
|
|
|
}
|