Compare commits

...

2 Commits

Author SHA1 Message Date
Diseri Pearson
94ace2df0e Portal frontend: apply Prettier formatting baseline
One-time `prettier --write` across the 22 source files not already touched
by the preceding tooling commit (b5ceedc). Pure formatting — no behaviour
change, no logic change. Reviewable as "all whitespace/reflow".

From here the lint-staged pre-commit hook keeps new and edited code
consistent automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:47:08 +02:00
Diseri Pearson
b5ceedc097 Portal frontend: tests, code splitting, 401 interceptor, lint/format tooling
Items 1-5 of the agreed frontend upgrades. The one-time Prettier
reformatting of files not touched here is in the FOLLOW-UP commit so this
one stays reviewable.

Testing (vitest + @testing-library)
- vitest 3 + @testing-library/react + jsdom. test / test:watch / test:ui
  scripts; setup.ts stubs matchMedia + ResizeObserver for AntD under jsdom.
- 15 tests, 4 files: RequireRole, LoginPage, measurements API client,
  extractApiError.

Route-level code splitting
- Every page converted to React.lazy() behind a Suspense boundary in
  App.tsx. Main bundle 1490 KB -> 714 KB; ECharts isolated in the
  DashboardPage chunk and only fetched when a dashboard is opened.

Global 401 handling
- api/client.ts response interceptor redirects any 401 from a non-/auth
  path to /login?returnTo=<current>; LoginPage honours the param.
- Shared extractApiError replaces four near-identical per-page copies
  (UsersPage, AdminCustomersPage, MyProfilePage, TariffDrawer).

Query devtools
- @tanstack/react-query-devtools, lazy + import.meta.env.DEV-gated so a
  production build dead-code-eliminates it. Verified: no devtools code in
  any shipped .js chunk.

ESLint + Prettier
- ESLint 9 flat config (js + typescript-eslint + react-hooks +
  react-refresh, eslint-config-prettier last). Prettier config tuned to
  the existing style. lint / lint:fix / format / format:check scripts +
  lint-staged config.
- Fixed one real react-hooks/exhaustive-deps warning (useMemo on
  DashboardsPage's `dashboards`).
- End state: eslint 0 problems, tsc clean, 15/15 tests pass.

Pre-commit hook (lint-staged) is installed locally in .git/hooks/ -- not
version-controlled, see follow-up note. Husky was deliberately not used:
it needs a core.hooksPath change and the monorepo layout makes it awkward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:46:54 +02:00
41 changed files with 5251 additions and 457 deletions

View File

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

View File

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

View File

@ -0,0 +1,55 @@
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,7 +6,23 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview" "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"
]
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
@ -21,10 +37,26 @@
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0"
}, },
"devDependencies": { "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": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4", "@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": "~5.6.2",
"vite": "^6.0.0" "typescript-eslint": "^8.59.4",
"vite": "^6.0.0",
"vitest": "^3.2.4"
} }
} }

View File

