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:
parent
b5ceedc097
commit
94ace2df0e
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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()}`;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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." />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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)}>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user