M8-T19: add scope-aware energy contract and cost UI
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
} from '@mantine/core'
|
||||
import {
|
||||
LineChart,
|
||||
@@ -34,7 +35,8 @@ import {
|
||||
ReferenceDot,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { useEnergyPrices } from './hooks'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import apiClient from '../api/client'
|
||||
import { formatLocalDate, formatLocalTime, parseBackendTimestamp } from '../utils/datetime'
|
||||
|
||||
const BUY_COLOR = '#2196f3'
|
||||
@@ -46,6 +48,18 @@ const FALLBACK_SLOT_MS = 60 * 60 * 1000
|
||||
/** How often the "current price" marker re-evaluates which slot is active. */
|
||||
const NOW_TICK_MS = 30 * 1000
|
||||
|
||||
/** D11 thermal profile units. The API snapshot is Decimal strings, while the
|
||||
* currency comes from its contract metadata. Keep this display-only: no rates
|
||||
* are derived or prefilled in the browser. */
|
||||
function thermalUnit(section: string, key: string, currency: string): string {
|
||||
if (section === 'standing') return `${currency}/year`
|
||||
if (key === 'heating') return `${currency}/GJ`
|
||||
if (key === 'hot_water_heating' || key === 'hot_water' || key === 'hot_water_tax') {
|
||||
return `${currency}/m³`
|
||||
}
|
||||
return currency
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Time range helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -288,46 +302,59 @@ function ManualTariffTable({ tariff, currency }: ManualTariffTableProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TibberPrices() {
|
||||
const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity')
|
||||
const start = getTodayStart()
|
||||
const end = getTomorrowEnd()
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['energy-prices', scope, start, end],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/energy/prices', { params: { query: { scope, start, end } } })
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data, isLoading, isError } = useEnergyPrices(start, end)
|
||||
const selector = (
|
||||
<SegmentedControl
|
||||
value={scope}
|
||||
onChange={(value) => setScope(value as 'electricity' | 'thermal')}
|
||||
data={[{ label: 'Electricity', value: 'electricity' }, { label: 'Thermal', value: 'thermal' }]}
|
||||
data-testid="prices-scope-selector"
|
||||
/>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl" data-testid="prices-loading">
|
||||
<Loader />
|
||||
</Center>
|
||||
<Stack><Group><Text fw={500}>Energy Prices</Text>{selector}</Group><Center py="xl" data-testid="prices-loading"><Loader /></Center></Stack>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert color="red" data-testid="prices-error">
|
||||
<Stack><Group><Text fw={500}>Energy Prices</Text>{selector}</Group><Alert color="red" data-testid="prices-error">
|
||||
Failed to load energy prices. Please refresh.
|
||||
</Alert>
|
||||
</Alert></Stack>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Alert color="gray" data-testid="prices-no-data">
|
||||
<Stack><Group><Text fw={500}>Energy Prices</Text>{selector}</Group><Alert color="gray" data-testid="prices-no-data">
|
||||
No pricing data available.
|
||||
</Alert>
|
||||
</Alert></Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// No active contract
|
||||
if (!data.kind) {
|
||||
return (
|
||||
<Paper withBorder p="md" data-testid="prices-no-contract">
|
||||
<Stack><Group><Text fw={500}>Energy Prices</Text>{selector}</Group><Paper withBorder p="md" data-testid="prices-no-contract">
|
||||
<Stack gap="xs">
|
||||
<Text fw={500}>No active contract</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Activate an energy contract on the Contracts tab to see pricing data.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Paper></Stack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -337,6 +364,7 @@ export function TibberPrices() {
|
||||
<Stack gap="lg" data-testid="tibber-prices">
|
||||
<Group gap="sm" align="center">
|
||||
<Text fw={500}>Energy Prices</Text>
|
||||
{selector}
|
||||
<Badge variant="outline" size="sm">
|
||||
{data.kind}
|
||||
</Badge>
|
||||
@@ -364,6 +392,23 @@ export function TibberPrices() {
|
||||
Manual tariff data not available.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{scope === 'thermal' && data.values && (
|
||||
<Paper withBorder p="md" data-testid="thermal-price-snapshot">
|
||||
<Stack gap="xs">
|
||||
<Text fw={500}>Thermal contract snapshot</Text>
|
||||
<Text size="sm">Version {data.contract_version_id ?? '—'} · effective {data.effective_from ?? '—'} to {data.effective_to ?? 'open'}</Text>
|
||||
<Text size="sm" c="dimmed">This is a contract snapshot, not a 15-minute market spot price.</Text>
|
||||
{Object.entries(data.values).map(([section, values]) => (
|
||||
<Text size="sm" key={section} data-testid={`thermal-price-section-${section}`}>
|
||||
{section}: {Object.entries(values).map(([key, value]) =>
|
||||
`${key} ${value} ${thermalUnit(section, key, currency)}`,
|
||||
).join(', ')}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user