Compare commits

..

No commits in common. "94ace2df0ee7d65efc116afad63b2a6f121579c7" and "e9143f8c27f431b4fc92954302df24c29baaf47f" have entirely different histories.

41 changed files with 454 additions and 5248 deletions

View File

@ -1,5 +0,0 @@
dist
node_modules
coverage
*.tsbuildinfo
package-lock.json

View File

@ -1,8 +0,0 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "lf"
}

View File

@ -1,55 +0,0 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
// ESLint 9 flat config. Order matters: `prettier` must come last so it can
// switch off any stylistic rules that would fight the formatter — ESLint owns
// code-correctness, Prettier owns formatting.
export default tseslint.config(
{ ignores: ['dist', 'node_modules', 'coverage'] },
{
files: ['**/*.{ts,tsx}'],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
{
// Test + setup files also touch Node globals (process, etc.) and use
// jsdom; allow both global sets.
files: ['**/*.test.{ts,tsx}', 'src/test/**/*.ts'],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
},
{
// Context files intentionally export a Provider component *and* its hook
// from the same module — the standard React Context pattern. The
// react-refresh rule flags this; the trade-off (marginally less optimal
// HMR) isn't worth splitting every context into two files.
files: ['src/hooks/**/*.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
{
// Config files run in Node.
files: ['*.config.{js,ts}'],
languageOptions: {
globals: globals.node,
},
},
prettier,
);

File diff suppressed because it is too large Load Diff

View File

@ -6,23 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,css,md}": [
"prettier --write"
]
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
@ -37,26 +21,10 @@
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tanstack/react-query-devtools": "^5.100.11",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.39.4",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^15.15.0",
"jsdom": "^25.0.1",
"lint-staged": "^15.5.2",
"prettier": "^3.8.3",
"typescript": "~5.6.2",
"typescript-eslint": "^8.59.4",
"vite": "^6.0.0",
"vitest": "^3.2.4"
"vite": "^6.0.0"
}
}

View File

@ -1,16 +1,5 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Spin } from 'antd';
// Query devtools — dev only. The lazy import means the devtools code is in its
// own chunk that a production build never references, so it adds 0 bytes to the
// shipped bundle.
const ReactQueryDevtools = import.meta.env.DEV
? lazy(() =>
import('@tanstack/react-query-devtools').then((m) => ({ default: m.ReactQueryDevtools })),
)
: () => null;
import { AuthProvider } from './hooks/useAuth';
import { BrandingProvider } from './hooks/useBranding';
import { AppInfoProvider } from './hooks/useAppInfo';
@ -18,52 +7,20 @@ import { ThemedRoot } from './components/ThemedRoot';
import { RequireAuth } from './components/RequireAuth';
import { RequireRole } from './components/RequireRole';
import { AppLayout } from './components/layout/AppLayout';
// Pages are code-split — each one ships in its own chunk and is fetched on
// demand. Keeps ECharts (DashboardPage), date utilities, and Admin-only pages
// out of the first-paint bundle for users who never visit them. The shim
// `m => ({ default: m.X })` adapts named exports to React.lazy's default-export
// expectation; cheaper than converting every page to a default export.
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })));
const DashboardPage = lazy(() =>
import('./pages/DashboardPage').then((m) => ({ default: m.DashboardPage })),
);
const DashboardsPage = lazy(() =>
import('./pages/DashboardsPage').then((m) => ({ default: m.DashboardsPage })),
);
const MeasurementsPage = lazy(() =>
import('./pages/MeasurementsPage').then((m) => ({ default: m.MeasurementsPage })),
);
const MyProfilePage = lazy(() =>
import('./pages/MyProfilePage').then((m) => ({ default: m.MyProfilePage })),
);
const AdminSitesPage = lazy(() =>
import('./pages/AdminSitesPage').then((m) => ({ default: m.AdminSitesPage })),
);
const AdminCustomersPage = lazy(() =>
import('./pages/AdminCustomersPage').then((m) => ({ default: m.AdminCustomersPage })),
);
const AdminCustomerDetailPage = lazy(() =>
import('./pages/AdminCustomerDetailPage').then((m) => ({ default: m.AdminCustomerDetailPage })),
);
const SettingsPage = lazy(() =>
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
);
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
import { DashboardsPage } from './pages/DashboardsPage';
import { MeasurementsPage } from './pages/MeasurementsPage';
import { MyProfilePage } from './pages/MyProfilePage';
import { AdminSitesPage } from './pages/AdminSitesPage';
import { AdminCustomersPage } from './pages/AdminCustomersPage';
import { AdminCustomerDetailPage } from './pages/AdminCustomerDetailPage';
import { SettingsPage } from './pages/SettingsPage';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
function PageFallback() {
return (
<div
style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 240 }}
>
<Spin size="large" />
</div>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
@ -72,7 +29,6 @@ export default function App() {
<ThemedRoot>
<AuthProvider>
<BrowserRouter>
<Suspense fallback={<PageFallback />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
@ -123,15 +79,11 @@ export default function App() {
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</BrowserRouter>
</AuthProvider>
</ThemedRoot>
</BrandingProvider>
</AppInfoProvider>
<Suspense fallback={null}>
<ReactQueryDevtools initialIsOpen={false} />
</Suspense>
</QueryClientProvider>
);
}

View File

@ -30,10 +30,7 @@ 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

@ -1,32 +0,0 @@
import { describe, expect, it } from 'vitest';
import { extractApiError } from './client';
describe('extractApiError', () => {
it('returns the single { error } message', () => {
const err = { response: { data: { error: 'Customer code already exists.' } } };
expect(extractApiError(err)).toBe('Customer code already exists.');
});
it('joins the { errors[] } list (Identity validation shape)', () => {
const err = { response: { data: { errors: ['Too short.', 'Needs a digit.'] } } };
expect(extractApiError(err)).toBe('Too short.; Needs a digit.');
});
it('prefers error over errors when both are present', () => {
const err = { response: { data: { error: 'primary', errors: ['secondary'] } } };
expect(extractApiError(err)).toBe('primary');
});
it('falls back to the default for a non-axios error', () => {
expect(extractApiError(new Error('boom'))).toBe('Request failed.');
});
it('uses a custom fallback when provided', () => {
expect(extractApiError(null, 'Save failed.')).toBe('Save failed.');
});
it('falls back when the response body has no recognised field', () => {
const err = { response: { data: { unexpected: true } } };
expect(extractApiError(err)).toBe('Request failed.');
});
});

View File

@ -1,41 +1,7 @@
import axios, { type AxiosError } from 'axios';
import axios from 'axios';
export const api = axios.create({
baseURL: '/api',
withCredentials: true,
headers: { 'Content-Type': 'application/json' },
});
// Global 401 → /login redirect. Without this, an expired session in the middle
// of a page shows generic "Request failed" errors from each TanStack Query.
// Skipping /auth/* lets the auth flow (login, /me probe, /check) handle 401
// inline — login shows wrong-password, fetchCurrentUser turns it into null,
// /check is the Traefik forwardAuth probe and is never called by user code.
api.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
const url = error.config?.url ?? '';
const isAuthPath = url.startsWith('/auth/') || url === '/auth';
const alreadyOnLogin = window.location.pathname === '/login';
if (!isAuthPath && !alreadyOnLogin) {
const returnTo = window.location.pathname + window.location.search;
window.location.href = `/login?returnTo=${encodeURIComponent(returnTo)}`;
}
}
return Promise.reject(error);
},
);
// Pull a human-readable error message out of an axios error. Backends use
// either { error: "..." } (single message) or { errors: ["...", "..."] }
// (Identity validation rejections). Falls back to the supplied default.
export function extractApiError(err: unknown, fallback = 'Request failed.'): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const data = (err as { response?: { data?: { error?: string; errors?: string[] } } }).response
?.data;
if (data?.error) return data.error;
if (data?.errors?.length) return data.errors.join('; ');
}
return fallback;
}

