M6-T10: frontend contract management + price/cost views + Tibber test
- EnergyPage: Mantine Tabs (Devices kept intact + Contracts/Prices/Costs). - ContractManager/ContractForm: list/activate/add-version + version history; form fields rendered dynamically from /api/energy/profiles structure. - TibberPrices + CostView: Recharts price curve, cost trend/detail/summary, recompute; window-bounded, currency/units from API, empty/error/loading states. - ConfigPage: tri-state Tibber test button (token never shown). - hooks.ts: typed energy hooks; schema.d.ts regenerated via codegen.
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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}>
|
||||
From: {new Date(v.effective_from).toLocaleDateString()}
|
||||
</Text>
|
||||
{v.effective_to ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
To: {new Date(v.effective_to).toLocaleDateString()}
|
||||
</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">
|
||||
{new Date(contract.created_at).toLocaleDateString()}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user