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>
This commit is contained in:
Diseri Pearson 2026-05-19 23:47:08 +02:00
parent b5ceedc097
commit 94ace2df0e
22 changed files with 1056 additions and 291 deletions

View File

@ -30,7 +30,10 @@ export async function updateMyProfile(displayName: string): Promise<CurrentUser>
return data; return data;
} }
export async function changeMyPassword(currentPassword: string, newPassword: string): Promise<void> { export async function changeMyPassword(
currentPassword: string,
newPassword: string,
): Promise<void> {
await api.post('/auth/me/change-password', { currentPassword, newPassword }); await api.post('/auth/me/change-password', { currentPassword, newPassword });
} }

View File

@ -24,7 +24,10 @@ export interface DashboardSummary {
chart: DashboardChartPoint[]; chart: DashboardChartPoint[];
} }
export async function fetchDashboardSummary(fromUtc: string, toUtc: string): Promise<DashboardSummary> { export async function fetchDashboardSummary(
fromUtc: string,
toUtc: string,
): Promise<DashboardSummary> {
const { data } = await api.get<DashboardSummary>('/dashboard/summary', { const { data } = await api.get<DashboardSummary>('/dashboard/summary', {
params: { from: fromUtc, to: toUtc }, params: { from: fromUtc, to: toUtc },
}); });

View File

@ -151,9 +151,8 @@ export async function fetchFleetCustomerCost(
fromUtc: string, fromUtc: string,
toUtc: string, toUtc: string,
): Promise<FleetCost> { ): Promise<FleetCost> {
const { data } = await api.get<FleetCost>( const { data } = await api.get<FleetCost>(`/fleet/customers/${id}/cost`, {
`/fleet/customers/${id}/cost`, params: { from: fromUtc, to: toUtc },
{ params: { from: fromUtc, to: toUtc } }, });
);
return data; return data;
} }

View File

@ -46,7 +46,8 @@ export async function fetchRawMeasurements(params: {
params: { params: {
from: params.fromUtc, from: params.fromUtc,
to: params.toUtc, to: params.toUtc,
deviceIds: params.deviceIds && params.deviceIds.length > 0 ? params.deviceIds.join(',') : undefined, deviceIds:
params.deviceIds && params.deviceIds.length > 0 ? params.deviceIds.join(',') : undefined,
limit: params.limit, limit: params.limit,
offset: params.offset, offset: params.offset,
}, },
@ -64,7 +65,8 @@ export function downloadRawMeasurementsExport(params: {
from: params.fromUtc, from: params.fromUtc,
to: params.toUtc, to: params.toUtc,
}); });
if (params.deviceIds && params.deviceIds.length > 0) q.set('deviceIds', params.deviceIds.join(',')); if (params.deviceIds && params.deviceIds.length > 0)
q.set('deviceIds', params.deviceIds.join(','));
if (params.rowCap) q.set('rowCap', String(params.rowCap)); if (params.rowCap) q.set('rowCap', String(params.rowCap));
window.location.href = `/api/measurements/raw/export.xlsx?${q.toString()}`; window.location.href = `/api/measurements/raw/export.xlsx?${q.toString()}`;
} }

View File

