M8-R12: align and format thermal cost UI
This commit is contained in:
@@ -79,6 +79,23 @@ const THERMAL_SUMMARY = {
|
||||
period_count: 4, degraded_count: 2,
|
||||
fixed_breakdown: { heating_network: '0.1', metering: '0.1', delivery_set: '0', hot_water_network: '0.1', other: '0.2' },
|
||||
}
|
||||
const ROUNDED_THERMAL_SUMMARY = {
|
||||
...THERMAL_SUMMARY,
|
||||
heating: '20.12344',
|
||||
hot_water_heating: '8.20005',
|
||||
hot_water: '1.99995',
|
||||
hot_water_tax: '0.40000',
|
||||
variable_subtotal: '7.0000',
|
||||
fixed_subtotal: '0.00004',
|
||||
all_in: '9.99995',
|
||||
fixed_breakdown: {
|
||||
heating_network: '100.000000000000000001',
|
||||
metering: '0.0000',
|
||||
delivery_set: '20.20000',
|
||||
hot_water_network: '30.30004',
|
||||
other: '40.40005',
|
||||
},
|
||||
}
|
||||
const THERMAL_VALUES = {
|
||||
variable: {
|
||||
heating: '20.123456789123456789', hot_water_heating: '8.200000000000000001',
|
||||
@@ -95,6 +112,12 @@ const THERMAL_ROW = {
|
||||
cost_breakdown: { heating: '0.123456789' }, pricing_snapshot: THERMAL_VALUES,
|
||||
quality: 'unverifiable', degraded: false, degraded_reason: null,
|
||||
}
|
||||
const ROUNDED_THERMAL_ROW = {
|
||||
...THERMAL_ROW,
|
||||
quantity: '1.23456',
|
||||
cost: '0.12344',
|
||||
cost_breakdown: { heating: '0.12345', hot_water: '0.10000' },
|
||||
}
|
||||
const THERMAL_ROW_OTHER_VERSION = {
|
||||
...THERMAL_ROW, commodity: 'hot_water', period_start: '2026-06-22T10:15:00Z', period_end: '2026-06-22T10:30:00Z',
|
||||
contract_version_id: 100, quantity: '2.3', cost: '4.339506172839506170',
|
||||
@@ -233,6 +256,32 @@ describe('CostView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the same bottom-aligned toolbar structure in both scopes', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
|
||||
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
|
||||
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||
})
|
||||
|
||||
renderWithProviders(<CostView />)
|
||||
|
||||
const electricityToolbar = screen.getByTestId('cost-toolbar')
|
||||
expect(electricityToolbar).toContainElement(screen.getByTestId('costs-scope-selector'))
|
||||
expect(electricityToolbar).toContainElement(screen.getByTestId('cost-range-control'))
|
||||
expect(electricityToolbar).toContainElement(screen.getByTestId('cost-recompute-button'))
|
||||
expect(screen.getByTestId('cost-recompute-button')).toHaveStyle({ marginLeft: 'auto' })
|
||||
|
||||
await user.click(screen.getByTestId('costs-scope-selector'))
|
||||
await user.click(screen.getByText('Thermal'))
|
||||
await waitFor(() => expect(screen.getByTestId('thermal-cost-toolbar')).toBeInTheDocument())
|
||||
const thermalToolbar = screen.getByTestId('thermal-cost-toolbar')
|
||||
expect(thermalToolbar).toContainElement(screen.getByTestId('costs-scope-selector'))
|
||||
expect(thermalToolbar).toContainElement(screen.getByTestId('thermal-cost-range-control'))
|
||||
expect(thermalToolbar).toContainElement(screen.getByTestId('thermal-recompute-button'))
|
||||
expect(screen.getByTestId('thermal-recompute-button')).toHaveStyle({ marginLeft: 'auto' })
|
||||
})
|
||||
|
||||
it('calls recompute mutation when confirmed', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -349,8 +398,8 @@ describe('CostView', () => {
|
||||
it.each([
|
||||
['only heating', [ACTIVE_HEATING_METER], 'hot-water meter is not configured', 'Not configured'],
|
||||
['only hot water', [ACTIVE_HOT_WATER_METER], 'heating meter is not configured', 'Not configured'],
|
||||
['both current meters', [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'],
|
||||
['a replaced heating meter plus its current epoch', [ENDED_HEATING_METER, ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'],
|
||||
['both current meters', [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.1'],
|
||||
['a replaced heating meter plus its current epoch', [ENDED_HEATING_METER, ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.1'],
|
||||
])('uses active meter epochs for %s without treating zero amounts as missing', async (_name, meterItems, missingText, heatingValue) => {
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: meterItems, total: meterItems.length } })
|
||||
@@ -373,6 +422,44 @@ describe('CostView', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rounds ordinary thermal Decimal strings without changing the audit snapshot', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === '/api/energy/meters') {
|
||||
return Promise.resolve({ data: { items: [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], total: 2 } })
|
||||
}
|
||||
if (path === '/api/energy/meter-costs') {
|
||||
return Promise.resolve({ data: { items: [ROUNDED_THERMAL_ROW], total: 1 } })
|
||||
}
|
||||
if (path === '/api/energy/meter-costs/summary') {
|
||||
return Promise.resolve({ data: ROUNDED_THERMAL_SUMMARY })
|
||||
}
|
||||
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
|
||||
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||
})
|
||||
|
||||
renderWithProviders(<CostView />)
|
||||
await user.click(screen.getByTestId('costs-scope-selector'))
|
||||
await user.click(screen.getByText('Thermal'))
|
||||
await waitFor(() => expect(screen.getByTestId('thermal-cost-summary')).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Heating20.1234')
|
||||
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Hot-water heating8.2001')
|
||||
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Hot water2')
|
||||
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Hot-water tax0.4')
|
||||
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Variable subtotal7')
|
||||
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Fixed subtotal0')
|
||||
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('All-in total10')
|
||||
expect(screen.getByTestId('thermal-fixed-breakdown')).toHaveTextContent('heating_network 100')
|
||||
expect(screen.getByTestId('thermal-fixed-breakdown')).toHaveTextContent('other 40.4001')
|
||||
expect(screen.getByTestId('thermal-cost-row-0')).toHaveTextContent('1.2346')
|
||||
expect(screen.getByTestId('thermal-cost-row-0')).toHaveTextContent('0.1234 EUR')
|
||||
expect(screen.getByTestId('thermal-cost-row-0')).toHaveTextContent('heating 0.1235, hot_water 0.1')
|
||||
|
||||
await user.click(screen.getByTestId('thermal-cost-expand-0'))
|
||||
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('20.123456789123456789')
|
||||
})
|
||||
|
||||
it('paginates the complete thermal ledger and resets offset when its range or scope changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const lastRow = { ...THERMAL_ROW, period_start: '2026-06-22T12:00:00Z', quantity: '501' }
|
||||
|
||||
@@ -110,6 +110,49 @@ function SummaryCard({ label, value, sub, testId }: SummaryCardProps) {
|
||||
|
||||
type RangePreset = 'today' | 'month' | 'custom'
|
||||
|
||||
/** Format an API Decimal string without converting it through binary floating point. */
|
||||
function formatDecimal(value: string): string {
|
||||
const negative = value.startsWith('-')
|
||||
const unsigned = negative ? value.slice(1) : value
|
||||
const [rawWhole = '0', rawFraction = ''] = unsigned.split('.')
|
||||
let whole = rawWhole.replace(/^0+(?=\d)/, '') || '0'
|
||||
let fraction = rawFraction.slice(0, 4)
|
||||
|
||||
if (rawFraction.length > 4 && rawFraction[4] >= '5') {
|
||||
const digits = '0123456789'
|
||||
const fractionDigits = fraction.split('')
|
||||
let carry = true
|
||||
for (let index = fractionDigits.length - 1; index >= 0 && carry; index -= 1) {
|
||||
const digit = fractionDigits[index]
|
||||
if (digit === '9') {
|
||||
fractionDigits[index] = '0'
|
||||
} else {
|
||||
fractionDigits[index] = digits[digits.indexOf(digit) + 1]
|
||||
carry = false
|
||||
}
|
||||
}
|
||||
fraction = fractionDigits.join('')
|
||||
|
||||
if (carry) {
|
||||
const wholeDigits = whole.split('')
|
||||
for (let index = wholeDigits.length - 1; index >= 0 && carry; index -= 1) {
|
||||
const digit = wholeDigits[index]
|
||||
if (digit === '9') {
|
||||
wholeDigits[index] = '0'
|
||||
} else {
|
||||
wholeDigits[index] = digits[digits.indexOf(digit) + 1]
|
||||
carry = false
|
||||
}
|
||||
}
|
||||
whole = `${carry ? '1' : ''}${wholeDigits.join('')}`
|
||||
}
|
||||
}
|
||||
|
||||
fraction = fraction.replace(/0+$/, '')
|
||||
const formatted = fraction ? `${whole}.${fraction}` : whole
|
||||
return negative && formatted !== '0' ? `-${formatted}` : formatted
|
||||
}
|
||||
|
||||
export function CostView() {
|
||||
const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity')
|
||||
if (scope === 'thermal') return <ThermalCostView onScopeChange={setScope} />
|
||||
@@ -155,7 +198,7 @@ function ElectricityCostView({ onScopeChange }: { onScopeChange: (scope: 'electr
|
||||
return (
|
||||
<Stack gap="lg" data-testid="cost-view">
|
||||
{/* Date range selector */}
|
||||
<Group align="flex-start" gap="md" wrap="wrap">
|
||||
<Group align="flex-end" gap="md" wrap="wrap" data-testid="cost-toolbar">
|
||||
<ScopeSelector scope="electricity" onScopeChange={onScopeChange} />
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
@@ -192,19 +235,18 @@ function ElectricityCostView({ onScopeChange }: { onScopeChange: (scope: 'electr
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap="sm" style={{ marginLeft: 'auto' }} align="flex-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="orange"
|
||||
size="sm"
|
||||
onClick={() => setShowRecomputeConfirm(true)}
|
||||
loading={recomputeMutation.isPending}
|
||||
style={{ marginLeft: 'auto' }}
|
||||
data-testid="cost-recompute-button"
|
||||
>
|
||||
Recompute
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Summary cards */}
|
||||
{summaryQuery.isLoading && (
|
||||
@@ -483,16 +525,16 @@ function ThermalCostView({ onScopeChange }: { onScopeChange: (scope: 'electricit
|
||||
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>
|
||||
<Group align="flex-end" gap="md" wrap="wrap" data-testid="thermal-cost-toolbar"><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} style={{ marginLeft: 'auto' }} 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>}
|
||||
<SummaryCard label="Heating" value={hasCurrentHeatingMeter === false ? 'Not configured' : formatDecimal(summary.data.heating)} /><SummaryCard label="Hot-water heating" value={hasCurrentHotWaterMeter === false ? 'Not configured' : formatDecimal(summary.data.hot_water_heating)} /><SummaryCard label="Hot water" value={hasCurrentHotWaterMeter === false ? 'Not configured' : formatDecimal(summary.data.hot_water)} /><SummaryCard label="Hot-water tax" value={hasCurrentHotWaterMeter === false ? 'Not configured' : formatDecimal(summary.data.hot_water_tax)} /><SummaryCard label="Variable subtotal" value={formatDecimal(summary.data.variable_subtotal)} /><SummaryCard label="Fixed subtotal" value={formatDecimal(summary.data.fixed_subtotal)} /><SummaryCard label="All-in total" value={formatDecimal(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} ${formatDecimal(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>}
|
||||
{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>{formatDecimal(item.quantity)}</Table.Td><Table.Td>{formatDecimal(item.cost)} {item.currency}</Table.Td><Table.Td>{Object.entries(item.cost_breakdown).map(([key, value]) => `${key} ${formatDecimal(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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user