499 lines
25 KiB
TypeScript
499 lines
25 KiB
TypeScript
/**
|
|
* CostView — cost trends + detail + summary.
|
|
*
|
|
* Features:
|
|
* - Date range selector: Today / This month / Custom (SegmentedControl + DateInput).
|
|
* - Recharts AreaChart showing import_cost, export_revenue, net_cost per period.
|
|
* - Table showing per-15min periods with time, kWh values, costs, degraded badge.
|
|
* - Summary cards: metered import/export, fixed costs, credits, total payable.
|
|
* - "Recompute" button with confirmation.
|
|
* - Loading/error/empty states, degraded period highlighting.
|
|
*
|
|
* Recharts imports are isolated to this file only.
|
|
*/
|
|
|
|
import { useState } from 'react'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import {
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
Loader,
|
|
Center,
|
|
Alert,
|
|
Table,
|
|
Group,
|
|
Badge,
|
|
ScrollArea,
|
|
SegmentedControl,
|
|
Button,
|
|
Paper,
|
|
SimpleGrid,
|
|
Modal,
|
|
Title,
|
|
} from '@mantine/core'
|
|
import {
|
|
AreaChart,
|
|
Area,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
Legend,
|
|
ResponsiveContainer,
|
|
} from 'recharts'
|
|
import { useEnergyCosts, useEnergyCostSummary, useRecomputeCosts } from './hooks'
|
|
import { formatLocalTime } from '../utils/datetime'
|
|
import apiClient from '../api/client'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cost limit — prevent accidental full-table pulls
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const COSTS_MAX_LIMIT = 500
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Date range helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getTodayRange(): { start: string; end: string } {
|
|
// Use local date boundary so "today" matches what the user sees in the display.
|
|
const now = new Date()
|
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
|
return { start: start.toISOString(), end: end.toISOString() }
|
|
}
|
|
|
|
function getThisMonthRange(): { start: string; end: string } {
|
|
// Use local date boundary so "this month" matches what the user sees in the display.
|
|
const now = new Date()
|
|
const start = new Date(now.getFullYear(), now.getMonth(), 1)
|
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1)
|
|
return { start: start.toISOString(), end: end.toISOString() }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Summary cards
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface SummaryCardProps {
|
|
label: string
|
|
value: string
|
|
/** Optional secondary line, e.g. the monetary equivalent of an energy figure. */
|
|
sub?: string
|
|
testId?: string
|
|
}
|
|
|
|
function SummaryCard({ label, value, sub, testId }: SummaryCardProps) {
|
|
return (
|
|
<Paper withBorder p="sm" data-testid={testId}>
|
|
<Stack gap={4}>
|
|
<Text size="xs" c="dimmed">
|
|
{label}
|
|
</Text>
|
|
<Text fw={600} size="lg">
|
|
{value}
|
|
</Text>
|
|
{sub !== undefined && (
|
|
<Text size="xs" c="dimmed" data-testid={testId ? `${testId}-sub` : undefined}>
|
|
{sub}
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
</Paper>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CostView — main component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type RangePreset = 'today' | 'month' | 'custom'
|
|
|
|
export function CostView() {
|
|
const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity')
|
|
if (scope === 'thermal') return <ThermalCostView onScopeChange={setScope} />
|
|
return <ElectricityCostView onScopeChange={setScope} />
|
|
}
|
|
|
|
function ScopeSelector({ scope, onScopeChange }: { scope: 'electricity' | 'thermal'; onScopeChange: (scope: 'electricity' | 'thermal') => void }) {
|
|
return <SegmentedControl value={scope} onChange={(value) => onScopeChange(value as 'electricity' | 'thermal')} data={[{ label: 'Electricity', value: 'electricity' }, { label: 'Thermal', value: 'thermal' }]} data-testid="costs-scope-selector" />
|
|
}
|
|
|
|
function ElectricityCostView({ onScopeChange }: { onScopeChange: (scope: 'electricity' | 'thermal') => void }) {
|
|
const [rangePreset, setRangePreset] = useState<RangePreset>('today')
|
|
// Date strings in YYYY-MM-DD format for custom range
|
|
const [customStartStr, setCustomStartStr] = useState('')
|
|
const [customEndStr, setCustomEndStr] = useState('')
|
|
const [showRecomputeConfirm, setShowRecomputeConfirm] = useState(false)
|
|
|
|
// Compute effective date range
|
|
const { start, end } = (() => {
|
|
if (rangePreset === 'today') return getTodayRange()
|
|
if (rangePreset === 'month') return getThisMonthRange()
|
|
return {
|
|
start: customStartStr ? new Date(customStartStr).toISOString() : undefined,
|
|
end: customEndStr ? new Date(customEndStr).toISOString() : undefined,
|
|
}
|
|
})()
|
|
|
|
const costsQuery = useEnergyCosts(start, end, COSTS_MAX_LIMIT)
|
|
const summaryQuery = useEnergyCostSummary(start, end)
|
|
const recomputeMutation = useRecomputeCosts()
|
|
|
|
async function handleRecompute() {
|
|
setShowRecomputeConfirm(false)
|
|
await recomputeMutation.mutateAsync({ start, end })
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Render
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const currency = costsQuery.data?.items[0]?.currency ?? summaryQuery.data?.currency ?? 'EUR'
|
|
|
|
return (
|
|
<Stack gap="lg" data-testid="cost-view">
|
|
{/* Date range selector */}
|
|
<Group align="flex-start" gap="md" wrap="wrap">
|
|
<ScopeSelector scope="electricity" onScopeChange={onScopeChange} />
|
|
<Stack gap="xs">
|
|
<Text size="sm" fw={500}>
|
|
Date range
|
|
</Text>
|
|
<SegmentedControl
|
|
value={rangePreset}
|
|
onChange={(v) => setRangePreset(v as RangePreset)}
|
|
data={[
|
|
{ label: 'Today', value: 'today' },
|
|
{ label: 'This month', value: 'month' },
|
|
{ label: 'Custom', value: 'custom' },
|
|
]}
|
|
data-testid="cost-range-control"
|
|
/>
|
|
</Stack>
|
|
|
|
{rangePreset === 'custom' && (
|
|
<Group gap="sm" align="flex-end">
|
|
<TextInput
|
|
label="From"
|
|
type="date"
|
|
value={customStartStr}
|
|
onChange={(e) => setCustomStartStr(e.currentTarget.value)}
|
|
data-testid="cost-custom-start"
|
|
/>
|
|
<TextInput
|
|
label="To"
|
|
type="date"
|
|
value={customEndStr}
|
|
onChange={(e) => setCustomEndStr(e.currentTarget.value)}
|
|
data-testid="cost-custom-end"
|
|
/>
|
|
</Group>
|
|
)}
|
|
|
|
<Group gap="sm" style={{ marginLeft: 'auto' }} align="flex-end">
|
|
<Button
|
|
variant="outline"
|
|
color="orange"
|
|
size="sm"
|
|
onClick={() => setShowRecomputeConfirm(true)}
|
|
loading={recomputeMutation.isPending}
|
|
data-testid="cost-recompute-button"
|
|
>
|
|
Recompute
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
|
|
{/* Summary cards */}
|
|
{summaryQuery.isLoading && (
|
|
<Center>
|
|
<Loader size="sm" />
|
|
</Center>
|
|
)}
|
|
|
|
{summaryQuery.isError && (
|
|
<Alert color="red" data-testid="summary-error">
|
|
Failed to load cost summary.
|
|
</Alert>
|
|
)}
|
|
|
|
{summaryQuery.data && (
|
|
<Stack gap="xs">
|
|
<Title order={6} c="dimmed">
|
|
Summary
|
|
</Title>
|
|
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm" data-testid="cost-summary">
|
|
<SummaryCard
|
|
label="Import (kWh)"
|
|
value={summaryQuery.data.metered_import_kwh.toFixed(3)}
|
|
sub={`${summaryQuery.data.metered_import.toFixed(2)} ${currency}`}
|
|
testId="summary-import"
|
|
/>
|
|
<SummaryCard
|
|
label="Export (kWh)"
|
|
value={summaryQuery.data.metered_export_kwh.toFixed(3)}
|
|
sub={`${summaryQuery.data.metered_export.toFixed(2)} ${currency}`}
|
|
testId="summary-export"
|
|
/>
|
|
<SummaryCard
|
|
label={`Fixed costs (${currency})`}
|
|
value={summaryQuery.data.fixed_costs.toFixed(2)}
|
|
testId="summary-fixed"
|
|
/>
|
|
<SummaryCard
|
|
label={`Credits (${currency})`}
|
|
value={summaryQuery.data.credits.toFixed(2)}
|
|
testId="summary-credits"
|
|
/>
|
|
<SummaryCard
|
|
label={`Total payable (${currency})`}
|
|
value={summaryQuery.data.total_payable.toFixed(2)}
|
|
testId="summary-total"
|
|
/>
|
|
</SimpleGrid>
|
|
</Stack>
|
|
)}
|
|
|
|
{/* Cost chart */}
|
|
{costsQuery.isLoading && (
|
|
<Center py="xl" data-testid="costs-loading">
|
|
<Loader />
|
|
</Center>
|
|
)}
|
|
|
|
{costsQuery.isError && (
|
|
<Alert color="red" data-testid="costs-error">
|
|
Failed to load cost periods. Please refresh.
|
|
</Alert>
|
|
)}
|
|
|
|
{costsQuery.data && costsQuery.data.items.length === 0 && (
|
|
<Alert color="gray" data-testid="costs-empty">
|
|
No cost data available for the selected period.
|
|
</Alert>
|
|
)}
|
|
|
|
{costsQuery.data && costsQuery.data.items.length > 0 && (
|
|
<>
|
|
{/* Area chart */}
|
|
<Stack gap="xs" data-testid="cost-chart">
|
|
<Title order={6} c="dimmed">
|
|
Cost trends ({currency})
|
|
</Title>
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
<AreaChart
|
|
data={costsQuery.data.items.map((item) => ({
|
|
time: formatLocalTime(item.period_start),
|
|
import_cost: item.import_cost,
|
|
export_revenue: item.export_revenue,
|
|
net_cost: item.net_cost,
|
|
}))}
|
|
margin={{ top: 4, right: 16, left: 0, bottom: 4 }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
|
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v: number) => v.toFixed(2)} />
|
|
<Tooltip
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
formatter={(val: any) =>
|
|
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
|
|
}
|
|
/>
|
|
<Legend />
|
|
<Area
|
|
type="monotone"
|
|
dataKey="import_cost"
|
|
stroke="#2196f3"
|
|
fill="#bbdefb"
|
|
name="Import cost"
|
|
dot={false}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="export_revenue"
|
|
stroke="#4caf50"
|
|
fill="#c8e6c9"
|
|
name="Export revenue"
|
|
dot={false}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="net_cost"
|
|
stroke="#ff9800"
|
|
fill="#ffe0b2"
|
|
name="Net cost"
|
|
dot={false}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</Stack>
|
|
|
|
{/* Detail table */}
|
|
<Stack gap="xs">
|
|
<Title order={6} c="dimmed">
|
|
Period detail
|
|
</Title>
|
|
<ScrollArea>
|
|
<Table
|
|
striped
|
|
highlightOnHover
|
|
withTableBorder
|
|
withColumnBorders
|
|
style={{ fontSize: 12 }}
|
|
data-testid="costs-table"
|
|
>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Time</Table.Th>
|
|
<Table.Th>Import kWh</Table.Th>
|
|
<Table.Th>Export kWh</Table.Th>
|
|
<Table.Th>Import cost</Table.Th>
|
|
<Table.Th>Export rev.</Table.Th>
|
|
<Table.Th>Net cost</Table.Th>
|
|
<Table.Th></Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{costsQuery.data.items.map((item, idx) => (
|
|
<Table.Tr
|
|
key={idx}
|
|
style={item.degraded ? { opacity: 0.6 } : undefined}
|
|
data-testid={`cost-row-${idx}`}
|
|
>
|
|
<Table.Td>
|
|
<Text size="xs">
|
|
{formatLocalTime(item.period_start)}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>{(item.d1_kwh + item.d2_kwh).toFixed(3)}</Table.Td>
|
|
<Table.Td>{(item.r1_kwh + item.r2_kwh).toFixed(3)}</Table.Td>
|
|
<Table.Td>{item.import_cost.toFixed(4)}</Table.Td>
|
|
<Table.Td>{item.export_revenue.toFixed(4)}</Table.Td>
|
|
<Table.Td>{item.net_cost.toFixed(4)}</Table.Td>
|
|
<Table.Td>
|
|
{item.degraded && (
|
|
<Badge color="orange" size="xs" data-testid={`cost-degraded-${idx}`}>
|
|
degraded
|
|
</Badge>
|
|
)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</ScrollArea>
|
|
</Stack>
|
|
</>
|
|
)}
|
|
|
|
{/* Recompute confirmation modal */}
|
|
{showRecomputeConfirm && (
|
|
<Modal
|
|
opened
|
|
onClose={() => setShowRecomputeConfirm(false)}
|
|
title="Recompute costs?"
|
|
size="sm"
|
|
data-testid="recompute-confirm-modal"
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm">
|
|
This will recompute all cost periods for the selected date range. Continue?
|
|
</Text>
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button
|
|
variant="default"
|
|
onClick={() => setShowRecomputeConfirm(false)}
|
|
data-testid="recompute-cancel"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="orange"
|
|
onClick={handleRecompute}
|
|
data-testid="recompute-confirm"
|
|
>
|
|
Recompute
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
)}
|
|
</Stack>
|
|
)
|
|
}
|
|
|
|
function ThermalCostView({ onScopeChange }: { onScopeChange: (scope: 'electricity' | 'thermal') => void }) {
|
|
const [rangePreset, setRangePreset] = useState<RangePreset>('today')
|
|
const [customStartStr, setCustomStartStr] = useState('')
|
|
const [customEndStr, setCustomEndStr] = useState('')
|
|
const [showConfirm, setShowConfirm] = useState(false)
|
|
const [recomputeError, setRecomputeError] = useState<string | null>(null)
|
|
const [recomputeSuccess, setRecomputeSuccess] = useState<string | null>(null)
|
|
const [expandedRows, setExpandedRows] = useState<Set<number>>(() => new Set())
|
|
const [ledgerOffset, setLedgerOffset] = useState(0)
|
|
const { start, end } = (() => {
|
|
if (rangePreset === 'today') return getTodayRange()
|
|
if (rangePreset === 'month') return getThisMonthRange()
|
|
return {
|
|
start: customStartStr ? new Date(customStartStr).toISOString() : undefined,
|
|
end: customEndStr ? new Date(customEndStr).toISOString() : undefined,
|
|
}
|
|
})()
|
|
// The server only accepts complete UTC quarters. Never send a future end.
|
|
const closedEnd = (() => {
|
|
const now = new Date()
|
|
now.setUTCMinutes(Math.floor(now.getUTCMinutes() / 15) * 15, 0, 0)
|
|
const selectedEnd = end ? new Date(end) : now
|
|
return new Date(Math.min(selectedEnd.getTime(), now.getTime())).toISOString()
|
|
})()
|
|
const recomputeStart = start
|
|
const recomputeAvailable = !!recomputeStart && new Date(recomputeStart) < new Date(closedEnd)
|
|
const qc = useQueryClient()
|
|
const resetLedgerPage = () => {
|
|
setLedgerOffset(0)
|
|
setExpandedRows(new Set())
|
|
}
|
|
const rows = useQuery({ queryKey: ['meter-costs', 'thermal', start, end, ledgerOffset], queryFn: async () => {
|
|
const result = await apiClient.GET('/api/energy/meter-costs', { params: { query: { scope: 'thermal', start, end, limit: COSTS_MAX_LIMIT, offset: ledgerOffset } } })
|
|
return result.data
|
|
} })
|
|
const meters = useQuery({ queryKey: ['energy-meters', 'thermal'], queryFn: async () => {
|
|
const result = await apiClient.GET('/api/energy/meters')
|
|
return result.data
|
|
} })
|
|
const summary = useQuery({ queryKey: ['meter-cost-summary', 'thermal', start, end], queryFn: async () => {
|
|
const result = await apiClient.GET('/api/energy/meter-costs/summary', { params: { query: { scope: 'thermal', start, end } } })
|
|
return result.data
|
|
} })
|
|
const recompute = useMutation({ mutationFn: () => apiClient.POST('/api/energy/meter-costs/recompute', { params: { query: { scope: 'thermal', start: recomputeStart!, end: closedEnd } } }), onSuccess: (result) => {
|
|
void qc.invalidateQueries({ queryKey: ['meter-costs', 'thermal'] }); void qc.invalidateQueries({ queryKey: ['meter-cost-summary', 'thermal'] })
|
|
setRecomputeSuccess(`Recomputed ${result.data?.processed ?? 0} closed periods.`)
|
|
} })
|
|
const currency = summary.data?.currency ?? rows.data?.items[0]?.currency ?? 'EUR'
|
|
const fixed = summary.data?.fixed_breakdown
|
|
const hasCurrentHeatingMeter = meters.data?.items.some((meter) => meter.commodity === 'heating' && meter.ended_at === null)
|
|
const hasCurrentHotWaterMeter = meters.data?.items.some((meter) => meter.commodity === 'hot_water' && meter.ended_at === null)
|
|
const missingCurrentMeters = [
|
|
...(hasCurrentHeatingMeter === false ? ['heating'] : []),
|
|
...(hasCurrentHotWaterMeter === false ? ['hot-water'] : []),
|
|
]
|
|
const totalRows = rows.data?.total ?? 0
|
|
const shownStart = totalRows === 0 ? 0 : ledgerOffset + 1
|
|
const shownEnd = Math.min(ledgerOffset + (rows.data?.items.length ?? 0), totalRows)
|
|
return <Stack gap="lg" data-testid="thermal-cost-view">
|
|
<Group align="flex-start" gap="md" wrap="wrap"><ScopeSelector scope="thermal" onScopeChange={onScopeChange} /><Stack gap="xs"><Text size="sm" fw={500}>Date range</Text><SegmentedControl value={rangePreset} onChange={(value) => { resetLedgerPage(); setRangePreset(value as RangePreset) }} data={[{ label: 'Today', value: 'today' }, { label: 'This month', value: 'month' }, { label: 'Custom', value: 'custom' }]} data-testid="thermal-cost-range-control" /></Stack>{rangePreset === 'custom' && <Group gap="sm" align="flex-end"><TextInput label="From" type="date" value={customStartStr} onChange={(event) => { resetLedgerPage(); setCustomStartStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-start" /><TextInput label="To" type="date" value={customEndStr} onChange={(event) => { resetLedgerPage(); setCustomEndStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-end" /></Group>}<Button variant="outline" color="orange" onClick={() => { setRecomputeError(null); setRecomputeSuccess(null); setShowConfirm(true) }} disabled={!recomputeAvailable} data-testid="thermal-recompute-button">Recompute</Button></Group>
|
|
{(rows.isLoading || summary.isLoading) && <Center><Loader size="sm" /></Center>}
|
|
{(rows.isError || summary.isError) && <Alert color="red">Failed to load thermal costs.</Alert>}
|
|
{recomputeError && <Alert color="red" data-testid="thermal-recompute-error">{recomputeError}</Alert>}
|
|
{recomputeSuccess && <Alert color="green" data-testid="thermal-recompute-success">{recomputeSuccess}</Alert>}
|
|
{summary.data && <Stack gap="xs" data-testid="thermal-cost-summary"><Title order={6}>{rangePreset === 'today' ? 'Today' : rangePreset === 'month' ? 'This month' : 'Custom range'} ({currency})</Title><Text size="sm" data-testid="thermal-cost-range">{start ?? 'Select a start date'} — {end ?? 'Select an end date'}</Text><SimpleGrid cols={{ base: 2, sm: 3 }}>
|
|
<SummaryCard label="Heating" value={hasCurrentHeatingMeter === false ? 'Not configured' : summary.data.heating} /><SummaryCard label="Hot-water heating" value={hasCurrentHotWaterMeter === false ? 'Not configured' : summary.data.hot_water_heating} /><SummaryCard label="Hot water" value={hasCurrentHotWaterMeter === false ? 'Not configured' : summary.data.hot_water} /><SummaryCard label="Hot-water tax" value={hasCurrentHotWaterMeter === false ? 'Not configured' : summary.data.hot_water_tax} /><SummaryCard label="Variable subtotal" value={summary.data.variable_subtotal} /><SummaryCard label="Fixed subtotal" value={summary.data.fixed_subtotal} /><SummaryCard label="All-in total" value={summary.data.all_in} />
|
|
</SimpleGrid>{missingCurrentMeters.length > 0 && <Alert color="yellow" data-testid="thermal-missing-current-meter">Current {missingCurrentMeters.join(' and ')} meter{missingCurrentMeters.length > 1 ? 's are' : ' is'} not configured. Historical ledger rows do not establish a current meter.</Alert>}<Text size="sm" data-testid="thermal-period-count">{summary.data.period_count} periods; {summary.data.degraded_count} degraded</Text>{summary.data.degraded_count > 0 && <Alert color="orange" data-testid="thermal-summary-degraded">Some totals include degraded periods. Expand a row to see its recorded reason.</Alert>}{fixed && <Text size="sm" data-testid="thermal-fixed-breakdown">Fixed once per settled local day (summary only): {Object.entries(fixed).map(([key, value]) => `${key} ${value}`).join(' · ')}</Text>}</Stack>}
|
|
{rows.data?.items.length === 0 && <Alert color="gray" data-testid="thermal-costs-empty">No thermal cost data for this range. Check that heating or hot-water meters are bound and have settled readings.</Alert>}
|
|
{rows.data && <Stack gap="xs"><Text size="sm" c="dimmed" data-testid="thermal-ledger-count">Showing {shownStart}-{shownEnd} of {totalRows}</Text>{rows.data.items.length > 0 && <ScrollArea><Table striped withTableBorder data-testid="thermal-costs-table"><Table.Thead><Table.Tr><Table.Th>Time</Table.Th><Table.Th>Commodity</Table.Th><Table.Th>Quantity</Table.Th><Table.Th>Cost</Table.Th><Table.Th>Breakdown</Table.Th><Table.Th>Status</Table.Th><Table.Th></Table.Th></Table.Tr></Table.Thead><Table.Tbody>{rows.data.items.flatMap((item, index) => [<Table.Tr key={`${item.commodity}-${item.period_start}`} data-testid={`thermal-cost-row-${index}`}><Table.Td>{formatLocalTime(item.period_start)}</Table.Td><Table.Td>{item.commodity}</Table.Td><Table.Td>{item.quantity}</Table.Td><Table.Td>{item.cost} {item.currency}</Table.Td><Table.Td>{Object.entries(item.cost_breakdown).map(([key, value]) => `${key} ${value}`).join(', ')}</Table.Td><Table.Td>{item.degraded ? <Badge color="orange" data-testid={`thermal-degraded-${index}`}>{item.degraded_reason ?? 'degraded'}</Badge> : 'normal'}</Table.Td><Table.Td><Button size="xs" variant="subtle" onClick={() => setExpandedRows((current) => { const next = new Set(current); if (next.has(index)) next.delete(index); else next.add(index); return next })} data-testid={`thermal-cost-expand-${index}`}>{expandedRows.has(index) ? 'Hide audit' : 'Audit'}</Button></Table.Td></Table.Tr>, ...(expandedRows.has(index) ? [<Table.Tr key={`audit-${item.commodity}-${item.period_start}`} data-testid={`thermal-cost-audit-${index}`}><Table.Td colSpan={7}><Text size="xs">Contract version: {item.contract_version_id ?? 'none'}</Text><Text size="xs">Pricing snapshot: {JSON.stringify(item.pricing_snapshot)}</Text></Table.Td></Table.Tr>] : [])])}</Table.Tbody></Table></ScrollArea>}<Group justify="flex-end"><Button size="xs" variant="default" disabled={ledgerOffset === 0} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => Math.max(0, current - COSTS_MAX_LIMIT)) }} data-testid="thermal-ledger-prev">Previous</Button><Button size="xs" variant="default" disabled={ledgerOffset + (rows.data.items.length ?? 0) >= totalRows} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => current + COSTS_MAX_LIMIT) }} data-testid="thermal-ledger-next">Next</Button></Group></Stack>}
|
|
{showConfirm && <Modal opened onClose={() => setShowConfirm(false)} title="Recompute thermal costs?" data-testid="thermal-recompute-confirm-modal"><Stack><Text>This explicitly overwrites closed 15-minute thermal ledger rows for {recomputeStart ?? 'the selected start'} — {closedEnd}. Continue?</Text>{!recomputeAvailable && <Alert color="yellow">Select a range containing at least one closed UTC quarter.</Alert>}<Group justify="flex-end"><Button variant="default" onClick={() => setShowConfirm(false)} data-testid="thermal-recompute-cancel">Cancel</Button><Button color="orange" loading={recompute.isPending} disabled={!recomputeAvailable} onClick={async () => { try { await recompute.mutateAsync(); setShowConfirm(false) } catch { setRecomputeError('Failed to recompute thermal costs. Please try again.'); setShowConfirm(false) } }} data-testid="thermal-recompute-confirm">Recompute</Button></Group></Stack></Modal>}
|
|
</Stack>
|
|
}
|