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>
169 lines
5.1 KiB
TypeScript
169 lines
5.1 KiB
TypeScript
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,
|
|
DownloadOutlined,
|
|
} from '@ant-design/icons';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { fetchFleetDashboard, type FleetCustomerSummary } from '../api/fleet';
|
|
import { downloadFleetDashboardXlsx } from '../api/dashboard';
|
|
|
|
const { Text } = Typography;
|
|
|
|
export function AdminFleetDashboardPage() {
|
|
const navigate = useNavigate();
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['fleet-dashboard'],
|
|
queryFn: fetchFleetDashboard,
|
|
refetchInterval: 30_000,
|
|
});
|
|
|
|
const columns: ColumnsType<FleetCustomerSummary> = [
|
|
{ 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: '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 (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>,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
|
<Col span={5}>
|
|
<Card>
|
|
<Statistic
|
|
title="Customers"
|
|
value={data?.totalCustomers ?? 0}
|
|
prefix={<TeamOutlined />}
|
|
loading={isLoading}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col span={5}>
|
|
<Card>
|
|
<Statistic
|
|
title="Active (last hr)"
|
|
value={data?.activeCustomers ?? 0}
|
|
suffix={data ? `/ ${data.totalCustomers}` : ''}
|
|
prefix={<CheckCircleOutlined />}
|
|
loading={isLoading}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col span={5}>
|
|
<Card>
|
|
<Statistic
|
|
title="Measurements today"
|
|
value={data?.totalMeasurementsToday ?? 0}
|
|
prefix={<ApartmentOutlined />}
|
|
loading={isLoading}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col span={4}>
|
|
<Card>
|
|
<Statistic
|
|
title="kWh today"
|
|
value={data?.totalKwhImportedToday ?? 0}
|
|
precision={2}
|
|
prefix={<ThunderboltOutlined />}
|
|
loading={isLoading}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col span={5}>
|
|
<Card>
|
|
<Statistic
|
|
title="Cost today"
|
|
value={data?.totalCostToday ?? 0}
|
|
precision={2}
|
|
prefix={<DollarOutlined />}
|
|
valueStyle={{ color: '#3f8600' }}
|
|
loading={isLoading}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Card
|
|
title="Customers"
|
|
extra={
|
|
<Space>
|
|
<Button
|
|
icon={<DownloadOutlined />}
|
|
onClick={() => downloadFleetDashboardXlsx()}
|
|
disabled={!data || data.customers.length === 0}
|
|
>
|
|
Export to Excel
|
|
</Button>
|
|
</Space>
|
|
}
|
|
>
|
|
{data && data.customers.length === 0 ? (
|
|
<Empty description="No customers registered yet. Go to Customers to register the first one." />
|
|
) : (
|
|
<Table<FleetCustomerSummary>
|
|
rowKey="id"
|
|
columns={columns}
|
|
dataSource={data?.customers ?? []}
|
|
loading={isLoading}
|
|
pagination={{ pageSize: 25 }}
|
|
onRow={(record) => ({
|
|
onClick: () => navigate(`/admin/customers/${record.id}`),
|
|
style: { cursor: 'pointer' },
|
|
})}
|
|
/>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function lagDescription(iso: string): React.ReactNode {
|
|
const ts = new Date(iso).getTime();
|
|
const ageMs = Date.now() - ts;
|
|
const ageMin = Math.floor(ageMs / 60_000);
|
|
if (ageMin < 1) return <Tag color="green">just now</Tag>;
|
|
if (ageMin < 5) return <Tag color="green">{ageMin}m ago</Tag>;
|
|
if (ageMin < 60) return <Tag color="orange">{ageMin}m ago</Tag>;
|
|
const ageHr = Math.floor(ageMin / 60);
|
|
if (ageHr < 24) return <Tag color="red">{ageHr}h ago</Tag>;
|
|
return <Tag color="red">{Math.floor(ageHr / 24)}d ago</Tag>;
|
|
}
|