View File

@ -24,10 +24,7 @@ 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,8 +151,9 @@ 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

@ -1,81 +0,0 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { api } from './client';
import { fetchRawMeasurements, downloadRawMeasurementsExport } from './measurements';
vi.mock('./client', () => ({
api: {
get: vi.fn(),
},
}));
describe('measurements API client', () => {
beforeEach(() => {
vi.mocked(api.get).mockReset();
});
it('passes the device-id filter as a comma-separated string', async () => {
vi.mocked(api.get).mockResolvedValueOnce({
data: { totalCount: 0, limit: 200, offset: 0, rows: [] },
});
await fetchRawMeasurements({
fromUtc: '2026-05-01T00:00:00Z',
toUtc: '2026-05-02T00:00:00Z',
deviceIds: ['aaa', 'bbb', 'ccc'],
limit: 200,
offset: 0,
});
expect(api.get).toHaveBeenCalledWith(
'/measurements/raw',
expect.objectContaining({
params: expect.objectContaining({ deviceIds: 'aaa,bbb,ccc' }),
}),
);
});
it('omits the device-id filter when the list is empty', async () => {
vi.mocked(api.get).mockResolvedValueOnce({
data: { totalCount: 0, limit: 200, offset: 0, rows: [] },
});
await fetchRawMeasurements({
fromUtc: '2026-05-01T00:00:00Z',
toUtc: '2026-05-02T00:00:00Z',
deviceIds: [],
limit: 200,
offset: 0,
});
const call = vi.mocked(api.get).mock.calls[0][1] as { params: Record<string, unknown> };
expect(call.params.deviceIds).toBeUndefined();
});
it('builds the download URL with the full filter set', () => {
const originalLocation = window.location;
const setHref = vi.fn();
Object.defineProperty(window, 'location', {
writable: true,
value: {
...originalLocation,
set href(v: string) {
setHref(v);
},
},
});
downloadRawMeasurementsExport({
fromUtc: '2026-05-01T00:00:00.000Z',
toUtc: '2026-05-02T00:00:00.000Z',
deviceIds: ['device-1', 'device-2'],
rowCap: 50000,
});
expect(setHref).toHaveBeenCalledWith(
expect.stringContaining('/api/measurements/raw/export.xlsx?'),
);
const url = setHref.mock.calls[0][0] as string;
expect(url).toContain('from=2026-05-01T00%3A00%3A00.000Z');
expect(url).toContain('to=2026-05-02T00%3A00%3A00.000Z');
expect(url).toContain('deviceIds=device-1%2Cdevice-2');
expect(url).toContain('rowCap=50000');
Object.defineProperty(window, 'location', { writable: true, value: originalLocation });
});
});

