M8-R13: simplify prices and meter controls
This commit is contained in:
@@ -209,14 +209,55 @@ describe('MeterManager — meter list', () => {
|
|||||||
{ uuid: 'binding-active', source_uuid: 'source-new', source_channel_uuid: 'channel-new', started_at: '2025-02-01T00:00:00Z', ended_at: null },
|
{ uuid: 'binding-active', source_uuid: 'source-new', source_channel_uuid: 'channel-new', started_at: '2025-02-01T00:00:00Z', ended_at: null },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
mockGet.mockResolvedValue({ data: { items: [meterWithBindings], total: 1 } })
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [meterWithBindings], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [
|
||||||
|
{ uuid: 'source-old', name: 'Old source' }, { uuid: 'source-new', name: 'New source' },
|
||||||
|
], total: 2 } })
|
||||||
|
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.resolve({ data: { items: [
|
||||||
|
{ uuid: 'channel-old', label: 'Old channel', unit: 'kWh' }, { uuid: 'channel-new', label: 'New channel', unit: 'kWh' },
|
||||||
|
], total: 2 } })
|
||||||
|
return Promise.resolve({ data: { items: [] } })
|
||||||
|
})
|
||||||
renderWithProviders(<MeterManager />)
|
renderWithProviders(<MeterManager />)
|
||||||
const closed = await screen.findByTestId('binding-timeline-binding-closed')
|
const closed = await screen.findByTestId('binding-timeline-binding-closed')
|
||||||
|
await waitFor(() => expect(closed).toHaveTextContent('Old source → Old channel'))
|
||||||
const active = screen.getByTestId('binding-timeline-binding-active')
|
const active = screen.getByTestId('binding-timeline-binding-active')
|
||||||
expect(closed).toHaveTextContent('source-old → channel-old')
|
expect(closed).toHaveTextContent('Old source → Old channel')
|
||||||
expect(closed).toHaveTextContent('[1/1/2025, 00:00:00, 2/1/2025, 00:00:00) (closed)')
|
expect(closed).toHaveTextContent('[1/1/2025, 00:00:00, 2/1/2025, 00:00:00) (closed)')
|
||||||
expect(active).toHaveTextContent('source-new → channel-new')
|
expect(active).toHaveTextContent('New source → New channel')
|
||||||
expect(active).toHaveTextContent('[2/1/2025, 00:00:00, open-ended) (active)')
|
expect(active).toHaveTextContent('[2/1/2025, 00:00:00, open-ended) (active)')
|
||||||
|
expect(closed).not.toHaveTextContent('source-old')
|
||||||
|
expect(active).not.toHaveTextContent('channel-new')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses UUID-free honest binding fallbacks while source details load or fail', async () => {
|
||||||
|
const meter = { ...ACTIVE_METER, bindings: [{ uuid: 'binding-visible-id-only', source_uuid: 'private-source-id', source_channel_uuid: 'private-channel-id', started_at: '2025-01-01T00:00:00Z', ended_at: null }] }
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [meter], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources') return new Promise(() => {})
|
||||||
|
return Promise.resolve({ data: { items: [] } })
|
||||||
|
})
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
const timeline = await screen.findByTestId('binding-timeline-binding-visible-id-only')
|
||||||
|
expect(timeline).toHaveTextContent('Loading source details… → Channel details unavailable')
|
||||||
|
expect(timeline).not.toHaveTextContent('private-source-id')
|
||||||
|
expect(timeline).not.toHaveTextContent('private-channel-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps meter row actions in the shared light, xs order', async () => {
|
||||||
|
const binding = { uuid: 'ordered-binding', source_uuid: 'source-1', source_channel_uuid: 'channel-1', started_at: '2024-01-15T00:00:00Z', ended_at: null }
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{ ...ACTIVE_METER, bindings: [binding] }], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }], total: 1 } })
|
||||||
|
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.resolve({ data: { items: [{ uuid: 'channel-1', label: 'Total import', unit: 'kWh' }], total: 1 } })
|
||||||
|
return Promise.resolve({ data: { items: [] } })
|
||||||
|
})
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
const row = await screen.findByTestId(`meter-row-${ACTIVE_METER.id}`)
|
||||||
|
const buttons = Array.from(row.querySelectorAll('button'))
|
||||||
|
expect(buttons.map((button) => button.textContent)).toEqual(['Edit', 'Transfer source', 'Unbind', 'Close meter'])
|
||||||
|
expect(buttons).toHaveLength(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders "Declare New Meter" button', async () => {
|
it('renders "Declare New Meter" button', async () => {
|
||||||
|
|||||||
@@ -167,6 +167,34 @@ function recoveryTargetFor(meter: MeterResponse, meters: MeterResponse[]): Meter
|
|||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BindingTimeline({ binding, sources }: {
|
||||||
|
binding: NonNullable<MeterResponse['bindings']>[number]
|
||||||
|
sources: ReturnType<typeof useSources>
|
||||||
|
}) {
|
||||||
|
const source = sources.data?.items.find((item) => item.uuid === binding.source_uuid)
|
||||||
|
const channels = useSourceChannels(source?.uuid ?? null)
|
||||||
|
const channel = channels.data?.items.find((item) => item.uuid === binding.source_channel_uuid)
|
||||||
|
let endpoint = 'Source details unavailable'
|
||||||
|
if (sources.isLoading) endpoint = 'Loading source details…'
|
||||||
|
else if (!sources.isError && source) endpoint = source.name
|
||||||
|
else if (!sources.isError) endpoint = 'Source unavailable'
|
||||||
|
|
||||||
|
let channelName = 'Channel details unavailable'
|
||||||
|
if (source && channels.isLoading) channelName = 'Loading channel details…'
|
||||||
|
else if (source && !channels.isError && channel) channelName = channel.label
|
||||||
|
else if (source && !channels.isError) channelName = 'Channel unavailable'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={0} mb="xs" data-testid={`binding-timeline-${binding.uuid}`}>
|
||||||
|
<Text size="xs">{endpoint} → {channelName}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
[{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'})
|
||||||
|
{' '}({binding.ended_at ? 'closed' : 'active'})
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Declare meter form (modal)
|
// Declare meter form (modal)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -515,11 +543,12 @@ function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
|
|||||||
|
|
||||||
interface MeterTableProps {
|
interface MeterTableProps {
|
||||||
meters: MeterResponse[]
|
meters: MeterResponse[]
|
||||||
|
sources: ReturnType<typeof useSources>
|
||||||
onEdit: (meter: MeterResponse) => void
|
onEdit: (meter: MeterResponse) => void
|
||||||
onClose: (meter: MeterResponse) => void
|
onClose: (meter: MeterResponse) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
|
function MeterTable({ meters, sources, onEdit, onClose }: MeterTableProps) {
|
||||||
if (meters.length === 0) {
|
if (meters.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Text c="dimmed" ta="center" size="sm" data-testid="meters-empty">
|
<Text c="dimmed" ta="center" size="sm" data-testid="meters-empty">
|
||||||
@@ -585,30 +614,24 @@ function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{meter.bindings?.length ? meter.bindings.map((binding) => (
|
{meter.bindings?.length ? meter.bindings.map((binding) => (
|
||||||
<Stack key={binding.uuid} gap={0} mb="xs" data-testid={`binding-timeline-${binding.uuid}`}>
|
<BindingTimeline key={binding.uuid} binding={binding} sources={sources} />
|
||||||
<Text size="xs">{binding.source_uuid} → {binding.source_channel_uuid}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
[{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'})
|
|
||||||
{' '}({binding.ended_at ? 'closed' : 'active'})
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
)) : <Text size="xs" c="dimmed">Unbound</Text>}
|
)) : <Text size="xs" c="dimmed">Unbound</Text>}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group justify="flex-end" gap="xs">
|
<Group justify="flex-end" gap="xs">
|
||||||
{meter.bindings?.filter((binding) => binding.ended_at === null).map((binding) => (
|
|
||||||
<BindingActions key={binding.uuid} meter={meter} binding={binding} meters={meters} />
|
|
||||||
))}
|
|
||||||
{isActive && !meter.bindings?.some((binding) => binding.ended_at === null) && <DirectBindButton meter={meter} meters={meters} />}
|
|
||||||
{isActive && <Button size="xs" color="red" variant="light" onClick={() => onClose(meter)}>Close meter</Button>}
|
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="outline"
|
variant="light"
|
||||||
onClick={() => onEdit(meter)}
|
onClick={() => onEdit(meter)}
|
||||||
data-testid={`meter-edit-${meter.id}`}
|
data-testid={`meter-edit-${meter.id}`}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
|
{meter.bindings?.filter((binding) => binding.ended_at === null).map((binding) => (
|
||||||
|
<BindingActions key={binding.uuid} meter={meter} binding={binding} meters={meters} />
|
||||||
|
))}
|
||||||
|
{isActive && !meter.bindings?.some((binding) => binding.ended_at === null) && <DirectBindButton meter={meter} meters={meters} />}
|
||||||
|
{isActive && <Button size="xs" color="red" variant="light" onClick={() => onClose(meter)}>Close meter</Button>}
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -622,7 +645,7 @@ function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
|
|||||||
|
|
||||||
function DirectBindButton({ meter, meters }: { meter: MeterResponse; meters: MeterResponse[] }) {
|
function DirectBindButton({ meter, meters }: { meter: MeterResponse; meters: MeterResponse[] }) {
|
||||||
const [opened, setOpened] = useState(false)
|
const [opened, setOpened] = useState(false)
|
||||||
return <>{<Button size="xs" variant="subtle" onClick={() => setOpened(true)}>Bind source</Button>}{opened && <DirectBindModal meter={meter} meters={meters} onClose={() => setOpened(false)} />}</>
|
return <>{<Button size="xs" variant="light" onClick={() => setOpened(true)}>Bind source</Button>}{opened && <DirectBindModal meter={meter} meters={meters} onClose={() => setOpened(false)} />}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function DirectBindModal({ meter, meters, onClose }: { meter: MeterResponse; meters: MeterResponse[]; onClose: () => void }) {
|
function DirectBindModal({ meter, meters, onClose }: { meter: MeterResponse; meters: MeterResponse[]; onClose: () => void }) {
|
||||||
@@ -676,8 +699,8 @@ function BindingActions({ meter, binding, meters }: { meter: MeterResponse; bind
|
|||||||
const recoverySourceIsClosable = meter.ended_at === null ||
|
const recoverySourceIsClosable = meter.ended_at === null ||
|
||||||
parseBackendTimestamp(binding.started_at).getTime() < parseBackendTimestamp(meter.ended_at).getTime()
|
parseBackendTimestamp(binding.started_at).getTime() < parseBackendTimestamp(meter.ended_at).getTime()
|
||||||
return <>
|
return <>
|
||||||
<Button size="xs" variant="subtle" onClick={() => setUnbindOpened(true)}>Unbind</Button>
|
{meter.ended_at === null ? <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Transfer source</Button> : recoveryTarget && recoverySourceIsClosable && <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Recover binding</Button>}
|
||||||
{meter.ended_at === null ? <Button size="xs" variant="subtle" onClick={() => setTransferOpened(true)}>Transfer source</Button> : recoveryTarget && recoverySourceIsClosable && <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Recover binding</Button>}
|
<Button size="xs" variant="light" onClick={() => setUnbindOpened(true)}>Unbind</Button>
|
||||||
{meter.ended_at !== null && recoveryTarget && !recoverySourceIsClosable && <Text size="xs" c="red">Cannot recover: the source binding starts at or after this Meter ended.</Text>}
|
{meter.ended_at !== null && recoveryTarget && !recoverySourceIsClosable && <Text size="xs" c="red">Cannot recover: the source binding starts at or after this Meter ended.</Text>}
|
||||||
{unbindOpened && <UnbindModal meter={meter} binding={binding} onClose={() => setUnbindOpened(false)} />}
|
{unbindOpened && <UnbindModal meter={meter} binding={binding} onClose={() => setUnbindOpened(false)} />}
|
||||||
{transferOpened && <TransferModal target={recoveryTarget ?? meter} sourceBinding={binding} meters={meters} recovery={!!recoveryTarget} onClose={() => setTransferOpened(false)} />}
|
{transferOpened && <TransferModal target={recoveryTarget ?? meter} sourceBinding={binding} meters={meters} recovery={!!recoveryTarget} onClose={() => setTransferOpened(false)} />}
|
||||||
@@ -793,6 +816,7 @@ function CloseMeterModal({ meter, onClose }: { meter: MeterResponse; onClose: ()
|
|||||||
|
|
||||||
export function MeterManager() {
|
export function MeterManager() {
|
||||||
const metersQuery = useMeters()
|
const metersQuery = useMeters()
|
||||||
|
const sources = useSources()
|
||||||
|
|
||||||
const [showDeclareForm, setShowDeclareForm] = useState(false)
|
const [showDeclareForm, setShowDeclareForm] = useState(false)
|
||||||
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
|
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
|
||||||
@@ -845,7 +869,7 @@ export function MeterManager() {
|
|||||||
</Notification>
|
</Notification>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MeterTable meters={meters} onEdit={(m) => setEditMeter(m)} onClose={(m) => setCloseMeter(m)} />
|
<MeterTable meters={meters} sources={sources} onEdit={(m) => setEditMeter(m)} onClose={(m) => setCloseMeter(m)} />
|
||||||
|
|
||||||
{/* Declare new meter */}
|
{/* Declare new meter */}
|
||||||
{showDeclareForm && (
|
{showDeclareForm && (
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ describe('TibberPrices', () => {
|
|||||||
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
|
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps thermal prices scoped and renders the complete D11 Decimal snapshot with units', async () => {
|
it('keeps thermal prices scoped and renders the complete D11 Decimal snapshot as a table', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const thermal = {
|
const thermal = {
|
||||||
kind: 'district_heating', currency: 'EUR', points: [], tariff: null,
|
kind: 'district_heating', currency: 'EUR', points: [], tariff: null,
|
||||||
@@ -273,15 +273,19 @@ describe('TibberPrices', () => {
|
|||||||
await waitFor(() => expect(screen.getByTestId('thermal-price-snapshot')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('thermal-price-snapshot')).toBeInTheDocument())
|
||||||
|
|
||||||
const snapshot = screen.getByTestId('thermal-price-snapshot')
|
const snapshot = screen.getByTestId('thermal-price-snapshot')
|
||||||
|
const table = screen.getByTestId('thermal-price-table')
|
||||||
expect(snapshot).toHaveTextContent('Version 42')
|
expect(snapshot).toHaveTextContent('Version 42')
|
||||||
expect(snapshot).toHaveTextContent('effective 2026-01-01T00:00:00Z to 2026-12-31T00:00:00Z')
|
expect(snapshot).toHaveTextContent('effective 2026-01-01T00:00:00Z to 2026-12-31T00:00:00Z')
|
||||||
expect(snapshot).toHaveTextContent('not a 15-minute market spot price')
|
expect(snapshot).toHaveTextContent('not a 15-minute market spot price')
|
||||||
expect(snapshot).toHaveTextContent('20.123456789123456789 EUR/GJ')
|
expect(screen.getAllByRole('columnheader').map((header) => header.textContent)).toEqual([
|
||||||
expect(snapshot).toHaveTextContent('8.200000000000000001 EUR/m³')
|
'Category', 'Charge', 'Rate', 'Unit',
|
||||||
expect(snapshot).toHaveTextContent('1.234567890123456789 EUR/m³')
|
])
|
||||||
expect(snapshot).toHaveTextContent('0.456789012345678901 EUR/m³')
|
expect(table.querySelectorAll('tbody tr')).toHaveLength(9)
|
||||||
|
expect(screen.getByTestId('thermal-price-row-variable-heating')).toHaveTextContent('VariableHeating20.123456789123456789EUR/GJ')
|
||||||
|
expect(screen.getByTestId('thermal-price-row-variable-hot_water_heating')).toHaveTextContent('Hot Water Heating8.200000000000000001EUR/m³')
|
||||||
|
expect(screen.getByTestId('thermal-price-row-standing-heating_network')).toHaveTextContent('StandingHeating Network100.000000000000000001EUR/year')
|
||||||
for (const value of Object.values(thermal.values.standing)) {
|
for (const value of Object.values(thermal.values.standing)) {
|
||||||
expect(snapshot).toHaveTextContent(`${value} EUR/year`)
|
expect(table).toHaveTextContent(value)
|
||||||
}
|
}
|
||||||
expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', expect.objectContaining({
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', expect.objectContaining({
|
||||||
params: { query: expect.objectContaining({ scope: 'thermal' }) },
|
params: { query: expect.objectContaining({ scope: 'thermal' }) },
|
||||||
@@ -292,6 +296,19 @@ describe('TibberPrices', () => {
|
|||||||
expect(screen.queryByTestId('thermal-price-snapshot')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('thermal-price-snapshot')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps unknown thermal snapshot fields with safe human-readable labels', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockImplementation((_path: string, options?: { params?: { query?: { scope?: string } } }) => Promise.resolve({
|
||||||
|
data: options?.params?.query?.scope === 'thermal'
|
||||||
|
? { kind: 'district_heating', currency: 'EUR', values: { future_fee: { experimental_charge: '1.000000000000000001' } } }
|
||||||
|
: { kind: 'manual', currency: 'EUR', points: [], tariff: { buy_dal: 0.1, buy_normal: 0.2, sell_dal: 0.03, sell_normal: 0.04 } },
|
||||||
|
}))
|
||||||
|
renderWithProviders(<TibberPrices />)
|
||||||
|
await user.click(await screen.findByText('Thermal'))
|
||||||
|
const row = await screen.findByTestId('thermal-price-row-future_fee-experimental_charge')
|
||||||
|
expect(row).toHaveTextContent('Future FeeExperimental Charge1.000000000000000001EUR')
|
||||||
|
})
|
||||||
|
|
||||||
it('marks the currently active price slot with a dot and a caption', async () => {
|
it('marks the currently active price slot with a dot and a caption', async () => {
|
||||||
installChartSize()
|
installChartSize()
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
|
ScrollArea,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
@@ -60,6 +61,19 @@ function thermalUnit(section: string, key: string, currency: string): string {
|
|||||||
return currency
|
return currency
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function humanizeThermalField(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function thermalSections(values: Record<string, Record<string, string>>): string[] {
|
||||||
|
const preferred = ['variable', 'standing']
|
||||||
|
return [...preferred.filter((section) => section in values), ...Object.keys(values)
|
||||||
|
.filter((section) => !preferred.includes(section))
|
||||||
|
.sort()]
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Time range helpers
|
// Time range helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -399,13 +413,28 @@ export function TibberPrices() {
|
|||||||
<Text fw={500}>Thermal contract snapshot</Text>
|
<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">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>
|
<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]) => (
|
<ScrollArea type="auto">
|
||||||
<Text size="sm" key={section} data-testid={`thermal-price-section-${section}`}>
|
<Table withTableBorder withColumnBorders data-testid="thermal-price-table">
|
||||||
{section}: {Object.entries(values).map(([key, value]) =>
|
<Table.Thead>
|
||||||
`${key} ${value} ${thermalUnit(section, key, currency)}`,
|
<Table.Tr>
|
||||||
).join(', ')}
|
<Table.Th>Category</Table.Th>
|
||||||
</Text>
|
<Table.Th>Charge</Table.Th>
|
||||||
))}
|
<Table.Th>Rate</Table.Th>
|
||||||
|
<Table.Th>Unit</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{thermalSections(data.values!).flatMap((section) => Object.entries(data.values![section]).map(([key, rate]) => (
|
||||||
|
<Table.Tr key={`${section}-${key}`} data-testid={`thermal-price-row-${section}-${key}`}>
|
||||||
|
<Table.Td>{humanizeThermalField(section)}</Table.Td>
|
||||||
|
<Table.Td>{humanizeThermalField(key)}</Table.Td>
|
||||||
|
<Table.Td>{rate}</Table.Td>
|
||||||
|
<Table.Td>{thermalUnit(section, key, currency)}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user