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;
}
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 });
}

View File

@ -24,7 +24,10 @@ export interface DashboardSummary {
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', {
params: { from: fromUtc, to: toUtc },
});

View File

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

View File

@ -46,7 +46,8 @@ export async function fetchRawMeasurements(params: {
params: {
from: params.fromUtc,
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,
offset: params.offset,
},
@ -64,7 +65,8 @@ export function downloadRawMeasurementsExport(params: {
from: params.fromUtc,
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));
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[]> {
const { data } = await api.get<TariffSummary[]>(`/rates/municipalities/${municipalityId}/tariffs`);
const { data } = await api.get<TariffSummary[]>(
`/rates/municipalities/${municipalityId}/tariffs`,
);
return data;
}
@ -86,7 +88,10 @@ export async function getTariff(tariffId: number): Promise<TariffDetail> {
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>(
`/admin/rates/municipalities/${municipalityId}/tariffs`,
payload,

View File

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

View File

@ -7,11 +7,7 @@ export function RequireRole({ role, children }: { role: string; children: ReactN
if (!user || !user.roles.includes(role)) {
return (
<Result
status="403"
title="403"
subTitle="You do not have permission to view this page."
/>
<Result 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 { 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;
@ -68,7 +72,11 @@ export function CustomerFormModal({ open, mode, submitting, error, onClose, onSu
>
<Input disabled={isEdit} placeholder="ABC0001" maxLength={50} />
</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 />
</Form.Item>
{isEdit && (
@ -78,8 +86,8 @@ export function CustomerFormModal({ open, mode, submitting, error, onClose, onSu
)}
{!isEdit && (
<Text type="secondary" style={{ fontSize: 12 }}>
A push token will be generated and shown once. Set it as <Text code>FleetIngest__Token</Text> in
the customer's environment.
A push token will be generated and shown once. Set it as{' '}
<Text code>FleetIngest__Token</Text> in the customer's environment.
</Text>
)}
</Form>

View File

@ -53,16 +53,19 @@ export function TokenShownOnceModal({ open, customerCode, token, onClose }: Prop
/>
<Paragraph>
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 type="secondary" style={{ fontSize: 12 }}>
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
within that window to avoid dropped pushes.
work for 24h. Update the customer's <Text code>.env</Text> and restart their portal within
that window to avoid dropped pushes.
</Paragraph>
<Space.Compact style={{ width: '100%' }}>
<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>
<Paragraph style={{ marginTop: 16 }}>
<Button onClick={() => setConfirmed(true)} disabled={confirmed}>

View File

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

View File

@ -1,5 +1,17 @@
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 { UploadOutlined, SaveOutlined } from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@ -26,7 +38,10 @@ export function BrandingForm() {
const [form] = Form.useForm<FormShape>();
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(() => {
if (!branding) return;
@ -75,7 +90,8 @@ export function BrandingForm() {
const beforeUpload = (file: UploadFile) => {
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;
};
@ -103,7 +119,13 @@ export function BrandingForm() {
<img
src={preview.logoUrl}
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
@ -143,7 +165,12 @@ export function BrandingForm() {
</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
</Button>
</Form.Item>
@ -164,11 +191,22 @@ export function BrandingForm() {
}}
>
{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>
</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
</div>
<Button type="primary" style={{ marginTop: 12 }}>

View File

@ -25,7 +25,13 @@ export function ConfigOverviewCard() {
{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="Environment">
<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>
<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="Resolved via">
<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="Database">{data.database.database}</Descriptions.Item>
<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 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>
<Descriptions title="Grafana" column={2} 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
title="Grafana"
column={2}
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="Auth mode">{data.grafana.authMode}</Descriptions.Item>
<Descriptions.Item label="Default dashboard UID">{data.grafana.defaultDashboardUid || '(unset)'}</Descriptions.Item>
<Descriptions.Item label="Dashboards configured" span={2}>{data.grafana.dashboardCount}</Descriptions.Item>
</Descriptions>
<Descriptions title="Monitoring" column={2} 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 label="Default dashboard UID">
{data.grafana.defaultDashboardUid || '(unset)'}
</Descriptions.Item>
<Descriptions.Item label="Dashboards configured" span={2}>
{data.grafana.dashboardCount}
</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
title="Monitoring"
column={2}
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 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="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>
{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">
{data.fleetIngest.enabled
? <Tag color="green">Yes</Tag>
: <Tag>No (push service not running)</Tag>}
{data.fleetIngest.enabled ? (
<Tag color="green">Yes</Tag>
) : (
<Tag>No (push service not running)</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="Token configured">
{data.fleetIngest.tokenConfigured
? <Tag color="green">Yes (value hidden)</Tag>
: <Tag color="red">No</Tag>}
{data.fleetIngest.tokenConfigured ? (
<Tag color="green">Yes (value hidden)</Tag>
) : (
<Tag color="red">No</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="Url" span={2}>
{data.fleetIngest.url
? <Text code>{data.fleetIngest.url}</Text>
: <Text type="secondary">(unset)</Text>}
{data.fleetIngest.url ? (
<Text code>{data.fleetIngest.url}</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 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}>
{data.fleetIngest.batchMaxBytes.toLocaleString()} bytes
</Descriptions.Item>
@ -130,23 +210,44 @@ export function ConfigOverviewCard() {
}
const pushStateColumns: ColumnsType<FleetPushStateRow> = [
{ title: 'Resource', dataIndex: 'resourceType', key: 'rt', render: (v: string) => <Text strong>{v}</Text> },
{
title: 'Last cursor', dataIndex: 'lastCursor', key: 'lc',
render: (v: string | null) => v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>
title: 'Resource',
dataIndex: 'resourceType',
key: 'rt',
render: (v: string) => <Text strong>{v}</Text>,
},
{
title: 'Last sync', dataIndex: 'lastSyncedAt', key: 'ls',
render: (v: string | null) => v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>
title: 'Last cursor',
dataIndex: 'lastCursor',
key: 'lc',
render: (v: string | null) =>
v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>,
},
{
title: 'Failures', dataIndex: 'consecutiveFailures', key: 'cf',
render: (n: number) => n === 0
? <Tag color="green">0</Tag>
: <Tag color={n > 5 ? 'red' : 'orange'}>{n}</Tag>
title: 'Last sync',
dataIndex: 'lastSyncedAt',
key: 'ls',
render: (v: string | null) =>
v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>,
},
{
title: 'Last error', dataIndex: 'lastError', key: 'le',
render: (v: string | null) => v ? <Text type="danger" style={{ fontSize: 12 }}>{v}</Text> : <Text type="secondary"></Text>
title: 'Failures',
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() {
const { data, isLoading } = useQuery({ queryKey: ['grafana-config'], queryFn: fetchGrafanaConfig });
const { data, isLoading } = useQuery({
queryKey: ['grafana-config'],
queryFn: fetchGrafanaConfig,
});
const baseUrl = data?.baseUrl?.replace(/\/$/, '') ?? '';
const rows: DashboardRow[] = (data?.dashboards ?? []).map((d) => ({
@ -38,7 +41,9 @@ export function GrafanaInfoCard() {
key: 'url',
render: (url: string) => (
<Space>
<Text code style={{ fontSize: 11 }}>{url}</Text>
<Text code style={{ fontSize: 11 }}>
{url}
</Text>
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(url)} />
</Space>
),
@ -52,7 +57,11 @@ export function GrafanaInfoCard() {
<Space>
<Text code>{data?.baseUrl}</Text>
{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
</Button>
)}
@ -61,12 +70,14 @@ export function GrafanaInfoCard() {
<Descriptions.Item label="Default dashboard UID">
<Text code>{data?.defaultDashboardUid || '(unset)'}</Text>
</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>
<Paragraph type="secondary" style={{ fontSize: 12 }}>
Add dashboards by dropping JSON into <Text code>grafana/dashboards/</Text> and adding a matching entry
to <Text code>Grafana.Dashboards</Text> in configuration.
Add dashboards by dropping JSON into <Text code>grafana/dashboards/</Text> and adding a
matching entry to <Text code>Grafana.Dashboards</Text> in configuration.
</Paragraph>
<Table<DashboardRow>

View File

@ -1,14 +1,33 @@
import { useState } from 'react';
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';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
listMunicipalities, createMunicipality, updateMunicipality, deleteMunicipality,
listTariffs, getTariff, deleteTariff,
type Municipality, type TariffSummary, type UpsertMunicipality, type TariffDetail,
listMunicipalities,
createMunicipality,
updateMunicipality,
deleteMunicipality,
listTariffs,
getTariff,
deleteTariff,
type Municipality,
type TariffSummary,
type UpsertMunicipality,
type TariffDetail,
} from '../../../api/rates';
import { TariffDrawer } from './TariffDrawer';
@ -41,17 +60,29 @@ export function MunicipalityList() {
const createMut = useMutation({
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'),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: UpsertMunicipality }) => updateMunicipality(id, payload),
onSuccess: () => { message.success('Updated'); closeMuni(); qc.invalidateQueries({ queryKey: ['municipalities'] }); },
mutationFn: ({ id, payload }: { id: number; payload: UpsertMunicipality }) =>
updateMunicipality(id, payload),
onSuccess: () => {
message.success('Updated');
closeMuni();
qc.invalidateQueries({ queryKey: ['municipalities'] });
},
onError: () => message.error('Update failed'),
});
const deleteMut = useMutation({
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)'),
});
const deleteTariffMut = useMutation({
@ -93,27 +124,37 @@ export function MunicipalityList() {
const columns: ColumnsType<Municipality> = [
{ 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: 'Active',
dataIndex: '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',
key: 'actions',
render: (_, m) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEditMuni(m)}>Edit</Button>
<Button size="small" icon={<EditOutlined />} onClick={() => openEditMuni(m)}>
Edit
</Button>
<Popconfirm
title={`Delete ${m.name}? All its tariffs will be removed.`}
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => deleteMut.mutate(m.id)}
>
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
<Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm>
</Space>
),
@ -123,8 +164,12 @@ export function MunicipalityList() {
return (
<>
<Space style={{ marginBottom: 16, justifyContent: 'space-between', width: '100%' }}>
<Text type="secondary">Configure municipalities and their tariffs. Expand a row to see tariffs.</Text>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateMuni}>New municipality</Button>
<Text type="secondary">
Configure municipalities and their tariffs. Expand a row to see tariffs.
</Text>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateMuni}>
New municipality
</Button>
</Space>
<Table<Municipality>
@ -154,7 +199,12 @@ export function MunicipalityList() {
onOk={() => muniForm.submit()}
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' }]}>
<Input />
</Form.Item>
@ -198,9 +248,23 @@ function TariffSubTable({
const columns: ColumnsType<TariffSummary> = [
{ 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: 'Fixed', dataIndex: 'fixedMonthlyCharge', key: 'fixed', render: (v: number) => v.toFixed(2) },
{
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: 'Fixed',
dataIndex: 'fixedMonthlyCharge',
key: 'fixed',
render: (v: number) => v.toFixed(2),
},
{ title: 'VAT %', dataIndex: 'vatPercentage', key: 'vat', render: (v: number) => v.toFixed(2) },
{ title: 'Periods', dataIndex: 'periodCount', key: 'periods' },
{
@ -213,14 +277,18 @@ function TariffSubTable({
key: 'actions',
render: (_, t) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => onEdit(t.id)}>Edit</Button>
<Button size="small" icon={<EditOutlined />} onClick={() => onEdit(t.id)}>
Edit
</Button>
<Popconfirm
title={`Delete tariff "${t.name}"?`}
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => onDelete(t.id)}
>
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
<Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm>
</Space>
),
@ -230,7 +298,9 @@ function TariffSubTable({
return (
<Card size="small" style={{ background: '#fafafa' }}>
<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>
<Table<TariffSummary>
rowKey="id"

View File

@ -68,7 +68,10 @@ export function PeriodEditor({ value, onChange }: Props) {
{DAY_OPTIONS.map((d, dIdx) => {
const active = (p.daysOfWeek & d.value) !== 0;
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
size="small"
type={active ? 'primary' : 'default'}

View File

@ -111,7 +111,11 @@ export function CustomerAccessModal({ open, user, onClose }: Props) {
onChange={() => toggle(c.id)}
>
<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.Group>

View File

@ -4,9 +4,7 @@ import type { UserListItem, CreateUserPayload, UpdateUserPayload } from '../../a
const { Text } = Typography;
type Mode =
| { kind: 'create' }
| { kind: 'edit'; user: UserListItem };
type Mode = { kind: 'create' } | { kind: 'edit'; user: UserListItem };
interface Props {
open: boolean;
@ -99,7 +97,9 @@ export function UserFormDrawer({ open, mode, submitting, error, onClose, onSubmi
<Form.Item
name="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" />
</Form.Item>
@ -109,15 +109,22 @@ export function UserFormDrawer({ open, mode, submitting, error, onClose, onSubmi
<Space direction="vertical">
<Radio value="none">
<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 value="admin">
<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 value="restricted">
<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>
</Space>
</Radio.Group>

View File

@ -1,21 +1,44 @@
import { useState } from 'react';
import {
Card, Descriptions, Tabs, Table, Tag, Typography, Button, Space, Spin, Result, Tooltip,
DatePicker, Statistic, Row, Col, Alert,
Card,
Descriptions,
Tabs,
Table,
Tag,
Typography,
Button,
Space,
Spin,
Result,
Tooltip,
DatePicker,
Statistic,
Row,
Col,
Alert,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ArrowLeftOutlined, LineChartOutlined, ThunderboltOutlined, DollarOutlined, DownloadOutlined,
ArrowLeftOutlined,
LineChartOutlined,
ThunderboltOutlined,
DollarOutlined,
DownloadOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import dayjs, { type Dayjs } from 'dayjs';
import {
fetchFleetCustomerDetail, fetchFleetCustomerCost,
type FleetSite, type FleetDevice,
type FleetRecentMeasurement, type FleetIngestEvent,
type FleetTariffView, type FleetTariffPeriodView,
type FleetCostDay, type FleetCostDeviceRow,
fetchFleetCustomerDetail,
fetchFleetCustomerCost,
type FleetSite,
type FleetDevice,
type FleetRecentMeasurement,
type FleetIngestEvent,
type FleetTariffView,
type FleetTariffPeriodView,
type FleetCostDay,
type FleetCostDeviceRow,
} from '../api/fleet';
import { downloadFleetCustomerCostXlsx } from '../api/dashboard';
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)}`;
})();
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) {
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> = [
{ 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> = [
{ 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: 'Active', dataIndex: 'isActive', key: 'a', render: (v: boolean) => v ? <Tag color="green">Active</Tag> : <Tag>Inactive</Tag> },
{
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: 'Active',
dataIndex: 'isActive',
key: 'a',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag>Inactive</Tag>),
},
];
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: '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> = [
{ 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: '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: '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 (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/customers')}>Customers</Button>
<Text strong style={{ fontSize: 20 }}>{data.code} · {data.name}</Text>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/customers')}>
Customers
</Button>
<Text strong style={{ fontSize: 20 }}>
{data.code} · {data.name}
</Text>
{data.isActive ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>}
</Space>
<Tooltip
@ -111,9 +209,23 @@ export function AdminCustomerDetailPage() {
<Card size="small">
<Descriptions column={3} size="small">
<Descriptions.Item label="Customer ID">{data.id}</Descriptions.Item>
<Descriptions.Item label="Created">{new Date(data.createdAt).toLocaleString()}</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.Item label="Created">
{new Date(data.createdAt).toLocaleString()}
</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>
</Card>
@ -124,27 +236,64 @@ export function AdminCustomerDetailPage() {
{
key: 'ingest',
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',
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',
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',
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',
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',
@ -159,7 +308,13 @@ export function AdminCustomerDetailPage() {
}
// ── 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) {
return (
<Text type="secondary">
@ -170,14 +325,26 @@ function TariffsTab({ tariffs, municipalitiesCount }: { tariffs: FleetTariffView
}
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),
},
{ title: 'Start', dataIndex: 'startTime', key: 's' },
{ 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 (
@ -196,13 +363,18 @@ function TariffsTab({ tariffs, municipalitiesCount }: { tariffs: FleetTariffView
}
extra={
<Text type="secondary" style={{ fontSize: 12 }}>
{t.effectiveFrom}{t.effectiveTo ? `${t.effectiveTo}` : ' →'}
{t.effectiveFrom}
{t.effectiveTo ? `${t.effectiveTo}` : ' →'}
</Text>
}
>
<Descriptions size="small" column={3} style={{ marginBottom: 12 }}>
<Descriptions.Item label="Default rate">{t.defaultRatePerKwh.toFixed(4)}/kWh</Descriptions.Item>
<Descriptions.Item label="Fixed monthly">{t.fixedMonthlyCharge.toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="Default rate">
{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>
{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: '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: '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> = [
{ title: 'Device', 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: 'Device',
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: '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 (
@ -270,11 +459,19 @@ function CostTab({ customerId }: { customerId: string }) {
allowClear={false}
value={range}
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={{
'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 30d': [dayjs().subtract(30, 'day').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 30d': [
dayjs().subtract(30, 'day').startOf('day'),
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>
{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 && (
<>
<Row gutter={16}>
<Col span={6}><Card><Statistic 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>
<Col span={6}>
<Card>
<Statistic
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>
{data.bucketsWithoutTariff > 0 && (
@ -318,15 +558,21 @@ function CostTab({ customerId }: { customerId: string }) {
<Card title="Per day" size="small">
<Table<FleetCostDay>
rowKey="date" size="small" pagination={false}
columns={dayCols} dataSource={data.daily}
rowKey="date"
size="small"
pagination={false}
columns={dayCols}
dataSource={data.daily}
/>
</Card>
<Card title="Per device" size="small">
<Table<FleetCostDeviceRow>
rowKey="deviceId" size="small" pagination={false}
columns={deviceCols} dataSource={data.perDevice}
rowKey="deviceId"
size="small"
pagination={false}
columns={deviceCols}
dataSource={data.perDevice}
/>
</Card>
</>

View File

@ -1,7 +1,11 @@
import { Button, Card, Col, Row, Space, Statistic, Table, Tag, Typography, Empty } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ApartmentOutlined, ThunderboltOutlined, TeamOutlined, CheckCircleOutlined, DollarOutlined,
ApartmentOutlined,
ThunderboltOutlined,
TeamOutlined,
CheckCircleOutlined,
DollarOutlined,
DownloadOutlined,
} from '@ant-design/icons';
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: 'Name', dataIndex: 'name', key: 'name' },
{
title: 'Status', dataIndex: 'isActive', key: 'active',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>)
title: 'Status',
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',
render: (v: string | null) => v ? lagDescription(v) : <Text type="secondary">Never</Text>
title: 'Last push',
dataIndex: 'lastSeenAt',
key: 'last',
render: (v: string | null) => (v ? lagDescription(v) : <Text type="secondary">Never</Text>),
},
{ title: 'Sites', dataIndex: 'sites', key: 'sites' },
{ title: 'Devices', dataIndex: 'devices', key: 'devices' },
{
title: 'Today (rows)', dataIndex: 'measurementsToday', key: 'mt',
render: (n: number) => n.toLocaleString()
title: 'Today (rows)',
dataIndex: 'measurementsToday',
key: 'mt',
render: (n: number) => n.toLocaleString(),
},
{
title: 'Today (kWh imp.)', dataIndex: 'kwhImportedToday', key: 'kwh',
render: (v: number | null) => v == null ? '—' : v.toFixed(2)
title: 'Today (kWh imp.)',
dataIndex: 'kwhImportedToday',
key: 'kwh',
render: (v: number | null) => (v == null ? '—' : v.toFixed(2)),
},
{
title: 'Today (cost)', dataIndex: 'costToday', key: 'cost',
render: (v: number | null) => v == null ? <Text type="secondary"></Text> : <Text strong>{v.toFixed(2)}</Text>
title: 'Today (cost)',
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 }}>
<Col span={5}>
<Card>
<Statistic title="Customers" value={data?.totalCustomers ?? 0} prefix={<TeamOutlined />} loading={isLoading} />
<Statistic
title="Customers"
value={data?.totalCustomers ?? 0}
prefix={<TeamOutlined />}
loading={isLoading}
/>
</Card>
</Col>
<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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
listSites, createSite, updateSite, deleteSite,
listSiteDevices, createDevice, updateDevice, deleteDevice,
type Site, type Device, type UpsertSite, type UpsertDevice,
listSites,
createSite,
updateSite,
deleteSite,
listSiteDevices,
createDevice,
updateDevice,
deleteDevice,
type Site,
type Device,
type UpsertSite,
type UpsertDevice,
} from '../api/sites';
import { SiteFormModal } from '../components/sites/SiteFormModal';
import { DeviceFormModal } from '../components/sites/DeviceFormModal';
@ -15,9 +24,18 @@ const { Text } = Typography;
export function AdminSitesPage() {
const qc = useQueryClient();
const [siteModal, setSiteModal] = useState<{ open: boolean; editing: Site | null }>({ open: false, editing: null });
const [deviceModal, setDeviceModal] = useState<{ open: boolean; siteId: string | null; editing: Device | null }>({
open: false, siteId: null, editing: null,
const [siteModal, setSiteModal] = useState<{ open: boolean; editing: Site | null }>({
open: false,
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[]>([]);
@ -25,22 +43,34 @@ export function AdminSitesPage() {
const createSiteMut = useMutation({
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'),
});
const updateSiteMut = useMutation({
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'),
});
const deleteSiteMut = useMutation({
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'),
});
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) => {
message.success('Device created');
setDeviceModal({ open: false, siteId: null, editing: null });
@ -50,7 +80,8 @@ export function AdminSitesPage() {
onError: () => message.error('Create failed'),
});
const updateDeviceMut = useMutation({
mutationFn: ({ deviceId, payload }: { deviceId: string; payload: UpsertDevice }) => updateDevice(deviceId, payload),
mutationFn: ({ deviceId, payload }: { deviceId: string; payload: UpsertDevice }) =>
updateDevice(deviceId, payload),
onSuccess: () => {
message.success('Device updated');
setDeviceModal({ open: false, siteId: null, editing: null });
@ -84,26 +115,40 @@ export function AdminSitesPage() {
const columns: ColumnsType<Site> = [
{ 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: 'Active',
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',
key: 'actions',
render: (_, site) => (
<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
title={`Delete site "${site.name}"? All its devices and measurements will be removed.`}
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => deleteSiteMut.mutate(site.id)}
>
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
<Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm>
</Space>
),
@ -114,7 +159,11 @@ export function AdminSitesPage() {
<Card
title="Sites"
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
</Button>
}
@ -159,7 +208,10 @@ export function AdminSitesPage() {
}
function DeviceSubTable({
site, onCreate, onEdit, onDelete,
site,
onCreate,
onEdit,
onDelete,
}: {
site: Site;
onCreate: () => void;
@ -185,14 +237,18 @@ function DeviceSubTable({
key: 'actions',
render: (_, d) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => onEdit(d)}>Edit</Button>
<Button size="small" icon={<EditOutlined />} onClick={() => onEdit(d)}>
Edit
</Button>
<Popconfirm
title={`Delete device "${d.name}"? Its measurements will be removed.`}
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => onDelete(d)}
>
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
<Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm>
</Space>
),
@ -202,7 +258,9 @@ function DeviceSubTable({
return (
<Card size="small" style={{ background: '#fafafa' }}>
<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>
<Table<Device>
rowKey="id"

View File

@ -1,9 +1,24 @@
import { useMemo, useState } from 'react';
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';
import {
DollarOutlined, DownloadOutlined, ThunderboltOutlined, ApartmentOutlined, ClockCircleOutlined,
DollarOutlined,
DownloadOutlined,
ThunderboltOutlined,
ApartmentOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { useQuery } from '@tanstack/react-query';
@ -12,8 +27,11 @@ import dayjs, { type Dayjs } from 'dayjs';
import { useAppInfo } from '../hooks/useAppInfo';
import { AdminFleetDashboardPage } from './AdminFleetDashboardPage';
import {
fetchDashboardSummary, downloadDashboardSummaryXlsx, downloadRawMeasurementsXlsx,
type DashboardDeviceRow, type DashboardSummary,
fetchDashboardSummary,
downloadDashboardSummaryXlsx,
downloadRawMeasurementsXlsx,
type DashboardDeviceRow,
type DashboardSummary,
} from '../api/dashboard';
const { Text } = Typography;
@ -48,12 +66,20 @@ function ClientDashboard() {
<RangePicker
allowClear={false}
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={{
'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 30d': [dayjs().subtract(30, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')],
'This month': [dayjs().startOf('month'), 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 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>
@ -76,7 +102,8 @@ function ClientDashboard() {
{error && (
<Alert
type="error" showIcon
type="error"
showIcon
message="Failed to load dashboard"
description={(error as Error).message}
/>
@ -169,46 +196,93 @@ function ChartPanel({ data, loading }: { data: DashboardSummary | undefined; loa
name: 'kW',
nameTextStyle: { fontSize: 11 },
},
series: [{
name: 'Active power',
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { opacity: 0.15 },
data: points.map(p => [p.time, p.totalKw]),
}],
series: [
{
name: 'Active power',
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { opacity: 0.15 },
data: points.map((p) => [p.time, p.totalKw]),
},
],
};
}, [data]);
if (loading) {
return <div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin />
</div>;
return (
<div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin />
</div>
);
}
if (!data || data.chart.length === 0) {
return <div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
<Space>
<ClockCircleOutlined />
<Text type="secondary">No measurements yet in this window.</Text>
</Space>
</div>;
return (
<div
style={{
height: 240,
display: 'flex',
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 }) {
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: '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> },
{
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: '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 (

View File

@ -1,13 +1,24 @@
import { useMemo, useState } from 'react';
import {
Alert, Button, Card, DatePicker, InputNumber, Select, Space, Table, Tag, Typography,
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,
fetchRawMeasurements,
listAllDevices,
downloadRawMeasurementsExport,
type RawMeasurementRow,
} from '../api/measurements';
@ -37,42 +48,97 @@ export function MeasurementsPage() {
});
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,
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 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,
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) },
{
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;
@ -91,27 +157,43 @@ export function MeasurementsPage() {
allowClear={false}
value={range}
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={{
'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')],
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>
<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); }}
onChange={(v) => {
setSelectedDeviceIds(v);
setOffset(0);
}}
options={deviceOptions}
maxTagCount="responsive"
showSearch
@ -123,8 +205,11 @@ export function MeasurementsPage() {
<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() }))}
onChange={(v) => {
setLimit(v);
setOffset(0);
}}
options={PREVIEW_LIMIT_OPTIONS.map((n) => ({ value: n, label: n.toString() }))}
style={{ width: 96 }}
/>
<Text type="secondary">·</Text>
@ -146,7 +231,8 @@ export function MeasurementsPage() {
{error && (
<Alert
type="error" showIcon
type="error"
showIcon
message="Failed to load measurements"
description={(error as Error).message}
/>
@ -158,7 +244,9 @@ export function MeasurementsPage() {
<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>
<Tag>
{selectedDeviceIds.length} meter{selectedDeviceIds.length === 1 ? '' : 's'} selected
</Tag>
)}
</Space>
}
@ -171,11 +259,14 @@ export function MeasurementsPage() {
type="primary"
icon={<DownloadOutlined />}
disabled={totalCount === 0}
onClick={() => downloadRawMeasurementsExport({
fromUtc: fromIso, toUtc: toIso,
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined,
rowCap: exportRowCap,
})}
onClick={() =>
downloadRawMeasurementsExport({
fromUtc: fromIso,
toUtc: toIso,
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined,
rowCap: exportRowCap,
})
}
>
Export to Excel
</Button>
@ -199,7 +290,10 @@ export function MeasurementsPage() {
: `Showing ${showingFrom.toLocaleString()}${showingTo.toLocaleString()} of ${totalCount.toLocaleString()}`}
</Text>
<Space>
<Button disabled={!canPrev || isFetching} onClick={() => setOffset(Math.max(0, offset - limit))}>
<Button
disabled={!canPrev || isFetching}
onClick={() => setOffset(Math.max(0, offset - limit))}
>
Previous
</Button>
<Button disabled={!canNext || isFetching} onClick={() => setOffset(offset + limit)}>