@ -1,5 +1,16 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 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 { AuthProvider } from './hooks/useAuth';
import { BrandingProvider } from './hooks/useBranding'; import { BrandingProvider } from './hooks/useBranding';
import { AppInfoProvider } from './hooks/useAppInfo'; import { AppInfoProvider } from './hooks/useAppInfo';
@ -7,20 +18,52 @@ import { ThemedRoot } from './components/ThemedRoot';
import { RequireAuth } from './components/RequireAuth'; import { RequireAuth } from './components/RequireAuth';
import { RequireRole } from './components/RequireRole'; import { RequireRole } from './components/RequireRole';
import { AppLayout } from './components/layout/AppLayout'; import { AppLayout } from './components/layout/AppLayout';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage'; // Pages are code-split — each one ships in its own chunk and is fetched on
import { DashboardsPage } from './pages/DashboardsPage'; // demand. Keeps ECharts (DashboardPage), date utilities, and Admin-only pages
import { MeasurementsPage } from './pages/MeasurementsPage'; // out of the first-paint bundle for users who never visit them. The shim
import { MyProfilePage } from './pages/MyProfilePage'; // `m => ({ default: m.X })` adapts named exports to React.lazy's default-export
import { AdminSitesPage } from './pages/AdminSitesPage'; // expectation; cheaper than converting every page to a default export.
import { AdminCustomersPage } from './pages/AdminCustomersPage'; const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })));
import { AdminCustomerDetailPage } from './pages/AdminCustomerDetailPage'; const DashboardPage = lazy(() =>
import { SettingsPage } from './pages/SettingsPage'; 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 })),
);
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, 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() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@ -29,61 +72,66 @@ export default function App() {
<ThemedRoot> <ThemedRoot>
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Suspense fallback={<PageFallback />}>
<Route path="/login" element={<LoginPage />} /> <Routes>
<Route <Route path="/login" element={<LoginPage />} />
path="/"
element={
<RequireAuth>
<AppLayout />
</RequireAuth>
}
>
<Route index element={<DashboardPage />} />
<Route path="dashboards" element={<DashboardsPage />} />
<Route path="measurements" element={<MeasurementsPage />} />
<Route path="profile" element={<MyProfilePage />} />
<Route <Route
path="admin/sites" path="/"
element={ element={
<RequireRole role="Admin"> <RequireAuth>
<AdminSitesPage /> <AppLayout />
</RequireRole> </RequireAuth>
} }
/> >
<Route <Route index element={<DashboardPage />} />
path="admin/customers" <Route path="dashboards" element={<DashboardsPage />} />
element={ <Route path="measurements" element={<MeasurementsPage />} />
<RequireRole role="Admin"> <Route path="profile" element={<MyProfilePage />} />
<AdminCustomersPage /> <Route
</RequireRole> path="admin/sites"
} element={
/> <RequireRole role="Admin">
<Route <AdminSitesPage />
path="admin/customers/:id" </RequireRole>
element={ }
<RequireRole role="Admin"> />
<AdminCustomerDetailPage /> <Route
</RequireRole> path="admin/customers"
} element={
/> <RequireRole role="Admin">
<Route <AdminCustomersPage />
path="settings" </RequireRole>
element={ }
<RequireRole role="Admin"> />
<SettingsPage /> <Route
</RequireRole> path="admin/customers/:id"
} element={
/> <RequireRole role="Admin">
<Route path="admin/users" element={<Navigate to="/settings" replace />} /> <AdminCustomerDetailPage />
</Route> </RequireRole>
<Route path="*" element={<Navigate to="/" replace />} /> }
</Routes> />
<Route
path="settings"
element={
<RequireRole role="Admin">
<SettingsPage />
</RequireRole>
}
/>
<Route path="admin/users" element={<Navigate to="/settings" replace />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
</ThemedRoot> </ThemedRoot>
</BrandingProvider> </BrandingProvider>
</AppInfoProvider> </AppInfoProvider>
<Suspense fallback={null}>
<ReactQueryDevtools initialIsOpen={false} />
</Suspense>
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

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

View File

@ -0,0 +1,32 @@
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,7 +1,41 @@
import axios from 'axios'; import axios, { type AxiosError } from 'axios';
export const api = axios.create({ export const api = axios.create({
baseURL: '/api', baseURL: '/api',
withCredentials: true, withCredentials: true,
headers: { 'Content-Type': 'application/json' }, 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,7 +24,10 @@ export interface DashboardSummary {
chart: DashboardChartPoint[]; chart: DashboardChartPoint[];
} }
export async function fetchDashboardSummary(fromUtc: string, toUtc: string): Promise<DashboardSummary> { export async function fetchDashboardSummary(
fromUtc: string,
toUtc: string,
): Promise<DashboardSummary> {
const { data } = await api.get<DashboardSummary>('/dashboard/summary', { const { data } = await api.get<DashboardSummary>('/dashboard/summary', {
params: { from: fromUtc, to: toUtc }, params: { from: fromUtc, to: toUtc },
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,33 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { 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'; } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createTariff, updateTariff, type TariffDetail, type TariffPeriod, type UpsertTariff } from '../../../api/rates'; import {
createTariff,
updateTariff,
type TariffDetail,
type TariffPeriod,
type UpsertTariff,
} from '../../../api/rates';
import { extractApiError } from '../../../api/client';
import { PeriodEditor } from './PeriodEditor'; import { PeriodEditor } from './PeriodEditor';
const { Title } = Typography; const { Title } = Typography;
type Mode = type Mode = { kind: 'create'; municipalityId: number } | { kind: 'edit'; tariff: TariffDetail };
| { kind: 'create'; municipalityId: number }
| { kind: 'edit'; tariff: TariffDetail };
interface Props { interface Props {
open: boolean; open: boolean;
@ -74,7 +90,7 @@ export function TariffDrawer({ open, mode, onClose }: Props) {
qc.invalidateQueries({ queryKey: ['municipalities'] }); qc.invalidateQueries({ queryKey: ['municipalities'] });
onClose(); onClose();
}, },
onError: (err: unknown) => setError(extractError(err)), onError: (err: unknown) => setError(extractApiError(err, 'Save failed.')),
}); });
const handleSubmit = (values: FormShape) => { const handleSubmit = (values: FormShape) => {
@ -111,7 +127,11 @@ export function TariffDrawer({ open, mode, onClose }: Props) {
<Form<FormShape> form={form} layout="vertical" onFinish={handleSubmit} requiredMark={false}> <Form<FormShape> form={form} layout="vertical" onFinish={handleSubmit} requiredMark={false}>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <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 /> <Input />
</Form.Item> </Form.Item>
</Col> </Col>
@ -165,17 +185,11 @@ export function TariffDrawer({ open, mode, onClose }: Props) {
</Col> </Col>
</Row> </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} /> <PeriodEditor value={periods} onChange={setPeriods} />
</Form> </Form>
</Drawer> </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,7 +111,11 @@ export function CustomerAccessModal({ open, user, onClose }: Props) {
onChange={() => toggle(c.id)} onChange={() => toggle(c.id)}
> >
<Text strong>{c.code}</Text> <Text type="secondary"> {c.name}</Text> <Text strong>{c.code}</Text> <Text type="secondary"> {c.name}</Text>
{!c.isActive && <Tag color="red" style={{ marginLeft: 8 }}>Disabled</Tag>} {!c.isActive && (
<Tag color="red" style={{ marginLeft: 8 }}>
Disabled
</Tag>
)}
</Checkbox> </Checkbox>
))} ))}
</Checkbox.Group> </Checkbox.Group>

View File

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

View File

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

View File

@ -5,11 +5,18 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
listCustomers, createCustomer, updateCustomer, rotateCustomerToken, deleteCustomer, listCustomers,
type CustomerListItem, type CreateCustomerPayload, type UpdateCustomerPayload, createCustomer,
updateCustomer,
rotateCustomerToken,
deleteCustomer,
type CustomerListItem,
type CreateCustomerPayload,
type UpdateCustomerPayload,
} from '../api/customers'; } from '../api/customers';
import { CustomerFormModal } from '../components/customers/CustomerFormModal'; import { CustomerFormModal } from '../components/customers/CustomerFormModal';
import { TokenShownOnceModal } from '../components/customers/TokenShownOnceModal'; import { TokenShownOnceModal } from '../components/customers/TokenShownOnceModal';
import { extractApiError } from '../api/client';
const { Text } = Typography; const { Text } = Typography;
@ -20,8 +27,14 @@ export function AdminCustomersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [formMode, setFormMode] = useState<FormMode | null>(null); const [formMode, setFormMode] = useState<FormMode | null>(null);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [tokenModal, setTokenModal] = useState<{ open: boolean; code: string | null; token: string | null }>({ const [tokenModal, setTokenModal] = useState<{
open: false, code: null, token: null, open: boolean;
code: string | null;
token: string | null;
}>({
open: false,
code: null,
token: null,
}); });
const { data: customers = [], isLoading } = useQuery({ const { data: customers = [], isLoading } = useQuery({
@ -39,18 +52,19 @@ export function AdminCustomersPage() {
setTokenModal({ open: true, code: result.customer.code, token: result.token }); setTokenModal({ open: true, code: result.customer.code, token: result.token });
invalidate(); invalidate();
}, },
onError: (err: unknown) => setFormError(extractError(err)), onError: (err: unknown) => setFormError(extractApiError(err)),
}); });
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpdateCustomerPayload }) => updateCustomer(id, payload), mutationFn: ({ id, payload }: { id: string; payload: UpdateCustomerPayload }) =>
updateCustomer(id, payload),
onSuccess: () => { onSuccess: () => {
message.success('Customer updated'); message.success('Customer updated');
setFormMode(null); setFormMode(null);
setFormError(null); setFormError(null);
invalidate(); invalidate();
}, },
onError: (err: unknown) => setFormError(extractError(err)), onError: (err: unknown) => setFormError(extractApiError(err)),
}); });
const rotateMut = useMutation({ const rotateMut = useMutation({
@ -59,13 +73,16 @@ export function AdminCustomersPage() {
setTokenModal({ open: true, code: result.customer.code, token: result.token }); setTokenModal({ open: true, code: result.customer.code, token: result.token });
invalidate(); invalidate();
}, },
onError: (err: unknown) => message.error(extractError(err)), onError: (err: unknown) => message.error(extractApiError(err)),
}); });
const deleteMut = useMutation({ const deleteMut = useMutation({
mutationFn: (id: string) => deleteCustomer(id), mutationFn: (id: string) => deleteCustomer(id),
onSuccess: () => { message.success('Customer deleted'); invalidate(); }, onSuccess: () => {
onError: (err: unknown) => message.error(extractError(err)), message.success('Customer deleted');
invalidate();
},
onError: (err: unknown) => message.error(extractApiError(err)),
}); });
const handleSubmit = (payload: CreateCustomerPayload | UpdateCustomerPayload) => { const handleSubmit = (payload: CreateCustomerPayload | UpdateCustomerPayload) => {
@ -84,7 +101,8 @@ export function AdminCustomersPage() {
title: 'Status', title: 'Status',
dataIndex: 'isActive', dataIndex: 'isActive',
key: 'isActive', key: 'isActive',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>), render: (v: boolean) =>
v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
}, },
{ {
title: 'Last push', title: 'Last push',
@ -106,7 +124,9 @@ export function AdminCustomersPage() {
return ( return (
<Space size={4} direction="vertical"> <Space size={4} direction="vertical">
{issued} {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 }}> <Tag color="orange" style={{ fontSize: 11 }}>
Old token valid until {exp.toLocaleTimeString()} Old token valid until {exp.toLocaleTimeString()}
</Tag> </Tag>
@ -120,7 +140,14 @@ export function AdminCustomersPage() {
key: 'actions', key: 'actions',
render: (_, c) => ( render: (_, c) => (
<Space> <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 Edit
</Button> </Button>
<Tooltip title="Generate a new token. The old token keeps working for 24h so customer ops can update their .env without dropping pushes."> <Tooltip title="Generate a new token. The old token keeps working for 24h so customer ops can update their .env without dropping pushes.">
@ -131,7 +158,11 @@ export function AdminCustomersPage() {
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
onConfirm={() => rotateMut.mutate(c.id)} 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 Rotate token
</Button> </Button>
</Popconfirm> </Popconfirm>
@ -143,7 +174,9 @@ export function AdminCustomersPage() {
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
onConfirm={() => deleteMut.mutate(c.id)} onConfirm={() => deleteMut.mutate(c.id)}
> >
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button> <Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@ -157,7 +190,10 @@ export function AdminCustomersPage() {
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => { setFormError(null); setFormMode({ kind: 'create' }); }} onClick={() => {
setFormError(null);
setFormMode({ kind: 'create' });
}}
> >
Register customer Register customer
</Button> </Button>
@ -184,7 +220,10 @@ export function AdminCustomersPage() {
mode={formMode} mode={formMode}
submitting={createMut.isPending || updateMut.isPending} submitting={createMut.isPending || updateMut.isPending}
error={formError} error={formError}
onClose={() => { setFormMode(null); setFormError(null); }} onClose={() => {
setFormMode(null);
setFormError(null);
}}
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />
@ -197,11 +236,3 @@ export function AdminCustomersPage() {
</Card> </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,7 +1,11 @@
import { Button, Card, Col, Row, Space, Statistic, Table, Tag, Typography, Empty } from 'antd'; import { Button, Card, Col, Row, Space, Statistic, Table, Tag, Typography, Empty } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
ApartmentOutlined, ThunderboltOutlined, TeamOutlined, CheckCircleOutlined, DollarOutlined, ApartmentOutlined,
ThunderboltOutlined,
TeamOutlined,
CheckCircleOutlined,
DollarOutlined,
DownloadOutlined, DownloadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@ -23,26 +27,38 @@ export function AdminFleetDashboardPage() {
{ title: 'Code', dataIndex: 'code', key: 'code', render: (v) => <Text strong>{v}</Text> }, { title: 'Code', dataIndex: 'code', key: 'code', render: (v) => <Text strong>{v}</Text> },
{ title: 'Name', dataIndex: 'name', key: 'name' }, { title: 'Name', dataIndex: 'name', key: 'name' },
{ {
title: 'Status', dataIndex: 'isActive', key: 'active', title: 'Status',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>) dataIndex: 'isActive',
key: 'active',
render: (v: boolean) =>
v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
}, },
{ {
title: 'Last push', dataIndex: 'lastSeenAt', key: 'last', title: 'Last push',
render: (v: string | null) => v ? lagDescription(v) : <Text type="secondary">Never</Text> dataIndex: 'lastSeenAt',
key: 'last',
render: (v: string | null) => (v ? lagDescription(v) : <Text type="secondary">Never</Text>),
}, },
{ title: 'Sites', dataIndex: 'sites', key: 'sites' }, { title: 'Sites', dataIndex: 'sites', key: 'sites' },
{ title: 'Devices', dataIndex: 'devices', key: 'devices' }, { title: 'Devices', dataIndex: 'devices', key: 'devices' },
{ {
title: 'Today (rows)', dataIndex: 'measurementsToday', key: 'mt', title: 'Today (rows)',
render: (n: number) => n.toLocaleString() dataIndex: 'measurementsToday',
key: 'mt',
render: (n: number) => n.toLocaleString(),
}, },
{ {
title: 'Today (kWh imp.)', dataIndex: 'kwhImportedToday', key: 'kwh', title: 'Today (kWh imp.)',
render: (v: number | null) => v == null ? '—' : v.toFixed(2) dataIndex: 'kwhImportedToday',
key: 'kwh',
render: (v: number | null) => (v == null ? '—' : v.toFixed(2)),
}, },
{ {
title: 'Today (cost)', dataIndex: 'costToday', key: 'cost', title: 'Today (cost)',
render: (v: number | null) => v == null ? <Text type="secondary"></Text> : <Text strong>{v.toFixed(2)}</Text> dataIndex: 'costToday',
key: 'cost',
render: (v: number | null) =>
v == null ? <Text type="secondary"></Text> : <Text strong>{v.toFixed(2)}</Text>,
}, },
]; ];
@ -51,7 +67,12 @@ export function AdminFleetDashboardPage() {
<Row gutter={16} style={{ marginBottom: 16 }}> <Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={5}> <Col span={5}>
<Card> <Card>
<Statistic title="Customers" value={data?.totalCustomers ?? 0} prefix={<TeamOutlined />} loading={isLoading} /> <Statistic
title="Customers"
value={data?.totalCustomers ?? 0}
prefix={<TeamOutlined />}
loading={isLoading}
/>
</Card> </Card>
</Col> </Col>
<Col span={5}> <Col span={5}>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,81 @@
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,7 +19,13 @@ export function LoginPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/'; // 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 onFinish = async (values: LoginValues) => { const onFinish = async (values: LoginValues) => {
setError(null); setError(null);
@ -77,7 +83,10 @@ export function LoginPage() {
</Form.Item> </Form.Item>
</Form> </Form>
{branding.footerText && ( {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} {branding.footerText}
</Text> </Text>
)} )}

View File

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

View File

@ -1,10 +1,22 @@
import { useState } from 'react'; import { useState } from 'react';
import { 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'; } from 'antd';
import { LockOutlined, SaveOutlined, UserOutlined } from '@ant-design/icons'; import { LockOutlined, SaveOutlined, UserOutlined } from '@ant-design/icons';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { changeMyPassword, updateMyProfile } from '../api/auth'; import { changeMyPassword, updateMyProfile } from '../api/auth';
import { extractApiError } from '../api/client';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@ -37,7 +49,7 @@ export function MyProfilePage() {
setUser(updated); setUser(updated);
message.success('Profile updated'); message.success('Profile updated');
} catch (err: unknown) { } catch (err: unknown) {
setProfileError(extractError(err)); setProfileError(extractApiError(err));
} finally { } finally {
setSavingProfile(false); setSavingProfile(false);
} }
@ -51,7 +63,7 @@ export function MyProfilePage() {
passwordForm.resetFields(); passwordForm.resetFields();
message.success('Password changed'); message.success('Password changed');
} catch (err: unknown) { } catch (err: unknown) {
setPasswordError(extractError(err)); setPasswordError(extractApiError(err));
} finally { } finally {
setSavingPassword(false); setSavingPassword(false);
} }
@ -60,20 +72,39 @@ export function MyProfilePage() {
return ( return (
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} md={12}> <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 size="small" column={1} style={{ marginBottom: 16 }}>
<Descriptions.Item label="Email"> <Descriptions.Item label="Email">
<Text code>{user.email}</Text> <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>
<Descriptions.Item label="Roles"> <Descriptions.Item label="Roles">
{user.roles.length === 0 {user.roles.length === 0 ? (
? <Tag>User</Tag> <Tag>User</Tag>
: user.roles.map(r => <Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>{r}</Tag>)} ) : (
user.roles.map((r) => (
<Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>
{r}
</Tag>
))
)}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
{profileError && <Alert type="error" message={profileError} style={{ marginBottom: 16 }} />} {profileError && (
<Alert type="error" message={profileError} style={{ marginBottom: 16 }} />
)}
<Form<ProfileFormValues> <Form<ProfileFormValues>
form={profileForm} form={profileForm}
@ -93,7 +124,12 @@ export function MyProfilePage() {
<Input autoComplete="name" /> <Input autoComplete="name" />
</Form.Item> </Form.Item>
<Form.Item style={{ marginBottom: 0 }}> <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 Save profile
</Button> </Button>
</Form.Item> </Form.Item>
@ -102,8 +138,19 @@ export function MyProfilePage() {
</Col> </Col>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Card title={<Space><LockOutlined /><Title level={5} style={{ margin: 0 }}>Change password</Title></Space>}> <Card
{passwordError && <Alert type="error" message={passwordError} style={{ marginBottom: 16 }} />} 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<PasswordFormValues>
form={passwordForm} form={passwordForm}
@ -151,7 +198,12 @@ export function MyProfilePage() {
<Input.Password autoComplete="new-password" /> <Input.Password autoComplete="new-password" />
</Form.Item> </Form.Item>
<Form.Item style={{ marginBottom: 0 }}> <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 Change password
</Button> </Button>
</Form.Item> </Form.Item>
@ -161,11 +213,3 @@ export function MyProfilePage() {
</Row> </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,7 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { Card, Button, Table, Tag, Popconfirm, Modal, Input, Space, message } from 'antd'; import { Card, Button, Table, Tag, Popconfirm, Modal, Input, Space, message } from 'antd';
import type { ColumnsType } from 'antd/es/table'; 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
listUsers, listUsers,
@ -13,6 +19,7 @@ import {
type CreateUserPayload, type CreateUserPayload,
type UpdateUserPayload, type UpdateUserPayload,
} from '../api/users'; } from '../api/users';
import { extractApiError } from '../api/client';
import { UserFormDrawer } from '../components/users/UserFormDrawer'; import { UserFormDrawer } from '../components/users/UserFormDrawer';
import { CustomerAccessModal } from '../components/users/CustomerAccessModal'; import { CustomerAccessModal } from '../components/users/CustomerAccessModal';
import { useAppInfo } from '../hooks/useAppInfo'; import { useAppInfo } from '../hooks/useAppInfo';
@ -43,18 +50,19 @@ export function UsersPage() {
setDrawerError(null); setDrawerError(null);
invalidate(); invalidate();
}, },
onError: (err: unknown) => setDrawerError(errorMessage(err)), onError: (err: unknown) => setDrawerError(extractApiError(err)),
}); });
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpdateUserPayload }) => updateUser(id, payload), mutationFn: ({ id, payload }: { id: string; payload: UpdateUserPayload }) =>
updateUser(id, payload),
onSuccess: () => { onSuccess: () => {
message.success('User updated'); message.success('User updated');
setDrawerMode(null); setDrawerMode(null);
setDrawerError(null); setDrawerError(null);
invalidate(); invalidate();
}, },
onError: (err: unknown) => setDrawerError(errorMessage(err)), onError: (err: unknown) => setDrawerError(extractApiError(err)),
}); });
const deleteMut = useMutation({ const deleteMut = useMutation({
@ -63,7 +71,7 @@ export function UsersPage() {
message.success('User deleted'); message.success('User deleted');
invalidate(); invalidate();
}, },
onError: (err: unknown) => message.error(errorMessage(err)), onError: (err: unknown) => message.error(extractApiError(err)),
}); });
const resetMut = useMutation({ const resetMut = useMutation({
@ -74,7 +82,7 @@ export function UsersPage() {
setResetTarget(null); setResetTarget(null);
setResetValue(''); setResetValue('');
}, },
onError: (err: unknown) => message.error(errorMessage(err)), onError: (err: unknown) => message.error(extractApiError(err)),
}); });
const handleSubmit = async (values: CreateUserPayload | UpdateUserPayload) => { const handleSubmit = async (values: CreateUserPayload | UpdateUserPayload) => {
@ -94,15 +102,22 @@ export function UsersPage() {
dataIndex: 'roles', dataIndex: 'roles',
key: 'roles', key: 'roles',
render: (roles: string[]) => render: (roles: string[]) =>
roles.length === 0 roles.length === 0 ? (
? <Tag>User</Tag> <Tag>User</Tag>
: roles.map((r) => <Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>{r}</Tag>), ) : (
roles.map((r) => (
<Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>
{r}
</Tag>
))
),
}, },
{ {
title: 'Active', title: 'Active',
dataIndex: 'isActive', dataIndex: 'isActive',
key: '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', title: 'Created',
@ -118,23 +133,25 @@ export function UsersPage() {
<Button <Button
size="small" size="small"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => { setDrawerError(null); setDrawerMode({ kind: 'edit', user }); }} onClick={() => {
setDrawerError(null);
setDrawerMode({ kind: 'edit', user });
}}
> >
Edit Edit
</Button> </Button>
{isAdminMode && user.roles.includes('RestrictedAdmin') && ( {isAdminMode && user.roles.includes('RestrictedAdmin') && (
<Button <Button size="small" icon={<ApartmentOutlined />} onClick={() => setAccessTarget(user)}>
size="small"
icon={<ApartmentOutlined />}
onClick={() => setAccessTarget(user)}
>
Customer access Customer access
</Button> </Button>
)} )}
<Button <Button
size="small" size="small"
icon={<KeyOutlined />} icon={<KeyOutlined />}
onClick={() => { setResetValue(''); setResetTarget(user); }} onClick={() => {
setResetValue('');
setResetTarget(user);
}}
> >
Reset password Reset password
</Button> </Button>
@ -144,7 +161,9 @@ export function UsersPage() {
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
onConfirm={() => deleteMut.mutate(user.id)} onConfirm={() => deleteMut.mutate(user.id)}
> >
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button> <Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@ -158,7 +177,10 @@ export function UsersPage() {
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => { setDrawerError(null); setDrawerMode({ kind: 'create' }); }} onClick={() => {
setDrawerError(null);
setDrawerMode({ kind: 'create' });
}}
> >
New user New user
</Button> </Button>
@ -177,7 +199,10 @@ export function UsersPage() {
mode={drawerMode} mode={drawerMode}
submitting={createMut.isPending || updateMut.isPending} submitting={createMut.isPending || updateMut.isPending}
error={drawerError} error={drawerError}
onClose={() => { setDrawerMode(null); setDrawerError(null); }} onClose={() => {
setDrawerMode(null);
setDrawerError(null);
}}
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />
@ -185,7 +210,10 @@ export function UsersPage() {
title={resetTarget ? `Reset password for ${resetTarget.email}` : 'Reset password'} title={resetTarget ? `Reset password for ${resetTarget.email}` : 'Reset password'}
open={resetTarget !== null} open={resetTarget !== null}
confirmLoading={resetMut.isPending} confirmLoading={resetMut.isPending}
onCancel={() => { setResetTarget(null); setResetValue(''); }} onCancel={() => {
setResetTarget(null);
setResetValue('');
}}
onOk={() => resetTarget && resetMut.mutate({ id: resetTarget.id, newPassword: resetValue })} onOk={() => resetTarget && resetMut.mutate({ id: resetTarget.id, newPassword: resetValue })}
okText="Reset" okText="Reset"
okButtonProps={{ disabled: resetValue.length < 8 }} okButtonProps={{ disabled: resetValue.length < 8 }}
@ -206,12 +234,3 @@ export function UsersPage() {
</Card> </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

@ -0,0 +1,37 @@
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,4 +1,7 @@
import { defineConfig } from 'vite'; // 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 react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
export default defineConfig({ export default defineConfig({
@ -20,4 +23,12 @@ export default defineConfig({
outDir: 'dist', outDir: 'dist',
sourcemap: true, 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.
},
}); });