View File

@ -46,8 +46,7 @@ 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,
},
@ -65,8 +64,7 @@ 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,9 +77,7 @@ 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;
}
@ -88,10 +86,7 @@ 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,9 +9,7 @@ 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

@ -1,47 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { RequireRole } from './RequireRole';
import * as useAuthModule from '../hooks/useAuth';
function mockAuth(user: { roles: string[] } | null) {
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
user: user as never,
loading: false,
login: vi.fn(),
logout: vi.fn(),
setUser: vi.fn(),
});
}
describe('<RequireRole>', () => {
it('renders children when the user has the required role', () => {
mockAuth({ roles: ['Admin'] });
render(
<RequireRole role="Admin">
<div>Admin-only content</div>
</RequireRole>,
);
expect(screen.getByText('Admin-only content')).toBeInTheDocument();
});
it('renders a 403 when the user is missing the required role', () => {
mockAuth({ roles: ['User'] });
render(
<RequireRole role="Admin">
<div>Admin-only content</div>
</RequireRole>,
);
expect(screen.queryByText('Admin-only content')).not.toBeInTheDocument();
expect(screen.getByText('403')).toBeInTheDocument();
});
it('renders a 403 when the user is null (unauthenticated edge)', () => {
mockAuth(null);
render(
<RequireRole role="Admin">
<div>Admin-only content</div>
</RequireRole>,
);
expect(screen.getByText('403')).toBeInTheDocument();
});
});

View File

@ -7,7 +7,11 @@ 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,10 +1,6 @@
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;
@ -72,11 +68,7 @@ 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 && (
@ -86,8 +78,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,19 +53,16 @@ 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,15 +1,9 @@
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';
@ -67,22 +61,13 @@ 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 ?? ''}
@ -119,9 +104,7 @@ export function AppLayout() {
}
function UserMenu({
displayName,
onProfile,
onLogout,
displayName, onProfile, onLogout,
}: {
displayName: string;
onProfile: () => void;

View File

@ -1,17 +1,5 @@
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';
@ -38,10 +26,7 @@ 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;
@ -90,8 +75,7 @@ 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;
};
@ -119,13 +103,7 @@ 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
@ -165,12 +143,7 @@ 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>
@ -191,22 +164,11 @@ 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,13 +25,7 @@ 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'}>
@ -46,13 +40,7 @@ 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>
@ -61,130 +49,62 @@ 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.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 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'
)}
{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 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>
)}
</Descriptions.Item>
<Descriptions.Item label="Interval">
{data.fleetIngest.intervalSeconds}s
</Descriptions.Item>
<Descriptions.Item label="Batch size">
{data.fleetIngest.batchSize.toLocaleString()} rows
{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="Batch max bytes" span={2}>
{data.fleetIngest.batchMaxBytes.toLocaleString()} bytes
</Descriptions.Item>
@ -210,44 +130,23 @@ export function ConfigOverviewCard() {
}
const pushStateColumns: ColumnsType<FleetPushStateRow> = [
{ title: 'Resource', dataIndex: 'resourceType', key: 'rt', render: (v: string) => <Text strong>{v}</Text> },
{
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: 'Last cursor',
dataIndex: 'lastCursor',
key: 'lc',
render: (v: string | null) =>
v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>,
title: 'Last sync', dataIndex: 'lastSyncedAt', key: 'ls',
render: (v: string | null) => v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>
},
{
title: 'Last sync',
dataIndex: 'lastSyncedAt',
key: 'ls',
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: '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>
),
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,10 +11,7 @@ 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) => ({
@ -41,9 +38,7 @@ 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>
),
@ -57,11 +52,7 @@ 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>
)}
@ -70,14 +61,12 @@ 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,33 +1,14 @@
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';
@ -60,29 +41,17 @@ 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({
@ -124,37 +93,27 @@ 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>
),
@ -164,12 +123,8 @@ 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>
@ -199,12 +154,7 @@ 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>
@ -248,23 +198,9 @@ 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' },
{
@ -277,18 +213,14 @@ 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>
),
@ -298,9 +230,7 @@ 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,10 +68,7 @@ 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

@ -1,33 +1,17 @@
import { useEffect, useState } from 'react';
import {
Drawer,
Form,
Input,
InputNumber,
Switch,
DatePicker,
Row,
Col,
Button,
Space,
Alert,
Typography,
Drawer, Form, Input, InputNumber, Switch, DatePicker, Row, Col, Button, Space, Alert, Typography,
} from 'antd';
import dayjs from 'dayjs';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
createTariff,
updateTariff,
type TariffDetail,
type TariffPeriod,
type UpsertTariff,
} from '../../../api/rates';
import { extractApiError } from '../../../api/client';
import { createTariff, updateTariff, type TariffDetail, type TariffPeriod, type UpsertTariff } from '../../../api/rates';
import { PeriodEditor } from './PeriodEditor';
const { Title } = Typography;
type Mode = { kind: 'create'; municipalityId: number } | { kind: 'edit'; tariff: TariffDetail };
type Mode =
| { kind: 'create'; municipalityId: number }
| { kind: 'edit'; tariff: TariffDetail };
interface Props {
open: boolean;
@ -90,7 +74,7 @@ export function TariffDrawer({ open, mode, onClose }: Props) {
qc.invalidateQueries({ queryKey: ['municipalities'] });
onClose();
},
onError: (err: unknown) => setError(extractApiError(err, 'Save failed.')),
onError: (err: unknown) => setError(extractError(err)),
});
const handleSubmit = (values: FormShape) => {
@ -127,11 +111,7 @@ export function TariffDrawer({ open, mode, onClose }: Props) {
<Form<FormShape> form={form} layout="vertical" onFinish={handleSubmit} requiredMark={false}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="name"
label="Tariff name"
rules={[{ required: true, message: 'Required' }]}
>
<Form.Item name="name" label="Tariff name" rules={[{ required: true, message: 'Required' }]}>
<Input />
</Form.Item>
</Col>
@ -185,11 +165,17 @@ export function TariffDrawer({ open, mode, onClose }: Props) {
</Col>
</Row>
<Title level={5} style={{ marginTop: 8 }}>
Time-of-use periods
</Title>
<Title level={5} style={{ marginTop: 8 }}>Time-of-use periods</Title>
<PeriodEditor value={periods} onChange={setPeriods} />
</Form>
</Drawer>
);
}
function extractError(err: unknown): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const data = (err as { response?: { data?: { error?: string } } }).response?.data;
if (data?.error) return data.error;
}
return 'Save failed.';
}

