Files
home-automation/frontend/src/energy/ContractManager.tsx
T
tliu93 effe434637 frontend: show Energy/DSMR/meter times in local timezone, 24h
Backend serializes SQLite-read (naive) UTC datetimes without a Z, so the browser
was parsing them as local time -> the UI effectively showed UTC values.

- New src/utils/datetime.ts: treats a tz-less timestamp as UTC, then formats in
  the browser's local timezone with hour12:false (no AM/PM).
- Applied to Energy tabs (contracts/prices/costs/DSMR), per-device readings &
  trends, and the meter readings table on the Records page.
- CostView Today/This month now use local day boundaries (queries stay UTC ISO).
- Untouched: location/poo (raw Zulu strings), the map, and query-window params.
- Tests are timezone-independent (verified under TZ=America/Los_Angeles).
2026-06-24 13:22:07 +02:00

332 lines
10 KiB
TypeScript

/**
* 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'
import { formatLocalDate } from '../utils/datetime'
// ---------------------------------------------------------------------------
// 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: {formatLocalDate(v.effective_from)}
</Text>
{v.effective_to ? (
<Text size="xs" c="dimmed">
To: {formatLocalDate(v.effective_to)}
</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">
{formatLocalDate(contract.created_at)}
</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>
)
}