@ -77,7 +77,9 @@ export async function deleteMunicipality(id: number): Promise<void> {
} }
export async function listTariffs(municipalityId: number): Promise<TariffSummary[]> { export async function listTariffs(municipalityId: number): Promise<TariffSummary[]> {
const { data } = await api.get<TariffSummary[]>(`/rates/municipalities/${municipalityId}/tariffs`); const { data } = await api.get<TariffSummary[]>(
`/rates/municipalities/${municipalityId}/tariffs`,
);
return data; return data;
} }
@ -86,7 +88,10 @@ export async function getTariff(tariffId: number): Promise<TariffDetail> {
return data; return data;
} }
export async function createTariff(municipalityId: number, payload: UpsertTariff): Promise<TariffDetail> { export async function createTariff(
municipalityId: number,
payload: UpsertTariff,
): Promise<TariffDetail> {
const { data } = await api.post<TariffDetail>( const { data } = await api.post<TariffDetail>(
`/admin/rates/municipalities/${municipalityId}/tariffs`, `/admin/rates/municipalities/${municipalityId}/tariffs`,
payload, payload,

View File

@ -9,7 +9,9 @@ export function RequireAuth({ children }: { children: ReactNode }) {
if (loading) { if (loading) {
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}> <div
style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}
>
<Spin size="large" /> <Spin size="large" />
</div> </div>
); );

View File

@ -7,11 +7,7 @@ export function RequireRole({ role, children }: { role: string; children: ReactN
if (!user || !user.roles.includes(role)) { if (!user || !user.roles.includes(role)) {
return ( return (
<Result <Result status="403" title="403" subTitle="You do not have permission to view this page." />
status="403"
title="403"
subTitle="You do not have permission to view this page."
/>
); );
} }

View File

@ -1,6 +1,10 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Modal, Form, Input, Switch, Alert, Typography } from 'antd'; import { Modal, Form, Input, Switch, Alert, Typography } from 'antd';
import type { CustomerListItem, CreateCustomerPayload, UpdateCustomerPayload } from '../../api/customers'; import type {
CustomerListItem,
CreateCustomerPayload,
UpdateCustomerPayload,
} from '../../api/customers';
const { Text } = Typography; const { Text } = Typography;
@ -68,7 +72,11 @@ export function CustomerFormModal({ open, mode, submitting, error, onClose, onSu
> >
<Input disabled={isEdit} placeholder="ABC0001" maxLength={50} /> <Input disabled={isEdit} placeholder="ABC0001" maxLength={50} />
</Form.Item> </Form.Item>
<Form.Item name="name" label="Display name" rules={[{ required: true, message: 'Required' }]}> <Form.Item
name="name"
label="Display name"
rules={[{ required: true, message: 'Required' }]}
>
<Input /> <Input />
</Form.Item> </Form.Item>
{isEdit && ( {isEdit && (
@ -78,8 +86,8 @@ export function CustomerFormModal({ open, mode, submitting, error, onClose, onSu
)} )}
{!isEdit && ( {!isEdit && (
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
A push token will be generated and shown once. Set it as <Text code>FleetIngest__Token</Text> in A push token will be generated and shown once. Set it as{' '}
the customer's environment. <Text code>FleetIngest__Token</Text> in the customer's environment.
</Text> </Text>
)} )}
</Form> </Form>

View File

@ -53,16 +53,19 @@ export function TokenShownOnceModal({ open, customerCode, token, onClose }: Prop
/> />
<Paragraph> <Paragraph>
Set this as <Text code>FleetIngest__Token</Text> in the customer's <Text code>.env</Text>, Set this as <Text code>FleetIngest__Token</Text> in the customer's <Text code>.env</Text>,
alongside <Text code>FleetIngest__Url</Text> and <Text code>FleetIngest__Enabled=true</Text>. alongside <Text code>FleetIngest__Url</Text> and <Text code>FleetIngest__Enabled=true</Text>
.
</Paragraph> </Paragraph>
<Paragraph type="secondary" style={{ fontSize: 12 }}> <Paragraph type="secondary" style={{ fontSize: 12 }}>
If this is a <strong>rotation</strong> (not the first issue), the old token continues to If this is a <strong>rotation</strong> (not the first issue), the old token continues to
work for 24h. Update the customer's <Text code>.env</Text> and restart their portal work for 24h. Update the customer's <Text code>.env</Text> and restart their portal within
within that window to avoid dropped pushes. that window to avoid dropped pushes.
</Paragraph> </Paragraph>
<Space.Compact style={{ width: '100%' }}> <Space.Compact style={{ width: '100%' }}>
<Input.TextArea value={token ?? ''} readOnly rows={2} style={{ fontFamily: 'monospace' }} /> <Input.TextArea value={token ?? ''} readOnly rows={2} style={{ fontFamily: 'monospace' }} />
<Button icon={<CopyOutlined />} onClick={copy}>Copy</Button> <Button icon={<CopyOutlined />} onClick={copy}>
Copy
</Button>
</Space.Compact> </Space.Compact>
<Paragraph style={{ marginTop: 16 }}> <Paragraph style={{ marginTop: 16 }}>
<Button onClick={() => setConfirmed(true)} disabled={confirmed}> <Button onClick={() => setConfirmed(true)} disabled={confirmed}>

View File

@ -1,9 +1,15 @@
import { Layout, Menu, Button, Typography, Space, Tag, Dropdown } from 'antd'; import { Layout, Menu, Button, Typography, Space, Tag, Dropdown } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { import {
DashboardOutlined, SettingOutlined, LogoutOutlined, DashboardOutlined,
LineChartOutlined, ApartmentOutlined, TeamOutlined, TableOutlined, SettingOutlined,
UserOutlined, DownOutlined, LogoutOutlined,
LineChartOutlined,
ApartmentOutlined,
TeamOutlined,
TableOutlined,
UserOutlined,
DownOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
@ -61,13 +67,22 @@ export function AppLayout() {
<img <img
src={branding.logoUrl} src={branding.logoUrl}
alt="" alt=""
style={{ maxHeight: 36, background: 'rgba(255,255,255,0.1)', padding: 2, borderRadius: 2 }} style={{
maxHeight: 36,
background: 'rgba(255,255,255,0.1)',
padding: 2,
borderRadius: 2,
}}
/> />
)} )}
<Text strong style={{ color: '#fff', fontSize: 18 }}> <Text strong style={{ color: '#fff', fontSize: 18 }}>
{branding.applicationName} {branding.applicationName}
</Text> </Text>
{adminMode && <Tag color="gold" style={{ marginLeft: 8 }}>FLEET ADMIN</Tag>} {adminMode && (
<Tag color="gold" style={{ marginLeft: 8 }}>
FLEET ADMIN
</Tag>
)}
</Space> </Space>
<UserMenu <UserMenu
displayName={user?.displayName ?? user?.email ?? ''} displayName={user?.displayName ?? user?.email ?? ''}
@ -104,7 +119,9 @@ export function AppLayout() {
} }
function UserMenu({ function UserMenu({
displayName, onProfile, onLogout, displayName,
onProfile,
onLogout,
}: { }: {
displayName: string; displayName: string;
onProfile: () => void; onProfile: () => void;

View File

@ -1,5 +1,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Form, Input, Button, Row, Col, Card, Upload, ColorPicker, Space, message, Typography } from 'antd'; import {
Form,
Input,
Button,
Row,
Col,
Card,
Upload,
ColorPicker,
Space,
message,
Typography,
} from 'antd';
import type { UploadFile } from 'antd/es/upload/interface'; import type { UploadFile } from 'antd/es/upload/interface';
import { UploadOutlined, SaveOutlined } from '@ant-design/icons'; import { UploadOutlined, SaveOutlined } from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@ -26,7 +38,10 @@ export function BrandingForm() {
const [form] = Form.useForm<FormShape>(); const [form] = Form.useForm<FormShape>();
const [preview, setPreview] = useState<Branding | null>(null); const [preview, setPreview] = useState<Branding | null>(null);
const { data: branding, isLoading } = useQuery({ queryKey: ['branding'], queryFn: fetchBranding }); const { data: branding, isLoading } = useQuery({
queryKey: ['branding'],
queryFn: fetchBranding,
});
useEffect(() => { useEffect(() => {
if (!branding) return; if (!branding) return;
@ -75,7 +90,8 @@ export function BrandingForm() {
const beforeUpload = (file: UploadFile) => { const beforeUpload = (file: UploadFile) => {
if (file instanceof File) uploadMut.mutate(file); if (file instanceof File) uploadMut.mutate(file);
else if ('originFileObj' in file && file.originFileObj) uploadMut.mutate(file.originFileObj as File); else if ('originFileObj' in file && file.originFileObj)
uploadMut.mutate(file.originFileObj as File);
return false; return false;
}; };
@ -103,7 +119,13 @@ export function BrandingForm() {
<img <img
src={preview.logoUrl} src={preview.logoUrl}
alt="Current logo" alt="Current logo"
style={{ maxHeight: 64, maxWidth: 200, background: '#f5f5f5', padding: 8, borderRadius: 4 }} style={{
maxHeight: 64,
maxWidth: 200,
background: '#f5f5f5',
padding: 8,
borderRadius: 4,
}}
/> />
)} )}
<Upload <Upload
@ -143,7 +165,12 @@ export function BrandingForm() {
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<SaveOutlined />}> <Button
type="primary"
htmlType="submit"
loading={saveMut.isPending}
icon={<SaveOutlined />}
>
Save branding Save branding
</Button> </Button>
</Form.Item> </Form.Item>
@ -164,11 +191,22 @@ export function BrandingForm() {
}} }}
> >
{preview?.logoUrl && ( {preview?.logoUrl && (
<img src={preview.logoUrl} alt="" style={{ maxHeight: 28, background: '#fff', padding: 2, borderRadius: 2 }} /> <img
src={preview.logoUrl}
alt=""
style={{ maxHeight: 28, background: '#fff', padding: 2, borderRadius: 2 }}
/>
)} )}
<strong>{preview?.applicationName || 'Application name'}</strong> <strong>{preview?.applicationName || 'Application name'}</strong>
</div> </div>
<div style={{ background: preview?.secondaryColor ?? '#374151', color: '#fff', padding: 8, marginTop: 4 }}> <div
style={{
background: preview?.secondaryColor ?? '#374151',
color: '#fff',
padding: 8,
marginTop: 4,
}}
>
Sidebar / secondary surface Sidebar / secondary surface
</div> </div>
<Button type="primary" style={{ marginTop: 12 }}> <Button type="primary" style={{ marginTop: 12 }}>

View File

@ -25,7 +25,13 @@ export function ConfigOverviewCard() {
{data && ( {data && (
<> <>
<Descriptions title="Application" column={2} size="small" bordered style={{ marginBottom: 16 }}> <Descriptions
title="Application"
column={2}
size="small"
bordered
style={{ marginBottom: 16 }}
>
<Descriptions.Item label="Name">{data.application.name}</Descriptions.Item> <Descriptions.Item label="Name">{data.application.name}</Descriptions.Item>
<Descriptions.Item label="Environment"> <Descriptions.Item label="Environment">
<Tag color={data.application.environment === 'Production' ? 'red' : 'blue'}> <Tag color={data.application.environment === 'Production' ? 'red' : 'blue'}>
@ -40,7 +46,13 @@ export function ConfigOverviewCard() {
<Descriptions.Item label="Public URL">{data.application.publicUrl}</Descriptions.Item> <Descriptions.Item label="Public URL">{data.application.publicUrl}</Descriptions.Item>
</Descriptions> </Descriptions>
<Descriptions title="Database" column={2} size="small" bordered style={{ marginBottom: 16 }}> <Descriptions
title="Database"
column={2}
size="small"
bordered
style={{ marginBottom: 16 }}
>
<Descriptions.Item label="Provider">{data.database.provider}</Descriptions.Item> <Descriptions.Item label="Provider">{data.database.provider}</Descriptions.Item>
<Descriptions.Item label="Resolved via"> <Descriptions.Item label="Resolved via">
<Text code>{data.database.resolvedVia}</Text> <Text code>{data.database.resolvedVia}</Text>
@ -49,62 +61,130 @@ export function ConfigOverviewCard() {
<Descriptions.Item label="Port">{data.database.port}</Descriptions.Item> <Descriptions.Item label="Port">{data.database.port}</Descriptions.Item>
<Descriptions.Item label="Database">{data.database.database}</Descriptions.Item> <Descriptions.Item label="Database">{data.database.database}</Descriptions.Item>
<Descriptions.Item label="Migrate on startup"> <Descriptions.Item label="Migrate on startup">
{data.database.migrateOnStartup ? <Tag color="green">Yes</Tag> : <Tag color="red">No</Tag>} {data.database.migrateOnStartup ? (
<Tag color="green">Yes</Tag>
) : (
<Tag color="red">No</Tag>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="Auto-provision local Timescale" span={2}> <Descriptions.Item label="Auto-provision local Timescale" span={2}>
{data.database.autoProvisionLocalTimescaleDb ? <Tag>Yes</Tag> : <Tag color="green">No</Tag>} {data.database.autoProvisionLocalTimescaleDb ? (
<Tag>Yes</Tag>
) : (
<Tag color="green">No</Tag>
)}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
<Descriptions title="Grafana" column={2} size="small" bordered style={{ marginBottom: 16 }}> <Descriptions
<Descriptions.Item label="Base URL" span={2}>{data.grafana.baseUrl}</Descriptions.Item> title="Grafana"
<Descriptions.Item label="Internal URL" span={2}>{data.grafana.internalUrl}</Descriptions.Item> column={2}
<Descriptions.Item label="Embed path prefix">{data.grafana.embedPathPrefix}</Descriptions.Item> size="small"
bordered
style={{ marginBottom: 16 }}
>
<Descriptions.Item label="Base URL" span={2}>
{data.grafana.baseUrl}
</Descriptions.Item>
<Descriptions.Item label="Internal URL" span={2}>
{data.grafana.internalUrl}
</Descriptions.Item>
<Descriptions.Item label="Embed path prefix">
{data.grafana.embedPathPrefix}
</Descriptions.Item>
<Descriptions.Item label="Embed mode">{data.grafana.embedMode}</Descriptions.Item> <Descriptions.Item label="Embed mode">{data.grafana.embedMode}</Descriptions.Item>
<Descriptions.Item label="Auth mode">{data.grafana.authMode}</Descriptions.Item> <Descriptions.Item label="Auth mode">{data.grafana.authMode}</Descriptions.Item>
<Descriptions.Item label="Default dashboard UID">{data.grafana.defaultDashboardUid || '(unset)'}</Descriptions.Item> <Descriptions.Item label="Default dashboard UID">
<Descriptions.Item label="Dashboards configured" span={2}>{data.grafana.dashboardCount}</Descriptions.Item> {data.grafana.defaultDashboardUid || '(unset)'}
</Descriptions> </Descriptions.Item>
<Descriptions.Item label="Dashboards configured" span={2}>
<Descriptions title="Monitoring" column={2} size="small" bordered style={{ marginBottom: 16 }}> {data.grafana.dashboardCount}
<Descriptions.Item label="Chunk time interval">{data.monitoring.chunkTimeInterval}</Descriptions.Item>
<Descriptions.Item label="Hourly aggregates">
{data.monitoring.enableHourlyAggregates ? <Tag color="orange">Flag set (not implemented)</Tag> : 'No'}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
<Descriptions title="Authentication" column={2} size="small" bordered style={{ marginBottom: 16 }}> <Descriptions
<Descriptions.Item label="Cookie name">{data.authentication.cookieName}</Descriptions.Item> title="Monitoring"
<Descriptions.Item label="Require confirmed email">{data.authentication.requireConfirmedEmail ? 'Yes' : 'No'}</Descriptions.Item> column={2}
<Descriptions.Item label="Default admin email" span={2}>{data.authentication.defaultAdminEmail}</Descriptions.Item> size="small"
bordered
style={{ marginBottom: 16 }}
>
<Descriptions.Item label="Chunk time interval">
{data.monitoring.chunkTimeInterval}
</Descriptions.Item>
<Descriptions.Item label="Hourly aggregates">
{data.monitoring.enableHourlyAggregates ? (
<Tag color="orange">Flag set (not implemented)</Tag>
) : (
'No'
)}
</Descriptions.Item>
</Descriptions>
<Descriptions
title="Authentication"
column={2}
size="small"
bordered
style={{ marginBottom: 16 }}
>
<Descriptions.Item label="Cookie name">
{data.authentication.cookieName}
</Descriptions.Item>
<Descriptions.Item label="Require confirmed email">
{data.authentication.requireConfirmedEmail ? 'Yes' : 'No'}
</Descriptions.Item>
<Descriptions.Item label="Default admin email" span={2}>
{data.authentication.defaultAdminEmail}
</Descriptions.Item>
</Descriptions> </Descriptions>
<Descriptions title="Build" column={2} size="small" bordered style={{ marginBottom: 16 }}> <Descriptions title="Build" column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Assembly version">{data.build.assemblyVersion}</Descriptions.Item> <Descriptions.Item label="Assembly version">
{data.build.assemblyVersion}
</Descriptions.Item>
<Descriptions.Item label=".NET runtime">{data.build.framework}</Descriptions.Item> <Descriptions.Item label=".NET runtime">{data.build.framework}</Descriptions.Item>
<Descriptions.Item label="Started" span={2}>{new Date(data.build.startedAtUtc).toLocaleString()}</Descriptions.Item> <Descriptions.Item label="Started" span={2}>
{new Date(data.build.startedAtUtc).toLocaleString()}
</Descriptions.Item>
</Descriptions> </Descriptions>
{data.fleetIngest && ( {data.fleetIngest && (
<> <>
<Descriptions title="Fleet push (Client → Admin)" column={2} size="small" bordered style={{ marginBottom: 16 }}> <Descriptions
title="Fleet push (Client → Admin)"
column={2}
size="small"
bordered
style={{ marginBottom: 16 }}
>
<Descriptions.Item label="Enabled"> <Descriptions.Item label="Enabled">
{data.fleetIngest.enabled {data.fleetIngest.enabled ? (
? <Tag color="green">Yes</Tag> <Tag color="green">Yes</Tag>
: <Tag>No (push service not running)</Tag>} ) : (
<Tag>No (push service not running)</Tag>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="Token configured"> <Descriptions.Item label="Token configured">
{data.fleetIngest.tokenConfigured {data.fleetIngest.tokenConfigured ? (
? <Tag color="green">Yes (value hidden)</Tag> <Tag color="green">Yes (value hidden)</Tag>
: <Tag color="red">No</Tag>} ) : (
<Tag color="red">No</Tag>
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="Url" span={2}> <Descriptions.Item label="Url" span={2}>
{data.fleetIngest.url {data.fleetIngest.url ? (
? <Text code>{data.fleetIngest.url}</Text> <Text code>{data.fleetIngest.url}</Text>
: <Text type="secondary">(unset)</Text>} ) : (
<Text type="secondary">(unset)</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="Interval">
{data.fleetIngest.intervalSeconds}s
</Descriptions.Item>
<Descriptions.Item label="Batch size">
{data.fleetIngest.batchSize.toLocaleString()} rows
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="Interval">{data.fleetIngest.intervalSeconds}s</Descriptions.Item>
<Descriptions.Item label="Batch size">{data.fleetIngest.batchSize.toLocaleString()} rows</Descriptions.Item>
<Descriptions.Item label="Batch max bytes" span={2}> <Descriptions.Item label="Batch max bytes" span={2}>
{data.fleetIngest.batchMaxBytes.toLocaleString()} bytes {data.fleetIngest.batchMaxBytes.toLocaleString()} bytes
</Descriptions.Item> </Descriptions.Item>
@ -130,23 +210,44 @@ export function ConfigOverviewCard() {
} }
const pushStateColumns: ColumnsType<FleetPushStateRow> = [ const pushStateColumns: ColumnsType<FleetPushStateRow> = [
{ title: 'Resource', dataIndex: 'resourceType', key: 'rt', render: (v: string) => <Text strong>{v}</Text> },
{ {
title: 'Last cursor', dataIndex: 'lastCursor', key: 'lc', title: 'Resource',
render: (v: string | null) => v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text> dataIndex: 'resourceType',
key: 'rt',
render: (v: string) => <Text strong>{v}</Text>,
}, },
{ {
title: 'Last sync', dataIndex: 'lastSyncedAt', key: 'ls', title: 'Last cursor',
render: (v: string | null) => v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text> dataIndex: 'lastCursor',
key: 'lc',
render: (v: string | null) =>
v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>,
}, },
{ {
title: 'Failures', dataIndex: 'consecutiveFailures', key: 'cf', title: 'Last sync',
render: (n: number) => n === 0 dataIndex: 'lastSyncedAt',
? <Tag color="green">0</Tag> key: 'ls',
: <Tag color={n > 5 ? 'red' : 'orange'}>{n}</Tag> render: (v: string | null) =>
v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>,
}, },
{ {
title: 'Last error', dataIndex: 'lastError', key: 'le', title: 'Failures',
render: (v: string | null) => v ? <Text type="danger" style={{ fontSize: 12 }}>{v}</Text> : <Text type="secondary"></Text> dataIndex: 'consecutiveFailures',
key: 'cf',
render: (n: number) =>
n === 0 ? <Tag color="green">0</Tag> : <Tag color={n > 5 ? 'red' : 'orange'}>{n}</Tag>,
},
{
title: 'Last error',
dataIndex: 'lastError',
key: 'le',
render: (v: string | null) =>
v ? (
<Text type="danger" style={{ fontSize: 12 }}>
{v}
</Text>
) : (
<Text type="secondary"></Text>
),
}, },
]; ];

View File

@ -11,7 +11,10 @@ interface DashboardRow extends GrafanaDashboard {
} }
export function GrafanaInfoCard() { export function GrafanaInfoCard() {
const { data, isLoading } = useQuery({ queryKey: ['grafana-config'], queryFn: fetchGrafanaConfig }); const { data, isLoading } = useQuery({
queryKey: ['grafana-config'],
queryFn: fetchGrafanaConfig,
});
const baseUrl = data?.baseUrl?.replace(/\/$/, '') ?? ''; const baseUrl = data?.baseUrl?.replace(/\/$/, '') ?? '';
const rows: DashboardRow[] = (data?.dashboards ?? []).map((d) => ({ const rows: DashboardRow[] = (data?.dashboards ?? []).map((d) => ({
@ -38,7 +41,9 @@ export function GrafanaInfoCard() {
key: 'url', key: 'url',
render: (url: string) => ( render: (url: string) => (
<Space> <Space>
<Text code style={{ fontSize: 11 }}>{url}</Text> <Text code style={{ fontSize: 11 }}>
{url}
</Text>
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(url)} /> <Button size="small" icon={<CopyOutlined />} onClick={() => copy(url)} />
</Space> </Space>
), ),
@ -52,7 +57,11 @@ export function GrafanaInfoCard() {
<Space> <Space>
<Text code>{data?.baseUrl}</Text> <Text code>{data?.baseUrl}</Text>
{data?.baseUrl && ( {data?.baseUrl && (
<Button size="small" icon={<LinkOutlined />} onClick={() => window.open(data.baseUrl, '_blank')}> <Button
size="small"
icon={<LinkOutlined />}
onClick={() => window.open(data.baseUrl, '_blank')}
>
Open Open
</Button> </Button>
)} )}
@ -61,12 +70,14 @@ export function GrafanaInfoCard() {
<Descriptions.Item label="Default dashboard UID"> <Descriptions.Item label="Default dashboard UID">
<Text code>{data?.defaultDashboardUid || '(unset)'}</Text> <Text code>{data?.defaultDashboardUid || '(unset)'}</Text>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="Dashboards configured">{data?.dashboards.length ?? 0}</Descriptions.Item> <Descriptions.Item label="Dashboards configured">
{data?.dashboards.length ?? 0}
</Descriptions.Item>
</Descriptions> </Descriptions>
<Paragraph type="secondary" style={{ fontSize: 12 }}> <Paragraph type="secondary" style={{ fontSize: 12 }}>
Add dashboards by dropping JSON into <Text code>grafana/dashboards/</Text> and adding a matching entry Add dashboards by dropping JSON into <Text code>grafana/dashboards/</Text> and adding a
to <Text code>Grafana.Dashboards</Text> in configuration. matching entry to <Text code>Grafana.Dashboards</Text> in configuration.
</Paragraph> </Paragraph>
<Table<DashboardRow> <Table<DashboardRow>

View File

@ -1,14 +1,33 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Table, Button, Space, Modal, Input, Switch, Tag, Popconfirm, Form, Typography, message, Card, Table,
Button,
Space,
Modal,
Input,
Switch,
Tag,
Popconfirm,
Form,
Typography,
message,
Card,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
listMunicipalities, createMunicipality, updateMunicipality, deleteMunicipality, listMunicipalities,
listTariffs, getTariff, deleteTariff, createMunicipality,
type Municipality, type TariffSummary, type UpsertMunicipality, type TariffDetail, updateMunicipality,
deleteMunicipality,
listTariffs,
getTariff,
deleteTariff,
type Municipality,
type TariffSummary,
type UpsertMunicipality,
type TariffDetail,
} from '../../../api/rates'; } from '../../../api/rates';
import { TariffDrawer } from './TariffDrawer'; import { TariffDrawer } from './TariffDrawer';
@ -41,17 +60,29 @@ export function MunicipalityList() {
const createMut = useMutation({ const createMut = useMutation({
mutationFn: (payload: UpsertMunicipality) => createMunicipality(payload), mutationFn: (payload: UpsertMunicipality) => createMunicipality(payload),
onSuccess: () => { message.success('Created'); closeMuni(); qc.invalidateQueries({ queryKey: ['municipalities'] }); }, onSuccess: () => {
message.success('Created');
closeMuni();
qc.invalidateQueries({ queryKey: ['municipalities'] });
},
onError: () => message.error('Create failed'), onError: () => message.error('Create failed'),
}); });
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: UpsertMunicipality }) => updateMunicipality(id, payload), mutationFn: ({ id, payload }: { id: number; payload: UpsertMunicipality }) =>
onSuccess: () => { message.success('Updated'); closeMuni(); qc.invalidateQueries({ queryKey: ['municipalities'] }); }, updateMunicipality(id, payload),
onSuccess: () => {
message.success('Updated');
closeMuni();
qc.invalidateQueries({ queryKey: ['municipalities'] });
},
onError: () => message.error('Update failed'), onError: () => message.error('Update failed'),
}); });
const deleteMut = useMutation({ const deleteMut = useMutation({
mutationFn: (id: number) => deleteMunicipality(id), mutationFn: (id: number) => deleteMunicipality(id),
onSuccess: () => { message.success('Deleted'); qc.invalidateQueries({ queryKey: ['municipalities'] }); }, onSuccess: () => {
message.success('Deleted');
qc.invalidateQueries({ queryKey: ['municipalities'] });
},
onError: () => message.error('Delete failed (any tariffs are removed with it)'), onError: () => message.error('Delete failed (any tariffs are removed with it)'),
}); });
const deleteTariffMut = useMutation({ const deleteTariffMut = useMutation({
@ -93,27 +124,37 @@ export function MunicipalityList() {
const columns: ColumnsType<Municipality> = [ const columns: ColumnsType<Municipality> = [
{ title: 'Name', dataIndex: 'name', key: 'name' }, { title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'Time zone', dataIndex: 'timeZoneId', key: 'tz', render: (v: string | null) => v ?? <Text type="secondary">UTC</Text> }, {
title: 'Time zone',
dataIndex: 'timeZoneId',
key: 'tz',
render: (v: string | null) => v ?? <Text type="secondary">UTC</Text>,
},
{ title: 'Tariffs', dataIndex: 'tariffCount', key: 'count' }, { title: 'Tariffs', dataIndex: 'tariffCount', key: 'count' },
{ {
title: 'Active', title: 'Active',
dataIndex: 'isActive', dataIndex: 'isActive',
key: 'isActive', key: 'isActive',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>), render: (v: boolean) =>
v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
}, },
{ {
title: 'Actions', title: 'Actions',
key: 'actions', key: 'actions',
render: (_, m) => ( render: (_, m) => (
<Space> <Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEditMuni(m)}>Edit</Button> <Button size="small" icon={<EditOutlined />} onClick={() => openEditMuni(m)}>
Edit
</Button>
<Popconfirm <Popconfirm
title={`Delete ${m.name}? All its tariffs will be removed.`} title={`Delete ${m.name}? All its tariffs will be removed.`}
okText="Delete" okText="Delete"
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
onConfirm={() => deleteMut.mutate(m.id)} onConfirm={() => deleteMut.mutate(m.id)}
> >
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button> <Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@ -123,8 +164,12 @@ export function MunicipalityList() {
return ( return (
<> <>
<Space style={{ marginBottom: 16, justifyContent: 'space-between', width: '100%' }}> <Space style={{ marginBottom: 16, justifyContent: 'space-between', width: '100%' }}>
<Text type="secondary">Configure municipalities and their tariffs. Expand a row to see tariffs.</Text> <Text type="secondary">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateMuni}>New municipality</Button> Configure municipalities and their tariffs. Expand a row to see tariffs.
</Text>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateMuni}>
New municipality
</Button>
</Space> </Space>
<Table<Municipality> <Table<Municipality>
@ -154,7 +199,12 @@ export function MunicipalityList() {
onOk={() => muniForm.submit()} onOk={() => muniForm.submit()}
confirmLoading={createMut.isPending || updateMut.isPending} confirmLoading={createMut.isPending || updateMut.isPending}
> >
<Form<MuniFormShape> form={muniForm} layout="vertical" onFinish={onMuniSubmit} requiredMark={false}> <Form<MuniFormShape>
form={muniForm}
layout="vertical"
onFinish={onMuniSubmit}
requiredMark={false}
>
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Required' }]}> <Form.Item name="name" label="Name" rules={[{ required: true, message: 'Required' }]}>
<Input /> <Input />
</Form.Item> </Form.Item>
@ -198,9 +248,23 @@ function TariffSubTable({
const columns: ColumnsType<TariffSummary> = [ const columns: ColumnsType<TariffSummary> = [
{ title: 'Name', dataIndex: 'name', key: 'name' }, { title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'Effective', key: 'effective', render: (_, t) => `${t.effectiveFrom}${t.effectiveTo ? ' → ' + t.effectiveTo : ' →'}` }, {
{ title: 'Default rate', dataIndex: 'defaultRatePerKwh', key: 'rate', render: (v: number) => v.toFixed(4) }, title: 'Effective',
{ title: 'Fixed', dataIndex: 'fixedMonthlyCharge', key: 'fixed', render: (v: number) => v.toFixed(2) }, key: 'effective',
render: (_, t) => `${t.effectiveFrom}${t.effectiveTo ? ' → ' + t.effectiveTo : ' →'}`,
},
{
title: 'Default rate',
dataIndex: 'defaultRatePerKwh',
key: 'rate',
render: (v: number) => v.toFixed(4),
},
{
title: 'Fixed',
dataIndex: 'fixedMonthlyCharge',
key: 'fixed',
render: (v: number) => v.toFixed(2),
},
{ title: 'VAT %', dataIndex: 'vatPercentage', key: 'vat', render: (v: number) => v.toFixed(2) }, { title: 'VAT %', dataIndex: 'vatPercentage', key: 'vat', render: (v: number) => v.toFixed(2) },
{ title: 'Periods', dataIndex: 'periodCount', key: 'periods' }, { title: 'Periods', dataIndex: 'periodCount', key: 'periods' },
{ {
@ -213,14 +277,18 @@ function TariffSubTable({
key: 'actions', key: 'actions',
render: (_, t) => ( render: (_, t) => (
<Space> <Space>
<Button size="small" icon={<EditOutlined />} onClick={() => onEdit(t.id)}>Edit</Button> <Button size="small" icon={<EditOutlined />} onClick={() => onEdit(t.id)}>
Edit
</Button>
<Popconfirm <Popconfirm
title={`Delete tariff "${t.name}"?`} title={`Delete tariff "${t.name}"?`}
okText="Delete" okText="Delete"
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
onConfirm={() => onDelete(t.id)} onConfirm={() => onDelete(t.id)}
> >
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button> <Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@ -230,7 +298,9 @@ function TariffSubTable({
return ( return (
<Card size="small" style={{ background: '#fafafa' }}> <Card size="small" style={{ background: '#fafafa' }}>
<Space style={{ marginBottom: 8, justifyContent: 'flex-end', width: '100%' }}> <Space style={{ marginBottom: 8, justifyContent: 'flex-end', width: '100%' }}>
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onCreate}>New tariff</Button> <Button type="primary" size="small" icon={<PlusOutlined />} onClick={onCreate}>
New tariff
</Button>
</Space> </Space>
<Table<TariffSummary> <Table<TariffSummary>
rowKey="id" rowKey="id"

View File

@ -68,7 +68,10 @@ export function PeriodEditor({ value, onChange }: Props) {
{DAY_OPTIONS.map((d, dIdx) => { {DAY_OPTIONS.map((d, dIdx) => {
const active = (p.daysOfWeek & d.value) !== 0; const active = (p.daysOfWeek & d.value) !== 0;
return ( return (
<Tooltip key={dIdx} title={['Mon','Tue','Wed','Thu','Fri','Sat','Sun'][dIdx]}> <Tooltip
key={dIdx}
title={['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][dIdx]}
>
<Button <Button
size="small" size="small"
type={active ? 'primary' : 'default'} type={active ? 'primary' : 'default'}

View File

@ -111,7 +111,11 @@ export function CustomerAccessModal({ open, user, onClose }: Props) {
onChange={() => toggle(c.id)} onChange={() => toggle(c.id)}
> >
<Text strong>{c.code}</Text> <Text type="secondary"> {c.name}</Text> <Text strong>{c.code}</Text> <Text type="secondary"> {c.name}</Text>
{!c.isActive && <Tag color="red" style={{ marginLeft: 8 }}>Disabled</Tag>} {!c.isActive && (
<Tag color="red" style={{ marginLeft: 8 }}>
Disabled
</Tag>
)}
</Checkbox> </Checkbox>
))} ))}
</Checkbox.Group> </Checkbox.Group>

View File

@ -4,9 +4,7 @@ import type { UserListItem, CreateUserPayload, UpdateUserPayload } from '../../a
const { Text } = Typography; const { Text } = Typography;
type Mode = type Mode = { kind: 'create' } | { kind: 'edit'; user: UserListItem };
| { kind: 'create' }
| { kind: 'edit'; user: UserListItem };
interface Props { interface Props {
open: boolean; open: boolean;
@ -99,7 +97,9 @@ export function UserFormDrawer({ open, mode, submitting, error, onClose, onSubmi
<Form.Item <Form.Item
name="password" name="password"
label="Password" label="Password"
rules={[{ required: true, min: 8, message: 'Min 8 characters with upper, lower, and digit' }]} rules={[
{ required: true, min: 8, message: 'Min 8 characters with upper, lower, and digit' },
]}
> >
<Input.Password autoComplete="new-password" /> <Input.Password autoComplete="new-password" />
</Form.Item> </Form.Item>
@ -109,15 +109,22 @@ export function UserFormDrawer({ open, mode, submitting, error, onClose, onSubmi
<Space direction="vertical"> <Space direction="vertical">
<Radio value="none"> <Radio value="none">
<Text>None</Text> <Text>None</Text>
<Text type="secondary" style={{ display: 'block', fontSize: 12 }}>Regular user Dashboard + Dashboards only.</Text> <Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Regular user Dashboard + Dashboards only.
</Text>
</Radio> </Radio>
<Radio value="admin"> <Radio value="admin">
<Text>Administrator</Text> <Text>Administrator</Text>
<Text type="secondary" style={{ display: 'block', fontSize: 12 }}>Full access to all customers, settings, user mgmt.</Text> <Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Full access to all customers, settings, user mgmt.
</Text>
</Radio> </Radio>
<Radio value="restricted"> <Radio value="restricted">
<Text>Restricted admin (fleet-scoped)</Text> <Text>Restricted admin (fleet-scoped)</Text>
<Text type="secondary" style={{ display: 'block', fontSize: 12 }}>Same pages as Admin but Postgres RLS limits which customers they see. Grant per-customer access from the Users page.</Text> <Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
Same pages as Admin but Postgres RLS limits which customers they see. Grant
per-customer access from the Users page.
</Text>
</Radio> </Radio>
</Space> </Space>
</Radio.Group> </Radio.Group>

View File

@ -1,21 +1,44 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Card, Descriptions, Tabs, Table, Tag, Typography, Button, Space, Spin, Result, Tooltip, Card,
DatePicker, Statistic, Row, Col, Alert, Descriptions,
Tabs,
Table,
Tag,
Typography,
Button,
Space,
Spin,
Result,
Tooltip,
DatePicker,
Statistic,
Row,
Col,
Alert,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
ArrowLeftOutlined, LineChartOutlined, ThunderboltOutlined, DollarOutlined, DownloadOutlined, ArrowLeftOutlined,
LineChartOutlined,
ThunderboltOutlined,
DollarOutlined,
DownloadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import dayjs, { type Dayjs } from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import { import {
fetchFleetCustomerDetail, fetchFleetCustomerCost, fetchFleetCustomerDetail,
type FleetSite, type FleetDevice, fetchFleetCustomerCost,
type FleetRecentMeasurement, type FleetIngestEvent, type FleetSite,
type FleetTariffView, type FleetTariffPeriodView, type FleetDevice,
type FleetCostDay, type FleetCostDeviceRow, type FleetRecentMeasurement,
type FleetIngestEvent,
type FleetTariffView,
type FleetTariffPeriodView,
type FleetCostDay,
type FleetCostDeviceRow,
} from '../api/fleet'; } from '../api/fleet';
import { downloadFleetCustomerCostXlsx } from '../api/dashboard'; import { downloadFleetCustomerCostXlsx } from '../api/dashboard';
import { fetchGrafanaConfig } from '../api/grafana'; import { fetchGrafanaConfig } from '../api/grafana';
@ -47,47 +70,122 @@ export function AdminCustomerDetailPage() {
return `${base}/d/${encodeURIComponent(CUSTOMER_DRILLDOWN_UID)}?orgId=1&kiosk=tv&theme=light&var-customer=${encodeURIComponent(data.id)}`; return `${base}/d/${encodeURIComponent(CUSTOMER_DRILLDOWN_UID)}?orgId=1&kiosk=tv&theme=light&var-customer=${encodeURIComponent(data.id)}`;
})(); })();
if (isLoading) return <div style={{ textAlign: 'center', padding: 64 }}><Spin size="large" /></div>; if (isLoading)
return (
<div style={{ textAlign: 'center', padding: 64 }}>
<Spin size="large" />
</div>
);
if (error || !data) { if (error || !data) {
return <Result status="404" title="Customer not found" extra={<Button onClick={() => navigate('/admin/customers')}>Back</Button>} />; return (
<Result
status="404"
title="Customer not found"
extra={<Button onClick={() => navigate('/admin/customers')}>Back</Button>}
/>
);
} }
const siteCols: ColumnsType<FleetSite> = [ const siteCols: ColumnsType<FleetSite> = [
{ title: 'Name', dataIndex: 'name', key: 'name' }, { title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'Address', dataIndex: 'address', key: 'addr', render: v => v ?? <Text type="secondary"></Text> }, {
{ title: 'Active', dataIndex: 'isActive', key: 'a', render: (v: boolean) => v ? <Tag color="green">Active</Tag> : <Tag>Inactive</Tag> }, title: 'Address',
dataIndex: 'address',
key: 'addr',
render: (v) => v ?? <Text type="secondary"></Text>,
},
{
title: 'Active',
dataIndex: 'isActive',
key: 'a',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag>Inactive</Tag>),
},
]; ];
const deviceCols: ColumnsType<FleetDevice> = [ const deviceCols: ColumnsType<FleetDevice> = [
{ title: 'Name', dataIndex: 'name', key: 'name' }, { title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'External ID', dataIndex: 'externalId', key: 'ext', render: v => <Text code>{v}</Text> }, {
{ title: 'Description', dataIndex: 'description', key: 'desc', render: v => v ?? <Text type="secondary"></Text> }, title: 'External ID',
{ title: 'Active', dataIndex: 'isActive', key: 'a', render: (v: boolean) => v ? <Tag color="green">Active</Tag> : <Tag>Inactive</Tag> }, dataIndex: 'externalId',
key: 'ext',
render: (v) => <Text code>{v}</Text>,
},
{
title: 'Description',
dataIndex: 'description',
key: 'desc',
render: (v) => v ?? <Text type="secondary"></Text>,
},
{
title: 'Active',
dataIndex: 'isActive',
key: 'a',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag>Inactive</Tag>),
},
]; ];
const measCols: ColumnsType<FleetRecentMeasurement> = [ const measCols: ColumnsType<FleetRecentMeasurement> = [
{ title: 'Time (UTC)', dataIndex: 'time', key: 't', render: (v: string) => new Date(v).toISOString().replace('T', ' ').slice(0, 19) }, {
title: 'Time (UTC)',
dataIndex: 'time',
key: 't',
render: (v: string) => new Date(v).toISOString().replace('T', ' ').slice(0, 19),
},
{ title: 'Device', dataIndex: 'deviceName', key: 'd' }, { title: 'Device', dataIndex: 'deviceName', key: 'd' },
{ title: 'Active power (kW)', dataIndex: 'activePowerKw', key: 'p', render: (v: number) => v.toFixed(3) }, {
{ title: 'kWh imported (cumulative)', dataIndex: 'energyImportedKwh', key: 'e', render: (v: number | null) => v == null ? '—' : v.toFixed(2) }, title: 'Active power (kW)',
dataIndex: 'activePowerKw',
key: 'p',
render: (v: number) => v.toFixed(3),
},
{
title: 'kWh imported (cumulative)',
dataIndex: 'energyImportedKwh',
key: 'e',
render: (v: number | null) => (v == null ? '—' : v.toFixed(2)),
},
]; ];
const eventCols: ColumnsType<FleetIngestEvent> = [ const eventCols: ColumnsType<FleetIngestEvent> = [
{ title: 'Received (UTC)', dataIndex: 'receivedAt', key: 'r', render: (v: string) => new Date(v).toISOString().replace('T', ' ').slice(0, 19) }, {
title: 'Received (UTC)',
dataIndex: 'receivedAt',
key: 'r',
render: (v: string) => new Date(v).toISOString().replace('T', ' ').slice(0, 19),
},
{ title: 'Type', dataIndex: 'batchType', key: 'bt' }, { title: 'Type', dataIndex: 'batchType', key: 'bt' },
{ title: 'Accepted', dataIndex: 'rowsAccepted', key: 'a' }, { title: 'Accepted', dataIndex: 'rowsAccepted', key: 'a' },
{ title: 'Rejected', dataIndex: 'rowsRejected', key: 'rj', render: (v: number) => v > 0 ? <Tag color="orange">{v}</Tag> : v }, {
title: 'Rejected',
dataIndex: 'rowsRejected',
key: 'rj',
render: (v: number) => (v > 0 ? <Tag color="orange">{v}</Tag> : v),
},
{ title: 'Bytes', dataIndex: 'batchBytes', key: 'b' }, { title: 'Bytes', dataIndex: 'batchBytes', key: 'b' },
{ title: 'Time spread', dataIndex: 'timeSpread', key: 'ts', render: (v: string | null) => v ?? '—' }, {
{ title: 'Error', dataIndex: 'error', key: 'e', render: (v: string | null) => v ? <Tag color="red">{v}</Tag> : '—' }, title: 'Time spread',
dataIndex: 'timeSpread',
key: 'ts',
render: (v: string | null) => v ?? '—',
},
{
title: 'Error',
dataIndex: 'error',
key: 'e',
render: (v: string | null) => (v ? <Tag color="red">{v}</Tag> : '—'),
},
]; ];
return ( return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}> <Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Space> <Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/customers')}>Customers</Button> <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/customers')}>
<Text strong style={{ fontSize: 20 }}>{data.code} · {data.name}</Text> Customers
</Button>
<Text strong style={{ fontSize: 20 }}>
{data.code} · {data.name}
</Text>
{data.isActive ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>} {data.isActive ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>}
</Space> </Space>
<Tooltip <Tooltip
@ -111,9 +209,23 @@ export function AdminCustomerDetailPage() {
<Card size="small"> <Card size="small">
<Descriptions column={3} size="small"> <Descriptions column={3} size="small">
<Descriptions.Item label="Customer ID">{data.id}</Descriptions.Item> <Descriptions.Item label="Customer ID">{data.id}</Descriptions.Item>
<Descriptions.Item label="Created">{new Date(data.createdAt).toLocaleString()}</Descriptions.Item> <Descriptions.Item label="Created">
<Descriptions.Item label="First seen">{data.firstSeenAt ? new Date(data.firstSeenAt).toLocaleString() : <Text type="secondary">Never</Text>}</Descriptions.Item> {new Date(data.createdAt).toLocaleString()}
<Descriptions.Item label="Last seen" span={3}>{data.lastSeenAt ? new Date(data.lastSeenAt).toLocaleString() : <Text type="secondary">Never</Text>}</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="First seen">
{data.firstSeenAt ? (
new Date(data.firstSeenAt).toLocaleString()
) : (
<Text type="secondary">Never</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="Last seen" span={3}>
{data.lastSeenAt ? (
new Date(data.lastSeenAt).toLocaleString()
) : (
<Text type="secondary">Never</Text>
)}
</Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
@ -124,27 +236,64 @@ export function AdminCustomerDetailPage() {
{ {
key: 'ingest', key: 'ingest',
label: `Recent ingest (${data.recentIngestEvents.length})`, label: `Recent ingest (${data.recentIngestEvents.length})`,
children: <Table<FleetIngestEvent> rowKey="receivedAt" columns={eventCols} dataSource={data.recentIngestEvents} pagination={false} size="small" />, children: (
<Table<FleetIngestEvent>
rowKey="receivedAt"
columns={eventCols}
dataSource={data.recentIngestEvents}
pagination={false}
size="small"
/>
),
}, },
{ {
key: 'measurements', key: 'measurements',
label: `Recent measurements (${data.recentMeasurements.length})`, label: `Recent measurements (${data.recentMeasurements.length})`,
children: <Table<FleetRecentMeasurement> rowKey={(r) => `${r.time}-${r.deviceId}`} columns={measCols} dataSource={data.recentMeasurements} pagination={false} size="small" />, children: (
<Table<FleetRecentMeasurement>
rowKey={(r) => `${r.time}-${r.deviceId}`}
columns={measCols}
dataSource={data.recentMeasurements}
pagination={false}
size="small"
/>
),
}, },
{ {
key: 'sites', key: 'sites',
label: `Sites (${data.sites.length})`, label: `Sites (${data.sites.length})`,
children: <Table<FleetSite> rowKey="id" columns={siteCols} dataSource={data.sites} pagination={false} size="small" />, children: (
<Table<FleetSite>
rowKey="id"
columns={siteCols}
dataSource={data.sites}
pagination={false}
size="small"
/>
),
}, },
{ {
key: 'devices', key: 'devices',
label: `Devices (${data.devices.length})`, label: `Devices (${data.devices.length})`,
children: <Table<FleetDevice> rowKey="id" columns={deviceCols} dataSource={data.devices} pagination={false} size="small" />, children: (
<Table<FleetDevice>
rowKey="id"
columns={deviceCols}
dataSource={data.devices}
pagination={false}
size="small"
/>
),
}, },
{ {
key: 'tariffs', key: 'tariffs',
label: `Tariffs (${data.tariffs.length})`, label: `Tariffs (${data.tariffs.length})`,
children: <TariffsTab tariffs={data.tariffs} municipalitiesCount={data.municipalities.length} />, children: (
<TariffsTab
tariffs={data.tariffs}
municipalitiesCount={data.municipalities.length}
/>
),
}, },
{ {
key: 'cost', key: 'cost',
@ -159,7 +308,13 @@ export function AdminCustomerDetailPage() {
} }
// ── Tariffs tab: collapsible per-tariff cards with the period table inline ─ // ── Tariffs tab: collapsible per-tariff cards with the period table inline ─
function TariffsTab({ tariffs, municipalitiesCount }: { tariffs: FleetTariffView[]; municipalitiesCount: number }) { function TariffsTab({
tariffs,
municipalitiesCount,
}: {
tariffs: FleetTariffView[];
municipalitiesCount: number;
}) {
if (tariffs.length === 0) { if (tariffs.length === 0) {
return ( return (
<Text type="secondary"> <Text type="secondary">
@ -170,14 +325,26 @@ function TariffsTab({ tariffs, municipalitiesCount }: { tariffs: FleetTariffView
} }
const periodCols: ColumnsType<FleetTariffPeriodView> = [ const periodCols: ColumnsType<FleetTariffPeriodView> = [
{ title: 'Period', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
{ {
title: 'Days', dataIndex: 'daysOfWeek', key: 'd', title: 'Period',
dataIndex: 'name',
key: 'name',
render: (v: string) => <Text strong>{v}</Text>,
},
{
title: 'Days',
dataIndex: 'daysOfWeek',
key: 'd',
render: (mask: number) => formatDays(mask), render: (mask: number) => formatDays(mask),
}, },
{ title: 'Start', dataIndex: 'startTime', key: 's' }, { title: 'Start', dataIndex: 'startTime', key: 's' },
{ title: 'End', dataIndex: 'endTime', key: 'e' }, { title: 'End', dataIndex: 'endTime', key: 'e' },
{ title: 'Rate (/kWh)', dataIndex: 'ratePerKwh', key: 'r', render: (n: number) => n.toFixed(4) }, {
title: 'Rate (/kWh)',
dataIndex: 'ratePerKwh',
key: 'r',
render: (n: number) => n.toFixed(4),
},
]; ];
return ( return (
@ -196,13 +363,18 @@ function TariffsTab({ tariffs, municipalitiesCount }: { tariffs: FleetTariffView
} }
extra={ extra={
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
{t.effectiveFrom}{t.effectiveTo ? `${t.effectiveTo}` : ' →'} {t.effectiveFrom}
{t.effectiveTo ? `${t.effectiveTo}` : ' →'}
</Text> </Text>
} }
> >
<Descriptions size="small" column={3} style={{ marginBottom: 12 }}> <Descriptions size="small" column={3} style={{ marginBottom: 12 }}>
<Descriptions.Item label="Default rate">{t.defaultRatePerKwh.toFixed(4)}/kWh</Descriptions.Item> <Descriptions.Item label="Default rate">
<Descriptions.Item label="Fixed monthly">{t.fixedMonthlyCharge.toFixed(2)}</Descriptions.Item> {t.defaultRatePerKwh.toFixed(4)}/kWh
</Descriptions.Item>
<Descriptions.Item label="Fixed monthly">
{t.fixedMonthlyCharge.toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="VAT">{t.vatPercentage.toFixed(2)}%</Descriptions.Item> <Descriptions.Item label="VAT">{t.vatPercentage.toFixed(2)}%</Descriptions.Item>
</Descriptions> </Descriptions>
{t.periods.length > 0 ? ( {t.periods.length > 0 ? (
@ -247,18 +419,35 @@ function CostTab({ customerId }: { customerId: string }) {
{ title: 'kWh', dataIndex: 'kwh', key: 'kwh', render: (n: number) => n.toFixed(3) }, { title: 'kWh', dataIndex: 'kwh', key: 'kwh', render: (n: number) => n.toFixed(3) },
{ title: 'Base cost', dataIndex: 'baseCost', key: 'b', render: (n: number) => n.toFixed(2) }, { title: 'Base cost', dataIndex: 'baseCost', key: 'b', render: (n: number) => n.toFixed(2) },
{ title: 'VAT', dataIndex: 'vatAmount', key: 'v', render: (n: number) => n.toFixed(2) }, { title: 'VAT', dataIndex: 'vatAmount', key: 'v', render: (n: number) => n.toFixed(2) },
{ title: 'Total', dataIndex: 'totalCost', key: 't', render: (n: number) => <Text strong>{n.toFixed(2)}</Text> }, {
title: 'Total',
dataIndex: 'totalCost',
key: 't',
render: (n: number) => <Text strong>{n.toFixed(2)}</Text>,
},
]; ];
const deviceCols: ColumnsType<FleetCostDeviceRow> = [ const deviceCols: ColumnsType<FleetCostDeviceRow> = [
{ title: 'Device', dataIndex: 'deviceName', key: 'd', render: (v: string) => <Text strong>{v}</Text> },
{ {
title: 'Municipality', dataIndex: 'municipalityName', key: 'm', title: 'Device',
render: (v: string | null) => v ?? <Text type="secondary"></Text> dataIndex: 'deviceName',
key: 'd',
render: (v: string) => <Text strong>{v}</Text>,
},
{
title: 'Municipality',
dataIndex: 'municipalityName',
key: 'm',
render: (v: string | null) => v ?? <Text type="secondary"></Text>,
}, },
{ title: 'kWh', dataIndex: 'kwh', key: 'kwh', render: (n: number) => n.toFixed(3) }, { title: 'kWh', dataIndex: 'kwh', key: 'kwh', render: (n: number) => n.toFixed(3) },
{ title: 'Base cost', dataIndex: 'baseCost', key: 'b', render: (n: number) => n.toFixed(2) }, { title: 'Base cost', dataIndex: 'baseCost', key: 'b', render: (n: number) => n.toFixed(2) },
{ title: 'Total', dataIndex: 'totalCost', key: 't', render: (n: number) => <Text strong>{n.toFixed(2)}</Text> }, {
title: 'Total',
dataIndex: 'totalCost',
key: 't',
render: (n: number) => <Text strong>{n.toFixed(2)}</Text>,
},
]; ];
return ( return (
@ -270,11 +459,19 @@ function CostTab({ customerId }: { customerId: string }) {
allowClear={false} allowClear={false}
value={range} value={range}
showTime={false} showTime={false}
onChange={(v) => v && v[0] && v[1] && setRange([v[0].startOf('day'), v[1].endOf('day')])} onChange={(v) =>
v && v[0] && v[1] && setRange([v[0].startOf('day'), v[1].endOf('day')])
}
ranges={{ ranges={{
'Today': [dayjs().startOf('day'), dayjs().add(1, 'day').startOf('day')], Today: [dayjs().startOf('day'), dayjs().add(1, 'day').startOf('day')],
'Last 7d': [dayjs().subtract(7, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')], 'Last 7d': [
'Last 30d': [dayjs().subtract(30, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')], 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')], 'This month': [dayjs().startOf('month'), dayjs().add(1, 'day').startOf('day')],
}} }}
/> />
@ -289,15 +486,58 @@ function CostTab({ customerId }: { customerId: string }) {
</Space> </Space>
{isLoading && <Spin />} {isLoading && <Spin />}
{error && <Alert type="error" message="Failed to compute cost" description={(error as Error).message} />} {error && (
<Alert
type="error"
message="Failed to compute cost"
description={(error as Error).message}
/>
)}
{data && ( {data && (
<> <>
<Row gutter={16}> <Row gutter={16}>
<Col span={6}><Card><Statistic title="Total kWh" value={data.totalKwh} precision={3} prefix={<ThunderboltOutlined />} /></Card></Col> <Col span={6}>
<Col span={6}><Card><Statistic title="Base cost" value={data.totalBaseCost} precision={2} prefix={<DollarOutlined />} /></Card></Col> <Card>
<Col span={6}><Card><Statistic title="VAT" value={data.totalVatAmount} precision={2} prefix={<DollarOutlined />} /></Card></Col> <Statistic
<Col span={6}><Card><Statistic title="Total" value={data.totalCost} precision={2} valueStyle={{ color: '#3f8600' }} prefix={<DollarOutlined />} /></Card></Col> title="Total kWh"
value={data.totalKwh}
precision={3}
prefix={<ThunderboltOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Base cost"
value={data.totalBaseCost}
precision={2}
prefix={<DollarOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="VAT"
value={data.totalVatAmount}
precision={2}
prefix={<DollarOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Total"
value={data.totalCost}
precision={2}
valueStyle={{ color: '#3f8600' }}
prefix={<DollarOutlined />}
/>
</Card>
</Col>
</Row> </Row>
{data.bucketsWithoutTariff > 0 && ( {data.bucketsWithoutTariff > 0 && (
@ -318,15 +558,21 @@ function CostTab({ customerId }: { customerId: string }) {
<Card title="Per day" size="small"> <Card title="Per day" size="small">
<Table<FleetCostDay> <Table<FleetCostDay>
rowKey="date" size="small" pagination={false} rowKey="date"
columns={dayCols} dataSource={data.daily} size="small"
pagination={false}
columns={dayCols}
dataSource={data.daily}
/> />
</Card> </Card>
<Card title="Per device" size="small"> <Card title="Per device" size="small">
<Table<FleetCostDeviceRow> <Table<FleetCostDeviceRow>
rowKey="deviceId" size="small" pagination={false} rowKey="deviceId"
columns={deviceCols} dataSource={data.perDevice} size="small"
pagination={false}
columns={deviceCols}
dataSource={data.perDevice}
/> />
</Card> </Card>
</> </>

View File

@ -1,7 +1,11 @@
import { Button, Card, Col, Row, Space, Statistic, Table, Tag, Typography, Empty } from 'antd'; import { Button, Card, Col, Row, Space, Statistic, Table, Tag, Typography, Empty } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
ApartmentOutlined, ThunderboltOutlined, TeamOutlined, CheckCircleOutlined, DollarOutlined, ApartmentOutlined,
ThunderboltOutlined,
TeamOutlined,
CheckCircleOutlined,
DollarOutlined,
DownloadOutlined, DownloadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@ -23,26 +27,38 @@ export function AdminFleetDashboardPage() {
{ title: 'Code', dataIndex: 'code', key: 'code', render: (v) => <Text strong>{v}</Text> }, { title: 'Code', dataIndex: 'code', key: 'code', render: (v) => <Text strong>{v}</Text> },
{ title: 'Name', dataIndex: 'name', key: 'name' }, { title: 'Name', dataIndex: 'name', key: 'name' },
{ {
title: 'Status', dataIndex: 'isActive', key: 'active', title: 'Status',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>) dataIndex: 'isActive',
key: 'active',
render: (v: boolean) =>
v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
}, },
{ {
title: 'Last push', dataIndex: 'lastSeenAt', key: 'last', title: 'Last push',
render: (v: string | null) => v ? lagDescription(v) : <Text type="secondary">Never</Text> dataIndex: 'lastSeenAt',
key: 'last',
render: (v: string | null) => (v ? lagDescription(v) : <Text type="secondary">Never</Text>),
}, },
{ title: 'Sites', dataIndex: 'sites', key: 'sites' }, { title: 'Sites', dataIndex: 'sites', key: 'sites' },
{ title: 'Devices', dataIndex: 'devices', key: 'devices' }, { title: 'Devices', dataIndex: 'devices', key: 'devices' },
{ {
title: 'Today (rows)', dataIndex: 'measurementsToday', key: 'mt', title: 'Today (rows)',
render: (n: number) => n.toLocaleString() dataIndex: 'measurementsToday',
key: 'mt',
render: (n: number) => n.toLocaleString(),
}, },
{ {
title: 'Today (kWh imp.)', dataIndex: 'kwhImportedToday', key: 'kwh', title: 'Today (kWh imp.)',
render: (v: number | null) => v == null ? '—' : v.toFixed(2) dataIndex: 'kwhImportedToday',
key: 'kwh',
render: (v: number | null) => (v == null ? '—' : v.toFixed(2)),
}, },
{ {
title: 'Today (cost)', dataIndex: 'costToday', key: 'cost', title: 'Today (cost)',
render: (v: number | null) => v == null ? <Text type="secondary"></Text> : <Text strong>{v.toFixed(2)}</Text> dataIndex: 'costToday',
key: 'cost',
render: (v: number | null) =>
v == null ? <Text type="secondary"></Text> : <Text strong>{v.toFixed(2)}</Text>,
}, },
]; ];
@ -51,7 +67,12 @@ export function AdminFleetDashboardPage() {
<Row gutter={16} style={{ marginBottom: 16 }}> <Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={5}> <Col span={5}>
<Card> <Card>
<Statistic title="Customers" value={data?.totalCustomers ?? 0} prefix={<TeamOutlined />} loading={isLoading} /> <Statistic
title="Customers"
value={data?.totalCustomers ?? 0}
prefix={<TeamOutlined />}
loading={isLoading}
/>
</Card> </Card>
</Col> </Col>
<Col span={5}> <Col span={5}>

View File

@ -4,9 +4,18 @@ import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
listSites, createSite, updateSite, deleteSite, listSites,
listSiteDevices, createDevice, updateDevice, deleteDevice, createSite,
type Site, type Device, type UpsertSite, type UpsertDevice, updateSite,
deleteSite,
listSiteDevices,
createDevice,
updateDevice,
deleteDevice,
type Site,
type Device,
type UpsertSite,
type UpsertDevice,
} from '../api/sites'; } from '../api/sites';
import { SiteFormModal } from '../components/sites/SiteFormModal'; import { SiteFormModal } from '../components/sites/SiteFormModal';
import { DeviceFormModal } from '../components/sites/DeviceFormModal'; import { DeviceFormModal } from '../components/sites/DeviceFormModal';
@ -15,9 +24,18 @@ const { Text } = Typography;
export function AdminSitesPage() { export function AdminSitesPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const [siteModal, setSiteModal] = useState<{ open: boolean; editing: Site | null }>({ open: false, editing: null }); const [siteModal, setSiteModal] = useState<{ open: boolean; editing: Site | null }>({
const [deviceModal, setDeviceModal] = useState<{ open: boolean; siteId: string | null; editing: Device | null }>({ open: false,
open: false, siteId: null, editing: null, editing: null,
});
const [deviceModal, setDeviceModal] = useState<{
open: boolean;
siteId: string | null;
editing: Device | null;
}>({
open: false,
siteId: null,
editing: null,
}); });
const [expanded, setExpanded] = useState<string[]>([]); const [expanded, setExpanded] = useState<string[]>([]);
@ -25,22 +43,34 @@ export function AdminSitesPage() {
const createSiteMut = useMutation({ const createSiteMut = useMutation({
mutationFn: (p: UpsertSite) => createSite(p), mutationFn: (p: UpsertSite) => createSite(p),
onSuccess: () => { message.success('Site created'); setSiteModal({ open: false, editing: null }); qc.invalidateQueries({ queryKey: ['sites'] }); }, onSuccess: () => {
message.success('Site created');
setSiteModal({ open: false, editing: null });
qc.invalidateQueries({ queryKey: ['sites'] });
},
onError: () => message.error('Create failed'), onError: () => message.error('Create failed'),
}); });
const updateSiteMut = useMutation({ const updateSiteMut = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpsertSite }) => updateSite(id, payload), mutationFn: ({ id, payload }: { id: string; payload: UpsertSite }) => updateSite(id, payload),
onSuccess: () => { message.success('Site updated'); setSiteModal({ open: false, editing: null }); qc.invalidateQueries({ queryKey: ['sites'] }); }, onSuccess: () => {
message.success('Site updated');
setSiteModal({ open: false, editing: null });
qc.invalidateQueries({ queryKey: ['sites'] });
},
onError: () => message.error('Update failed'), onError: () => message.error('Update failed'),
}); });
const deleteSiteMut = useMutation({ const deleteSiteMut = useMutation({
mutationFn: (id: string) => deleteSite(id), mutationFn: (id: string) => deleteSite(id),
onSuccess: () => { message.success('Site deleted'); qc.invalidateQueries({ queryKey: ['sites'] }); }, onSuccess: () => {
message.success('Site deleted');
qc.invalidateQueries({ queryKey: ['sites'] });
},
onError: () => message.error('Delete failed'), onError: () => message.error('Delete failed'),
}); });
const createDeviceMut = useMutation({ const createDeviceMut = useMutation({
mutationFn: ({ siteId, payload }: { siteId: string; payload: UpsertDevice }) => createDevice(siteId, payload), mutationFn: ({ siteId, payload }: { siteId: string; payload: UpsertDevice }) =>
createDevice(siteId, payload),
onSuccess: (_, vars) => { onSuccess: (_, vars) => {
message.success('Device created'); message.success('Device created');
setDeviceModal({ open: false, siteId: null, editing: null }); setDeviceModal({ open: false, siteId: null, editing: null });
@ -50,7 +80,8 @@ export function AdminSitesPage() {
onError: () => message.error('Create failed'), onError: () => message.error('Create failed'),
}); });
const updateDeviceMut = useMutation({ const updateDeviceMut = useMutation({
mutationFn: ({ deviceId, payload }: { deviceId: string; payload: UpsertDevice }) => updateDevice(deviceId, payload), mutationFn: ({ deviceId, payload }: { deviceId: string; payload: UpsertDevice }) =>
updateDevice(deviceId, payload),
onSuccess: () => { onSuccess: () => {
message.success('Device updated'); message.success('Device updated');
setDeviceModal({ open: false, siteId: null, editing: null }); setDeviceModal({ open: false, siteId: null, editing: null });
@ -84,26 +115,40 @@ export function AdminSitesPage() {
const columns: ColumnsType<Site> = [ const columns: ColumnsType<Site> = [
{ title: 'Name', dataIndex: 'name', key: 'name' }, { title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'Address', dataIndex: 'address', key: 'address', render: (v) => v ?? <Text type="secondary"></Text> }, {
title: 'Address',
dataIndex: 'address',
key: 'address',
render: (v) => v ?? <Text type="secondary"></Text>,
},
{ title: 'Devices', dataIndex: 'deviceCount', key: 'deviceCount' }, { title: 'Devices', dataIndex: 'deviceCount', key: 'deviceCount' },
{ {
title: 'Active', title: 'Active',
dataIndex: 'isActive', dataIndex: 'isActive',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>), render: (v: boolean) =>
v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
}, },
{ {
title: 'Actions', title: 'Actions',
key: 'actions', key: 'actions',
render: (_, site) => ( render: (_, site) => (
<Space> <Space>
<Button size="small" icon={<EditOutlined />} onClick={() => setSiteModal({ open: true, editing: site })}>Edit</Button> <Button
size="small"
icon={<EditOutlined />}
onClick={() => setSiteModal({ open: true, editing: site })}
>
Edit
</Button>
<Popconfirm <Popconfirm
title={`Delete site "${site.name}"? All its devices and measurements will be removed.`} title={`Delete site "${site.name}"? All its devices and measurements will be removed.`}
okText="Delete" okText="Delete"
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
onConfirm={() => deleteSiteMut.mutate(site.id)} onConfirm={() => deleteSiteMut.mutate(site.id)}
> >
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button> <Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@ -114,7 +159,11 @@ export function AdminSitesPage() {
<Card <Card
title="Sites" title="Sites"
extra={ extra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => setSiteModal({ open: true, editing: null })}> <Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setSiteModal({ open: true, editing: null })}
>
New site New site
</Button> </Button>
} }
@ -159,7 +208,10 @@ export function AdminSitesPage() {
} }
function DeviceSubTable({ function DeviceSubTable({
site, onCreate, onEdit, onDelete, site,
onCreate,
onEdit,
onDelete,
}: { }: {
site: Site; site: Site;
onCreate: () => void; onCreate: () => void;
@ -185,14 +237,18 @@ function DeviceSubTable({
key: 'actions', key: 'actions',
render: (_, d) => ( render: (_, d) => (
<Space> <Space>
<Button size="small" icon={<EditOutlined />} onClick={() => onEdit(d)}>Edit</Button> <Button size="small" icon={<EditOutlined />} onClick={() => onEdit(d)}>
Edit
</Button>
<Popconfirm <Popconfirm
title={`Delete device "${d.name}"? Its measurements will be removed.`} title={`Delete device "${d.name}"? Its measurements will be removed.`}
okText="Delete" okText="Delete"
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
onConfirm={() => onDelete(d)} onConfirm={() => onDelete(d)}
> >
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button> <Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@ -202,7 +258,9 @@ function DeviceSubTable({
return ( return (
<Card size="small" style={{ background: '#fafafa' }}> <Card size="small" style={{ background: '#fafafa' }}>
<Space style={{ marginBottom: 8, justifyContent: 'flex-end', width: '100%' }}> <Space style={{ marginBottom: 8, justifyContent: 'flex-end', width: '100%' }}>
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onCreate}>New device</Button> <Button type="primary" size="small" icon={<PlusOutlined />} onClick={onCreate}>
New device
</Button>
</Space> </Space>
<Table<Device> <Table<Device>
rowKey="id" rowKey="id"

View File

@ -1,9 +1,24 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { import {
Alert, Button, Card, Col, DatePicker, Row, Space, Spin, Statistic, Table, Tag, Typography, Alert,
Button,
Card,
Col,
DatePicker,
Row,
Space,
Spin,
Statistic,
Table,
Tag,
Typography,
} from 'antd'; } from 'antd';
import { import {
DollarOutlined, DownloadOutlined, ThunderboltOutlined, ApartmentOutlined, ClockCircleOutlined, DollarOutlined,
DownloadOutlined,
ThunderboltOutlined,
ApartmentOutlined,
ClockCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@ -12,8 +27,11 @@ import dayjs, { type Dayjs } from 'dayjs';
import { useAppInfo } from '../hooks/useAppInfo'; import { useAppInfo } from '../hooks/useAppInfo';
import { AdminFleetDashboardPage } from './AdminFleetDashboardPage'; import { AdminFleetDashboardPage } from './AdminFleetDashboardPage';
import { import {
fetchDashboardSummary, downloadDashboardSummaryXlsx, downloadRawMeasurementsXlsx, fetchDashboardSummary,
type DashboardDeviceRow, type DashboardSummary, downloadDashboardSummaryXlsx,
downloadRawMeasurementsXlsx,
type DashboardDeviceRow,
type DashboardSummary,
} from '../api/dashboard'; } from '../api/dashboard';
const { Text } = Typography; const { Text } = Typography;
@ -48,12 +66,20 @@ function ClientDashboard() {
<RangePicker <RangePicker
allowClear={false} allowClear={false}
value={range} value={range}
onChange={(v) => v && v[0] && v[1] && setRange([v[0].startOf('day'), v[1].endOf('day')])} onChange={(v) =>
v && v[0] && v[1] && setRange([v[0].startOf('day'), v[1].endOf('day')])
}
ranges={{ ranges={{
'Today': [dayjs().startOf('day'), dayjs().add(1, 'day').startOf('day')], Today: [dayjs().startOf('day'), dayjs().add(1, 'day').startOf('day')],
'Last 7d': [dayjs().subtract(7, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')], 'Last 7d': [
'Last 30d': [dayjs().subtract(30, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')], dayjs().subtract(7, 'day').startOf('day'),
'This month': [dayjs().startOf('month'), dayjs().add(1, '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')],
}} }}
/> />
</Space> </Space>
@ -76,7 +102,8 @@ function ClientDashboard() {
{error && ( {error && (
<Alert <Alert
type="error" showIcon type="error"
showIcon
message="Failed to load dashboard" message="Failed to load dashboard"
description={(error as Error).message} description={(error as Error).message}
/> />
@ -169,46 +196,93 @@ function ChartPanel({ data, loading }: { data: DashboardSummary | undefined; loa
name: 'kW', name: 'kW',
nameTextStyle: { fontSize: 11 }, nameTextStyle: { fontSize: 11 },
}, },
series: [{ series: [
name: 'Active power', {
type: 'line', name: 'Active power',
smooth: true, type: 'line',
showSymbol: false, smooth: true,
areaStyle: { opacity: 0.15 }, showSymbol: false,
data: points.map(p => [p.time, p.totalKw]), areaStyle: { opacity: 0.15 },
}], data: points.map((p) => [p.time, p.totalKw]),
},
],
}; };
}, [data]); }, [data]);
if (loading) { if (loading) {
return <div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> return (
<Spin /> <div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
</div>; <Spin />
</div>
);
} }
if (!data || data.chart.length === 0) { if (!data || data.chart.length === 0) {
return <div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}> return (
<Space> <div
<ClockCircleOutlined /> style={{
<Text type="secondary">No measurements yet in this window.</Text> height: 240,
</Space> display: 'flex',
</div>; alignItems: 'center',
justifyContent: 'center',
color: '#999',
}}
>
<Space>
<ClockCircleOutlined />
<Text type="secondary">No measurements yet in this window.</Text>
</Space>
</div>
);
} }
return <ReactECharts option={option} style={{ height: 240, width: '100%' }} notMerge lazyUpdate />; return (
<ReactECharts option={option} style={{ height: 240, width: '100%' }} notMerge lazyUpdate />
);
} }
function DeviceTable({ rows, loading }: { rows: DashboardDeviceRow[]; loading: boolean }) { function DeviceTable({ rows, loading }: { rows: DashboardDeviceRow[]; loading: boolean }) {
const columns: ColumnsType<DashboardDeviceRow> = [ const columns: ColumnsType<DashboardDeviceRow> = [
{ 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: 'Device',
{ title: 'kWh', dataIndex: 'kwh', key: 'k', render: (v: number) => v.toFixed(3), align: 'right' as const }, dataIndex: 'deviceName',
{ title: 'Peak kW', dataIndex: 'peakKw', key: 'p', key: 'd',
render: (v: number | null) => v == null ? '—' : v.toFixed(3), align: 'right' as const }, render: (v: string) => <Text strong>{v}</Text>,
{ title: 'Last seen (UTC)', dataIndex: 'lastSeen', key: 'l', },
render: (v: string | null) => v ? lastSeenTag(v) : <Text type="secondary">never</Text> }, {
{ title: 'Cost', dataIndex: 'cost', key: 'c', align: 'right' as const, title: 'Site',
render: (v: number | null) => v == null ? <Text type="secondary"></Text> : <Text strong>{v.toFixed(2)}</Text> }, dataIndex: 'siteName',
key: 's',
render: (v: string | null) => v ?? <Text type="secondary"></Text>,
},
{
title: 'kWh',
dataIndex: 'kwh',
key: 'k',
render: (v: number) => v.toFixed(3),
align: 'right' as const,
},
{
title: 'Peak kW',
dataIndex: 'peakKw',
key: 'p',
render: (v: number | null) => (v == null ? '—' : v.toFixed(3)),
align: 'right' as const,
},
{
title: 'Last seen (UTC)',
dataIndex: 'lastSeen',
key: 'l',
render: (v: string | null) => (v ? lastSeenTag(v) : <Text type="secondary">never</Text>),
},
{
title: 'Cost',
dataIndex: 'cost',
key: 'c',
align: 'right' as const,
render: (v: number | null) =>
v == null ? <Text type="secondary"></Text> : <Text strong>{v.toFixed(2)}</Text>,
},
]; ];
return ( return (

View File

@ -1,13 +1,24 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { import {
Alert, Button, Card, DatePicker, InputNumber, Select, Space, Table, Tag, Typography, Alert,
Button,
Card,
DatePicker,
InputNumber,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { DownloadOutlined, ReloadOutlined } from '@ant-design/icons'; import { DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import dayjs, { type Dayjs } from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import { import {
fetchRawMeasurements, listAllDevices, downloadRawMeasurementsExport, fetchRawMeasurements,
listAllDevices,
downloadRawMeasurementsExport,
type RawMeasurementRow, type RawMeasurementRow,
} from '../api/measurements'; } from '../api/measurements';
@ -37,42 +48,97 @@ export function MeasurementsPage() {
}); });
const { data, isLoading, isFetching, error, refetch } = useQuery({ const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: ['raw-measurements', fromIso, toIso, selectedDeviceIds.sort().join(','), limit, offset], queryKey: [
queryFn: () => fetchRawMeasurements({ 'raw-measurements',
fromUtc: fromIso, fromIso,
toUtc: toIso, toIso,
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined, selectedDeviceIds.sort().join(','),
limit, limit,
offset, offset,
}), ],
queryFn: () =>
fetchRawMeasurements({
fromUtc: fromIso,
toUtc: toIso,
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined,
limit,
offset,
}),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
const deviceOptions = useMemo(() => devices.map(d => ({ const deviceOptions = useMemo(
value: d.id, () =>
label: `${d.name}${d.siteName}`, devices.map((d) => ({
disabled: !d.isActive, value: d.id,
})), [devices]); label: `${d.name}${d.siteName}`,
disabled: !d.isActive,
})),
[devices],
);
const columns: ColumnsType<RawMeasurementRow> = [ const columns: ColumnsType<RawMeasurementRow> = [
{ {
title: 'Time (UTC)', dataIndex: 'time', key: 't', width: 180, title: 'Time (UTC)',
dataIndex: 'time',
key: 't',
width: 180,
render: (v: string) => new Date(v).toISOString().replace('T', ' ').slice(0, 19), 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: 'Device',
{ title: 'Active kW', dataIndex: 'activePowerKw', key: 'kw', align: 'right' as const, dataIndex: 'deviceName',
render: (v: number) => v.toFixed(3) }, key: 'd',
{ title: 'kWh imported (cum.)', dataIndex: 'energyImportedKwh', key: 'imp', align: 'right' as const, render: (v: string) => <Text strong>{v}</Text>,
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: 'Site',
{ title: 'PF', dataIndex: 'powerFactor', key: 'pf', align: 'right' as const, dataIndex: 'siteName',
render: (v: number | null) => v == null ? '—' : v.toFixed(3) }, key: 's',
{ title: 'V', dataIndex: 'voltageV', key: 'v', align: 'right' as const, render: (v: string | null) => v ?? <Text type="secondary"></Text>,
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) }, 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 totalCount = data?.totalCount ?? 0;
@ -91,27 +157,43 @@ export function MeasurementsPage() {
allowClear={false} allowClear={false}
value={range} value={range}
showTime={false} showTime={false}
onChange={(v) => v && v[0] && v[1] && (setRange([v[0].startOf('day'), v[1].endOf('day')]), setOffset(0))} onChange={(v) =>
v &&
v[0] &&
v[1] &&
(setRange([v[0].startOf('day'), v[1].endOf('day')]), setOffset(0))
}
ranges={{ ranges={{
'Today': [dayjs().startOf('day'), dayjs().add(1, 'day').startOf('day')], Today: [dayjs().startOf('day'), dayjs().add(1, 'day').startOf('day')],
'Yesterday': [dayjs().subtract(1, 'day').startOf('day'), dayjs().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 7d': [
'Last 30d': [dayjs().subtract(30, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')], dayjs().subtract(7, 'day').startOf('day'),
'This month': [dayjs().startOf('month'), dayjs().add(1, 'day').startOf('day')], dayjs().add(1, 'day').startOf('day'),
'All time': [dayjs('2020-01-01'), 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>
<Space wrap style={{ width: '100%' }}> <Space wrap style={{ width: '100%' }}>
<Text strong style={{ minWidth: 80 }}>Meters:</Text> <Text strong style={{ minWidth: 80 }}>
Meters:
</Text>
<Select <Select
mode="multiple" mode="multiple"
allowClear allowClear
placeholder={loadingDevices ? 'Loading devices…' : 'All meters (leave empty)'} placeholder={loadingDevices ? 'Loading devices…' : 'All meters (leave empty)'}
style={{ minWidth: 360, flex: 1 }} style={{ minWidth: 360, flex: 1 }}
value={selectedDeviceIds} value={selectedDeviceIds}
onChange={(v) => { setSelectedDeviceIds(v); setOffset(0); }} onChange={(v) => {
setSelectedDeviceIds(v);
setOffset(0);
}}
options={deviceOptions} options={deviceOptions}
maxTagCount="responsive" maxTagCount="responsive"
showSearch showSearch
@ -123,8 +205,11 @@ export function MeasurementsPage() {
<Text strong>Preview rows:</Text> <Text strong>Preview rows:</Text>
<Select <Select
value={limit} value={limit}
onChange={(v) => { setLimit(v); setOffset(0); }} onChange={(v) => {
options={PREVIEW_LIMIT_OPTIONS.map(n => ({ value: n, label: n.toString() }))} setLimit(v);
setOffset(0);
}}
options={PREVIEW_LIMIT_OPTIONS.map((n) => ({ value: n, label: n.toString() }))}
style={{ width: 96 }} style={{ width: 96 }}
/> />
<Text type="secondary">·</Text> <Text type="secondary">·</Text>
@ -146,7 +231,8 @@ export function MeasurementsPage() {
{error && ( {error && (
<Alert <Alert
type="error" showIcon type="error"
showIcon
message="Failed to load measurements" message="Failed to load measurements"
description={(error as Error).message} description={(error as Error).message}
/> />
@ -158,7 +244,9 @@ export function MeasurementsPage() {
<Text strong>Measurements</Text> <Text strong>Measurements</Text>
<Tag color="blue">{totalCount.toLocaleString()} rows match</Tag> <Tag color="blue">{totalCount.toLocaleString()} rows match</Tag>
{selectedDeviceIds.length > 0 && ( {selectedDeviceIds.length > 0 && (
<Tag>{selectedDeviceIds.length} meter{selectedDeviceIds.length === 1 ? '' : 's'} selected</Tag> <Tag>
{selectedDeviceIds.length} meter{selectedDeviceIds.length === 1 ? '' : 's'} selected
</Tag>
)} )}
</Space> </Space>
} }
@ -171,11 +259,14 @@ export function MeasurementsPage() {
type="primary" type="primary"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
disabled={totalCount === 0} disabled={totalCount === 0}
onClick={() => downloadRawMeasurementsExport({ onClick={() =>
fromUtc: fromIso, toUtc: toIso, downloadRawMeasurementsExport({
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined, fromUtc: fromIso,
rowCap: exportRowCap, toUtc: toIso,
})} deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined,
rowCap: exportRowCap,
})
}
> >
Export to Excel Export to Excel
</Button> </Button>
@ -199,7 +290,10 @@ export function MeasurementsPage() {
: `Showing ${showingFrom.toLocaleString()}${showingTo.toLocaleString()} of ${totalCount.toLocaleString()}`} : `Showing ${showingFrom.toLocaleString()}${showingTo.toLocaleString()} of ${totalCount.toLocaleString()}`}
</Text> </Text>
<Space> <Space>
<Button disabled={!canPrev || isFetching} onClick={() => setOffset(Math.max(0, offset - limit))}> <Button
disabled={!canPrev || isFetching}
onClick={() => setOffset(Math.max(0, offset - limit))}
>
Previous Previous
</Button> </Button>
<Button disabled={!canNext || isFetching} onClick={() => setOffset(offset + limit)}> <Button disabled={!canNext || isFetching} onClick={() => setOffset(offset + limit)}>