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,407 @@
|
||||
/**
|
||||
* 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 {
|
||||
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'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cost limit — prevent accidental full-table pulls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COSTS_MAX_LIMIT = 500
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date range helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getTodayRange(): { start: string; end: string } {
|
||||
const start = new Date()
|
||||
start.setUTCHours(0, 0, 0, 0)
|
||||
const end = new Date(start)
|
||||
end.setUTCDate(end.getUTCDate() + 1)
|
||||
return { start: start.toISOString(), end: end.toISOString() }
|
||||
}
|
||||
|
||||
function getThisMonthRange(): { start: string; end: string } {
|
||||
const now = new Date()
|
||||
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
|
||||
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1))
|
||||
return { start: start.toISOString(), end: end.toISOString() }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SummaryCardProps {
|
||||
label: string
|
||||
value: string
|
||||
testId?: string
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, 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>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CostView — main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RangePreset = 'today' | 'month' | 'custom'
|
||||
|
||||
export function CostView() {
|
||||
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">
|
||||
<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.toFixed(3)}
|
||||
testId="summary-import"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Export (kWh)"
|
||||
value={summaryQuery.data.metered_export.toFixed(3)}
|
||||
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: new Date(item.period_start).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
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">
|
||||
{new Date(item.period_start).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user