View File

@ -111,11 +111,7 @@ 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,7 +4,9 @@ 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;
@ -97,9 +99,7 @@ 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,22 +109,15 @@ 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,44 +1,21 @@
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';
@ -70,122 +47,47 @@ 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
@ -209,23 +111,9 @@ 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>
@ -236,64 +124,27 @@ 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',
@ -308,13 +159,7 @@ 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">
@ -325,26 +170,14 @@ function TariffsTab({
}
const periodCols: ColumnsType<FleetTariffPeriodView> = [
{ title: 'Period', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
{
title: 'Period',
dataIndex: 'name',
key: 'name',
render: (v: string) => <Text strong>{v}</Text>,
},
{
title: 'Days',
dataIndex: 'daysOfWeek',
key: 'd',
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 (
@ -363,18 +196,13 @@ function TariffsTab({
}
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 ? (
@ -419,35 +247,18 @@ 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: '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: '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 (
@ -459,19 +270,11 @@ 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')],
}}
/>
@ -486,58 +289,15 @@ 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 && (
@ -558,21 +318,15 @@ 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

@ -5,18 +5,11 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import {
listCustomers,
createCustomer,
updateCustomer,
rotateCustomerToken,
deleteCustomer,
type CustomerListItem,
type CreateCustomerPayload,
type UpdateCustomerPayload,
listCustomers, createCustomer, updateCustomer, rotateCustomerToken, deleteCustomer,
type CustomerListItem, type CreateCustomerPayload, type UpdateCustomerPayload,
} from '../api/customers';
import { CustomerFormModal } from '../components/customers/CustomerFormModal';
import { TokenShownOnceModal } from '../components/customers/TokenShownOnceModal';
import { extractApiError } from '../api/client';
const { Text } = Typography;
@ -27,14 +20,8 @@ export function AdminCustomersPage() {
const navigate = useNavigate();
const [formMode, setFormMode] = useState<FormMode | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [tokenModal, setTokenModal] = useState<{
open: boolean;
code: string | null;
token: string | null;
}>({
open: false,
code: null,
token: null,
const [tokenModal, setTokenModal] = useState<{ open: boolean; code: string | null; token: string | null }>({
open: false, code: null, token: null,
});
const { data: customers = [], isLoading } = useQuery({
@ -52,19 +39,18 @@ export function AdminCustomersPage() {
setTokenModal({ open: true, code: result.customer.code, token: result.token });
invalidate();
},
onError: (err: unknown) => setFormError(extractApiError(err)),
onError: (err: unknown) => setFormError(extractError(err)),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpdateCustomerPayload }) =>
updateCustomer(id, payload),
mutationFn: ({ id, payload }: { id: string; payload: UpdateCustomerPayload }) => updateCustomer(id, payload),
onSuccess: () => {
message.success('Customer updated');
setFormMode(null);
setFormError(null);
invalidate();
},
onError: (err: unknown) => setFormError(extractApiError(err)),
onError: (err: unknown) => setFormError(extractError(err)),
});
const rotateMut = useMutation({
@ -73,16 +59,13 @@ export function AdminCustomersPage() {
setTokenModal({ open: true, code: result.customer.code, token: result.token });
invalidate();
},
onError: (err: unknown) => message.error(extractApiError(err)),
onError: (err: unknown) => message.error(extractError(err)),
});
const deleteMut = useMutation({
mutationFn: (id: string) => deleteCustomer(id),
onSuccess: () => {
message.success('Customer deleted');
invalidate();
},
onError: (err: unknown) => message.error(extractApiError(err)),
onSuccess: () => { message.success('Customer deleted'); invalidate(); },
onError: (err: unknown) => message.error(extractError(err)),
});
const handleSubmit = (payload: CreateCustomerPayload | UpdateCustomerPayload) => {
@ -101,8 +84,7 @@ export function AdminCustomersPage() {
title: 'Status',
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: 'Last push',
@ -124,9 +106,7 @@ export function AdminCustomersPage() {
return (
<Space size={4} direction="vertical">
{issued}
<Tooltip
title={`Old token still accepted by ingest until ${exp.toLocaleString()}. Customer ops should update their .env before then.`}
>
<Tooltip title={`Old token still accepted by ingest until ${exp.toLocaleString()}. Customer ops should update their .env before then.`}>
<Tag color="orange" style={{ fontSize: 11 }}>
Old token valid until {exp.toLocaleTimeString()}
</Tag>
@ -140,14 +120,7 @@ export function AdminCustomersPage() {
key: 'actions',
render: (_, c) => (
<Space>
<Button
size="small"
icon={<EditOutlined />}
onClick={() => {
setFormError(null);
setFormMode({ kind: 'edit', customer: c });
}}
>
<Button size="small" icon={<EditOutlined />} onClick={() => { setFormError(null); setFormMode({ kind: 'edit', customer: c }); }}>
Edit
</Button>
<Tooltip title="Generate a new token. The old token keeps working for 24h so customer ops can update their .env without dropping pushes.">
@ -158,11 +131,7 @@ export function AdminCustomersPage() {
okButtonProps={{ danger: true }}
onConfirm={() => rotateMut.mutate(c.id)}
>
<Button
size="small"
icon={<ReloadOutlined />}
loading={rotateMut.isPending && rotateMut.variables === c.id}
>
<Button size="small" icon={<ReloadOutlined />} loading={rotateMut.isPending && rotateMut.variables === c.id}>
Rotate token
</Button>
</Popconfirm>
@ -174,9 +143,7 @@ export function AdminCustomersPage() {
okButtonProps={{ danger: true }}
onConfirm={() => deleteMut.mutate(c.id)}
>
<Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
</Popconfirm>
</Space>
),
@ -190,10 +157,7 @@ export function AdminCustomersPage() {
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setFormError(null);
setFormMode({ kind: 'create' });
}}
onClick={() => { setFormError(null); setFormMode({ kind: 'create' }); }}
>
Register customer
</Button>
@ -220,10 +184,7 @@ export function AdminCustomersPage() {
mode={formMode}
submitting={createMut.isPending || updateMut.isPending}
error={formError}
onClose={() => {
setFormMode(null);
setFormError(null);
}}
onClose={() => { setFormMode(null); setFormError(null); }}
onSubmit={handleSubmit}
/>
@ -236,3 +197,11 @@ export function AdminCustomersPage() {
</Card>
);
}
function extractError(err: unknown): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const data = (err as { response?: { data?: { error?: string } } }).response?.data;
if (data?.error) return data.error;
}
return 'Request failed.';
}

View File

@ -1,11 +1,7 @@
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';
@ -27,38 +23,26 @@ 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>
},
];
@ -67,12 +51,7 @@ 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,18 +4,9 @@ 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';
@ -24,18 +15,9 @@ 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[]>([]);
@ -43,34 +25,22 @@ 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 });
@ -80,8 +50,7 @@ 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 });
@ -115,40 +84,26 @@ 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>
),
@ -159,11 +114,7 @@ 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>
}
@ -208,10 +159,7 @@ export function AdminSitesPage() {
}
function DeviceSubTable({
site,
onCreate,
onEdit,
onDelete,
site, onCreate, onEdit, onDelete,
}: {
site: Site;
onCreate: () => void;
@ -237,18 +185,14 @@ 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>
),
@ -258,9 +202,7 @@ 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,24 +1,9 @@
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';
@ -27,11 +12,8 @@ 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;
@ -66,19 +48,11 @@ 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'),
],
'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')],
}}
/>
@ -102,8 +76,7 @@ function ClientDashboard() {
{error && (
<Alert
type="error"
showIcon
type="error" showIcon
message="Failed to load dashboard"
description={(error as Error).message}
/>
@ -196,93 +169,46 @@ function ChartPanel({ data, loading }: { data: DashboardSummary | undefined; loa
name: 'kW',
nameTextStyle: { fontSize: 11 },
},
series: [
{
series: [{
name: 'Active power',
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { opacity: 0.15 },
data: points.map((p) => [p.time, p.totalKw]),
},
],
data: points.map(p => [p.time, p.totalKw]),
}],
};
}, [data]);
if (loading) {
return (
<div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
return <div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin />
</div>
);
</div>;
}
if (!data || data.chart.length === 0) {
return (
<div
style={{
height: 240,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
}}
>
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>
);
</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

@ -14,16 +14,15 @@ export function DashboardsPage() {
staleTime: 60_000,
});
const dashboards = useMemo(() => data?.dashboards ?? [], [data]);
const dashboards = data?.dashboards ?? [];
const baseUrl = data?.baseUrl?.replace(/\/$/, '') ?? '';
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
if (selected) return;
const initial =
data?.defaultDashboardUid && dashboards.some((d) => d.uid === data.defaultDashboardUid)
const initial = data?.defaultDashboardUid && dashboards.some((d) => d.uid === data.defaultDashboardUid)
? data.defaultDashboardUid
: (dashboards[0]?.uid ?? null);
: dashboards[0]?.uid ?? null;
if (initial) setSelected(initial);
}, [data, dashboards, selected]);

