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>
308 lines
9.2 KiB
TypeScript
308 lines
9.2 KiB
TypeScript
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>
|
||
);
|
||
}
|