Tau.Acuvim/portal/frontend/src/pages/MeasurementsPage.tsx
Diseri Pearson 94ace2df0e Portal frontend: apply Prettier formatting baseline
One-time `prettier --write` across the 22 source files not already touched
by the preceding tooling commit (b5ceedc). Pure formatting — no behaviour
change, no logic change. Reviewable as "all whitespace/reflow".

From here the lint-staged pre-commit hook keeps new and edited code
consistent automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:47:08 +02:00

308 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from 'react';
import {
Alert,
Button,
Card,
DatePicker,
InputNumber,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import dayjs, { type Dayjs } from 'dayjs';
import {
fetchRawMeasurements,
listAllDevices,
downloadRawMeasurementsExport,
type RawMeasurementRow,
} from '../api/measurements';
const { Text } = Typography;
const { RangePicker } = DatePicker;
// Hard cap on the in-page preview to keep the table fast. Excel export has its
// own cap (250 000 backend-side) since users actually want the whole dataset.
const PREVIEW_LIMIT_OPTIONS = [100, 200, 500, 1000];
export function MeasurementsPage() {
const [range, setRange] = useState<[Dayjs, Dayjs]>(() => [
dayjs().subtract(1, 'day').startOf('day'),
dayjs().add(1, 'day').startOf('day'),
]);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const [limit, setLimit] = useState<number>(200);
const [offset, setOffset] = useState<number>(0);
const [exportRowCap, setExportRowCap] = useState<number>(100_000);
const fromIso = range[0].toISOString();
const toIso = range[1].toISOString();
const { data: devices = [], isLoading: loadingDevices } = useQuery({
queryKey: ['all-devices'],
queryFn: listAllDevices,
});
const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: [
'raw-measurements',
fromIso,
toIso,
selectedDeviceIds.sort().join(','),
limit,
offset,
],
queryFn: () =>
fetchRawMeasurements({
fromUtc: fromIso,
toUtc: toIso,
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined,
limit,
offset,
}),
placeholderData: keepPreviousData,
});
const deviceOptions = useMemo(
() =>
devices.map((d) => ({
value: d.id,
label: `${d.name}${d.siteName}`,
disabled: !d.isActive,
})),
[devices],
);
const columns: ColumnsType<RawMeasurementRow> = [
{
title: 'Time (UTC)',
dataIndex: 'time',
key: 't',
width: 180,
render: (v: string) => new Date(v).toISOString().replace('T', ' ').slice(0, 19),
},
{
title: 'Device',
dataIndex: 'deviceName',
key: 'd',
render: (v: string) => <Text strong>{v}</Text>,
},
{
title: 'Site',
dataIndex: 'siteName',
key: 's',
render: (v: string | null) => v ?? <Text type="secondary"></Text>,
},
{
title: 'Active kW',
dataIndex: 'activePowerKw',
key: 'kw',
align: 'right' as const,
render: (v: number) => v.toFixed(3),
},
{
title: 'kWh imported (cum.)',
dataIndex: 'energyImportedKwh',
key: 'imp',
align: 'right' as const,
render: (v: number | null) => (v == null ? '—' : v.toFixed(2)),
},
{
title: 'kWh exported (cum.)',
dataIndex: 'energyExportedKwh',
key: 'exp',
align: 'right' as const,
render: (v: number | null) => (v == null ? '—' : v.toFixed(2)),
},
{
title: 'PF',
dataIndex: 'powerFactor',
key: 'pf',
align: 'right' as const,
render: (v: number | null) => (v == null ? '—' : v.toFixed(3)),
},
{
title: 'V',
dataIndex: 'voltageV',
key: 'v',
align: 'right' as const,
render: (v: number | null) => (v == null ? '—' : v.toFixed(1)),
},
{
title: 'Hz',
dataIndex: 'frequencyHz',
key: 'hz',
align: 'right' as const,
render: (v: number | null) => (v == null ? '—' : v.toFixed(2)),
},
];
const totalCount = data?.totalCount ?? 0;
const showingFrom = totalCount === 0 ? 0 : offset + 1;
const showingTo = Math.min(offset + limit, totalCount);
const canPrev = offset > 0;
const canNext = offset + limit < totalCount;
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" title="Filters">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Space wrap>
<Text strong>Range (UTC):</Text>
<RangePicker
allowClear={false}
value={range}
showTime={false}
onChange={(v) =>
v &&
v[0] &&
v[1] &&
(setRange([v[0].startOf('day'), v[1].endOf('day')]), setOffset(0))
}
ranges={{
Today: [dayjs().startOf('day'), dayjs().add(1, 'day').startOf('day')],
Yesterday: [dayjs().subtract(1, 'day').startOf('day'), dayjs().startOf('day')],
'Last 7d': [
dayjs().subtract(7, 'day').startOf('day'),
dayjs().add(1, 'day').startOf('day'),
],
'Last 30d': [
dayjs().subtract(30, 'day').startOf('day'),
dayjs().add(1, 'day').startOf('day'),
],
'This month': [dayjs().startOf('month'), dayjs().add(1, 'day').startOf('day')],
'All time': [dayjs('2020-01-01'), dayjs().add(1, 'day').startOf('day')],
}}
/>
</Space>
<Space wrap style={{ width: '100%' }}>
<Text strong style={{ minWidth: 80 }}>
Meters:
</Text>
<Select
mode="multiple"
allowClear
placeholder={loadingDevices ? 'Loading devices…' : 'All meters (leave empty)'}
style={{ minWidth: 360, flex: 1 }}
value={selectedDeviceIds}
onChange={(v) => {
setSelectedDeviceIds(v);
setOffset(0);
}}
options={deviceOptions}
maxTagCount="responsive"
showSearch
optionFilterProp="label"
/>
</Space>
<Space wrap>
<Text strong>Preview rows:</Text>
<Select
value={limit}
onChange={(v) => {
setLimit(v);
setOffset(0);
}}
options={PREVIEW_LIMIT_OPTIONS.map((n) => ({ value: n, label: n.toString() }))}
style={{ width: 96 }}
/>
<Text type="secondary">·</Text>
<Text strong>Export row cap:</Text>
<InputNumber
value={exportRowCap}
min={100}
max={250_000}
step={10_000}
onChange={(v) => setExportRowCap(typeof v === 'number' ? v : 100_000)}
style={{ width: 130 }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
max 250 000 shrink the range if you exceed this
</Text>
</Space>
</Space>
</Card>
{error && (
<Alert
type="error"
showIcon
message="Failed to load measurements"
description={(error as Error).message}
/>
)}
<Card
title={
<Space>
<Text strong>Measurements</Text>
<Tag color="blue">{totalCount.toLocaleString()} rows match</Tag>
{selectedDeviceIds.length > 0 && (
<Tag>
{selectedDeviceIds.length} meter{selectedDeviceIds.length === 1 ? '' : 's'} selected
</Tag>
)}
</Space>
}
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => refetch()} loading={isFetching}>
Refresh
</Button>
<Button
type="primary"
icon={<DownloadOutlined />}
disabled={totalCount === 0}
onClick={() =>
downloadRawMeasurementsExport({
fromUtc: fromIso,
toUtc: toIso,
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined,
rowCap: exportRowCap,
})
}
>
Export to Excel
</Button>
</Space>
}
>
<Table<RawMeasurementRow>
rowKey={(r) => `${r.time}-${r.deviceName}`}
size="small"
columns={columns}
dataSource={data?.rows ?? []}
loading={isLoading}
pagination={false}
scroll={{ x: 'max-content' }}
/>
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'space-between' }}>
<Text type="secondary">
{totalCount === 0
? 'No measurements in this window.'
: `Showing ${showingFrom.toLocaleString()}${showingTo.toLocaleString()} of ${totalCount.toLocaleString()}`}
</Text>
<Space>
<Button
disabled={!canPrev || isFetching}
onClick={() => setOffset(Math.max(0, offset - limit))}
>
Previous
</Button>
<Button disabled={!canNext || isFetching} onClick={() => setOffset(offset + limit)}>
Next
</Button>
</Space>
</Space>
</Card>
</Space>
);
}