View File

@ -1,81 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { LoginPage } from './LoginPage';
import * as useAuthModule from '../hooks/useAuth';
import * as useBrandingModule from '../hooks/useBranding';
function mockHooks(loginImpl: (email: string, pw: string) => Promise<void>) {
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
user: null,
loading: false,
login: loginImpl,
logout: vi.fn(),
setUser: vi.fn(),
});
vi.spyOn(useBrandingModule, 'useBranding').mockReturnValue({
branding: {
applicationName: 'Test Portal',
logoUrl: '',
primaryColor: '#000',
secondaryColor: '#111',
accentColor: '#222',
footerText: 'footer',
},
loading: false,
});
}
describe('<LoginPage>', () => {
it('calls login() with the submitted credentials', async () => {
const login = vi.fn().mockResolvedValue(undefined);
mockHooks(login);
render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<div>home</div>} />
</Routes>
</MemoryRouter>,
);
await userEvent.type(screen.getByLabelText(/email/i), 'admin@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'ChangeMe123!');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(login).toHaveBeenCalledWith('admin@example.com', 'ChangeMe123!');
});
it('shows an inline error when login() rejects', async () => {
const login = vi.fn().mockRejectedValue(new Error('bad creds'));
mockHooks(login);
render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<LoginPage />} />
</Routes>
</MemoryRouter>,
);
await userEvent.type(screen.getByLabelText(/email/i), 'a@b.c');
await userEvent.type(screen.getByLabelText(/password/i), 'xx');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/invalid email or password/i)).toBeInTheDocument();
});
it('renders the branded application name in the header', () => {
mockHooks(vi.fn());
render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<LoginPage />} />
</Routes>
</MemoryRouter>,
);
expect(screen.getByText('Test Portal')).toBeInTheDocument();
});
});

View File

@ -19,13 +19,7 @@ export function LoginPage() {
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Return-to comes from either the ?returnTo= query param (set by the global
// 401 interceptor) or router state (set by RequireAuth). Query param wins.
const returnToParam = new URLSearchParams(location.search).get('returnTo');
const from =
returnToParam ??
(location.state as { from?: { pathname: string } } | null)?.from?.pathname ??
'/';
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';
const onFinish = async (values: LoginValues) => {
setError(null);
@ -83,10 +77,7 @@ export function LoginPage() {
</Form.Item>
</Form>
{branding.footerText && (
<Text
type="secondary"
style={{ display: 'block', textAlign: 'center', marginTop: 16, fontSize: 12 }}
>
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: 16, fontSize: 12 }}>
{branding.footerText}
</Text>
)}

View File

@ -1,24 +1,13 @@
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';
@ -48,16 +37,8 @@ export function MeasurementsPage() {
});
const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: [
'raw-measurements',
fromIso,
toIso,
selectedDeviceIds.sort().join(','),
limit,
offset,
],
queryFn: () =>
fetchRawMeasurements({
queryKey: ['raw-measurements', fromIso, toIso, selectedDeviceIds.sort().join(','), limit, offset],
queryFn: () => fetchRawMeasurements({
fromUtc: fromIso,
toUtc: toIso,
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined,
@ -67,78 +48,31 @@ export function MeasurementsPage() {
placeholderData: keepPreviousData,
});
const deviceOptions = useMemo(
() =>
devices.map((d) => ({
const deviceOptions = useMemo(() => devices.map(d => ({
value: d.id,
label: `${d.name}${d.siteName}`,
disabled: !d.isActive,
})),
[devices],
);
})), [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;
@ -157,23 +91,12 @@ 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'),
],
'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')],
}}
@ -181,19 +104,14 @@ export function MeasurementsPage() {
</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
@ -205,11 +123,8 @@ 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>
@ -231,8 +146,7 @@ export function MeasurementsPage() {
{error && (
<Alert
type="error"
showIcon
type="error" showIcon
message="Failed to load measurements"
description={(error as Error).message}
/>
@ -244,9 +158,7 @@ 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>
}
@ -259,14 +171,11 @@ export function MeasurementsPage() {
type="primary"
icon={<DownloadOutlined />}
disabled={totalCount === 0}
onClick={() =>
downloadRawMeasurementsExport({
fromUtc: fromIso,
toUtc: toIso,
onClick={() => downloadRawMeasurementsExport({
fromUtc: fromIso, toUtc: toIso,
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined,
rowCap: exportRowCap,
})
}
})}
>
Export to Excel
</Button>
@ -290,10 +199,7 @@ 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)}>

View File

@ -1,22 +1,10 @@
import { useState } from 'react';
import {
Alert,
Button,
Card,
Col,
Descriptions,
Form,
Input,
Row,
Space,
Tag,
Typography,
message,
Alert, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tag, Typography, message,
} from 'antd';
import { LockOutlined, SaveOutlined, UserOutlined } from '@ant-design/icons';
import { useAuth } from '../hooks/useAuth';
import { changeMyPassword, updateMyProfile } from '../api/auth';
import { extractApiError } from '../api/client';
const { Title, Text } = Typography;
@ -49,7 +37,7 @@ export function MyProfilePage() {
setUser(updated);
message.success('Profile updated');
} catch (err: unknown) {
setProfileError(extractApiError(err));
setProfileError(extractError(err));
} finally {
setSavingProfile(false);
}
@ -63,7 +51,7 @@ export function MyProfilePage() {
passwordForm.resetFields();
message.success('Password changed');
} catch (err: unknown) {
setPasswordError(extractApiError(err));
setPasswordError(extractError(err));
} finally {
setSavingPassword(false);
}
@ -72,39 +60,20 @@ export function MyProfilePage() {
return (
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Card
title={
<Space>
<UserOutlined />
<Title level={5} style={{ margin: 0 }}>
My profile
</Title>
</Space>
}
>
<Card title={<Space><UserOutlined /><Title level={5} style={{ margin: 0 }}>My profile</Title></Space>}>
<Descriptions size="small" column={1} style={{ marginBottom: 16 }}>
<Descriptions.Item label="Email">
<Text code>{user.email}</Text>
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
(read-only)
</Text>
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>(read-only)</Text>
</Descriptions.Item>
<Descriptions.Item label="Roles">
{user.roles.length === 0 ? (
<Tag>User</Tag>
) : (
user.roles.map((r) => (
<Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>
{r}
</Tag>
))
)}
{user.roles.length === 0
? <Tag>User</Tag>
: user.roles.map(r => <Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>{r}</Tag>)}
</Descriptions.Item>
</Descriptions>
{profileError && (
<Alert type="error" message={profileError} style={{ marginBottom: 16 }} />
)}
{profileError && <Alert type="error" message={profileError} style={{ marginBottom: 16 }} />}
<Form<ProfileFormValues>
form={profileForm}
@ -124,12 +93,7 @@ export function MyProfilePage() {
<Input autoComplete="name" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={savingProfile}
>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={savingProfile}>
Save profile
</Button>
</Form.Item>
@ -138,19 +102,8 @@ export function MyProfilePage() {
</Col>
<Col xs={24} md={12}>
<Card
title={
<Space>
<LockOutlined />
<Title level={5} style={{ margin: 0 }}>
Change password
</Title>
</Space>
}
>
{passwordError && (
<Alert type="error" message={passwordError} style={{ marginBottom: 16 }} />
)}
<Card title={<Space><LockOutlined /><Title level={5} style={{ margin: 0 }}>Change password</Title></Space>}>
{passwordError && <Alert type="error" message={passwordError} style={{ marginBottom: 16 }} />}
<Form<PasswordFormValues>
form={passwordForm}
@ -198,12 +151,7 @@ export function MyProfilePage() {
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
icon={<LockOutlined />}
loading={savingPassword}
>
<Button type="primary" htmlType="submit" icon={<LockOutlined />} loading={savingPassword}>
Change password
</Button>
</Form.Item>
@ -213,3 +161,11 @@ export function MyProfilePage() {
</Row>
);
}
function extractError(err: unknown): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const data = (err as { response?: { data?: { error?: string } } }).response?.data;
if (data?.error) return data.error;
}
return 'Request failed.';
}

View File

@ -1,13 +1,7 @@
import { useState } from 'react';
import { Card, Button, Table, Tag, Popconfirm, Modal, Input, Space, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
KeyOutlined,
ApartmentOutlined,
} from '@ant-design/icons';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, ApartmentOutlined } from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
listUsers,
@ -19,7 +13,6 @@ import {
type CreateUserPayload,
type UpdateUserPayload,
} from '../api/users';
import { extractApiError } from '../api/client';
import { UserFormDrawer } from '../components/users/UserFormDrawer';
import { CustomerAccessModal } from '../components/users/CustomerAccessModal';
import { useAppInfo } from '../hooks/useAppInfo';
@ -50,19 +43,18 @@ export function UsersPage() {
setDrawerError(null);
invalidate();
},
onError: (err: unknown) => setDrawerError(extractApiError(err)),
onError: (err: unknown) => setDrawerError(errorMessage(err)),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpdateUserPayload }) =>
updateUser(id, payload),
mutationFn: ({ id, payload }: { id: string; payload: UpdateUserPayload }) => updateUser(id, payload),
onSuccess: () => {
message.success('User updated');
setDrawerMode(null);
setDrawerError(null);
invalidate();
},
onError: (err: unknown) => setDrawerError(extractApiError(err)),
onError: (err: unknown) => setDrawerError(errorMessage(err)),
});
const deleteMut = useMutation({
@ -71,7 +63,7 @@ export function UsersPage() {
message.success('User deleted');
invalidate();
},
onError: (err: unknown) => message.error(extractApiError(err)),
onError: (err: unknown) => message.error(errorMessage(err)),
});
const resetMut = useMutation({
@ -82,7 +74,7 @@ export function UsersPage() {
setResetTarget(null);
setResetValue('');
},
onError: (err: unknown) => message.error(extractApiError(err)),
onError: (err: unknown) => message.error(errorMessage(err)),
});
const handleSubmit = async (values: CreateUserPayload | UpdateUserPayload) => {
@ -102,22 +94,15 @@ export function UsersPage() {
dataIndex: 'roles',
key: 'roles',
render: (roles: string[]) =>
roles.length === 0 ? (
<Tag>User</Tag>
) : (
roles.map((r) => (
<Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>
{r}
</Tag>
))
),
roles.length === 0
? <Tag>User</Tag>
: roles.map((r) => <Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>{r}</Tag>),
},
{
title: 'Active',
dataIndex: 'isActive',
key: 'isActive',
render: (active: boolean) =>
active ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
render: (active: boolean) => active ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
},
{
title: 'Created',
@ -133,25 +118,23 @@ export function UsersPage() {
<Button
size="small"
icon={<EditOutlined />}
onClick={() => {
setDrawerError(null);
setDrawerMode({ kind: 'edit', user });
}}
onClick={() => { setDrawerError(null); setDrawerMode({ kind: 'edit', user }); }}
>
Edit
</Button>
{isAdminMode && user.roles.includes('RestrictedAdmin') && (
<Button size="small" icon={<ApartmentOutlined />} onClick={() => setAccessTarget(user)}>
<Button
size="small"
icon={<ApartmentOutlined />}
onClick={() => setAccessTarget(user)}
>
Customer access
</Button>
)}
<Button
size="small"
icon={<KeyOutlined />}
onClick={() => {
setResetValue('');
setResetTarget(user);
}}
onClick={() => { setResetValue(''); setResetTarget(user); }}
>
Reset password
</Button>
@ -161,9 +144,7 @@ export function UsersPage() {
okButtonProps={{ danger: true }}
onConfirm={() => deleteMut.mutate(user.id)}
>
<Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
</Popconfirm>
</Space>
),
@ -177,10 +158,7 @@ export function UsersPage() {
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setDrawerError(null);
setDrawerMode({ kind: 'create' });
}}
onClick={() => { setDrawerError(null); setDrawerMode({ kind: 'create' }); }}
>
New user
</Button>
@ -199,10 +177,7 @@ export function UsersPage() {
mode={drawerMode}
submitting={createMut.isPending || updateMut.isPending}
error={drawerError}
onClose={() => {
setDrawerMode(null);
setDrawerError(null);
}}
onClose={() => { setDrawerMode(null); setDrawerError(null); }}
onSubmit={handleSubmit}
/>
@ -210,10 +185,7 @@ export function UsersPage() {
title={resetTarget ? `Reset password for ${resetTarget.email}` : 'Reset password'}
open={resetTarget !== null}
confirmLoading={resetMut.isPending}
onCancel={() => {
setResetTarget(null);
setResetValue('');
}}
onCancel={() => { setResetTarget(null); setResetValue(''); }}
onOk={() => resetTarget && resetMut.mutate({ id: resetTarget.id, newPassword: resetValue })}
okText="Reset"
okButtonProps={{ disabled: resetValue.length < 8 }}
@ -234,3 +206,12 @@ export function UsersPage() {
</Card>
);
}
function errorMessage(err: unknown): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const data = (err as { response?: { data?: { error?: string; errors?: string[] } } }).response?.data;
if (data?.error) return data.error;
if (data?.errors?.length) return data.errors.join('; ');
}
return 'Request failed.';
}

View File

@ -1,37 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
// React Testing Library auto-cleans between tests; we still want to reset
// any spies/mocks per test for predictable behaviour.
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// AntD's responsive observer uses matchMedia which jsdom doesn't implement.
// Stub with a plain function (not vi.fn()) so vi.restoreAllMocks() in afterEach
// doesn't strip its implementation between tests.
const noop = () => undefined;
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: noop, // legacy
removeListener: noop, // legacy
addEventListener: noop,
removeEventListener: noop,
dispatchEvent: () => false,
}),
});
// ResizeObserver is used by AntD Layout — also missing in jsdom.
class MockResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as unknown as { ResizeObserver: typeof MockResizeObserver }).ResizeObserver =
MockResizeObserver;

View File

@ -1,7 +1,4 @@
// defineConfig from vitest/config is a superset of vite's — it accepts the
// `test` block. Importing it here keeps vite.config.ts the single source of
// truth for both the dev/build config and the test config.
import { defineConfig } from 'vitest/config';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
@ -23,12 +20,4 @@ export default defineConfig({
outDir: 'dist',
sourcemap: true,
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: false,
// AntD's reset.css is imported in main.tsx — skip it for tests.
// No `globals: true` — test files import describe/it/expect/vi from 'vitest'
// explicitly. Less magic, no tsconfig types array required.
},
});