Compare commits
No commits in common. "94ace2df0ee7d65efc116afad63b2a6f121579c7" and "e9143f8c27f431b4fc92954302df24c29baaf47f" have entirely different histories.
94ace2df0e
...
e9143f8c27
@ -1,5 +0,0 @@
|
|||||||
dist
|
|
||||||
node_modules
|
|
||||||
coverage
|
|
||||||
*.tsbuildinfo
|
|
||||||
package-lock.json
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"printWidth": 100,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"endOfLine": "lf"
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import js from '@eslint/js';
|
|
||||||
import globals from 'globals';
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
|
||||||
import tseslint from 'typescript-eslint';
|
|
||||||
import prettier from 'eslint-config-prettier';
|
|
||||||
|
|
||||||
// ESLint 9 flat config. Order matters: `prettier` must come last so it can
|
|
||||||
// switch off any stylistic rules that would fight the formatter — ESLint owns
|
|
||||||
// code-correctness, Prettier owns formatting.
|
|
||||||
export default tseslint.config(
|
|
||||||
{ ignores: ['dist', 'node_modules', 'coverage'] },
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2022,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
'react-hooks': reactHooks,
|
|
||||||
'react-refresh': reactRefresh,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Test + setup files also touch Node globals (process, etc.) and use
|
|
||||||
// jsdom; allow both global sets.
|
|
||||||
files: ['**/*.test.{ts,tsx}', 'src/test/**/*.ts'],
|
|
||||||
languageOptions: {
|
|
||||||
globals: { ...globals.browser, ...globals.node },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Context files intentionally export a Provider component *and* its hook
|
|
||||||
// from the same module — the standard React Context pattern. The
|
|
||||||
// react-refresh rule flags this; the trade-off (marginally less optimal
|
|
||||||
// HMR) isn't worth splitting every context into two files.
|
|
||||||
files: ['src/hooks/**/*.tsx'],
|
|
||||||
rules: {
|
|
||||||
'react-refresh/only-export-components': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Config files run in Node.
|
|
||||||
files: ['*.config.{js,ts}'],
|
|
||||||
languageOptions: {
|
|
||||||
globals: globals.node,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
prettier,
|
|
||||||
);
|
|
||||||
3456
portal/frontend/package-lock.json
generated
3456
portal/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -6,23 +6,7 @@
|
|||||||
"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",
|
||||||
@ -37,26 +21,10 @@
|
|||||||
"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",
|
||||||
"typescript-eslint": "^8.59.4",
|
"vite": "^6.0.0"
|
||||||
"vite": "^6.0.0",
|
|
||||||
"vitest": "^3.2.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,5 @@
|
|||||||
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';
|
||||||
@ -18,52 +7,20 @@ 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';
|
||||||
// Pages are code-split — each one ships in its own chunk and is fetched on
|
import { DashboardPage } from './pages/DashboardPage';
|
||||||
// demand. Keeps ECharts (DashboardPage), date utilities, and Admin-only pages
|
import { DashboardsPage } from './pages/DashboardsPage';
|
||||||
// out of the first-paint bundle for users who never visit them. The shim
|
import { MeasurementsPage } from './pages/MeasurementsPage';
|
||||||
// `m => ({ default: m.X })` adapts named exports to React.lazy's default-export
|
import { MyProfilePage } from './pages/MyProfilePage';
|
||||||
// expectation; cheaper than converting every page to a default export.
|
import { AdminSitesPage } from './pages/AdminSitesPage';
|
||||||
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })));
|
import { AdminCustomersPage } from './pages/AdminCustomersPage';
|
||||||
const DashboardPage = lazy(() =>
|
import { AdminCustomerDetailPage } from './pages/AdminCustomerDetailPage';
|
||||||
import('./pages/DashboardPage').then((m) => ({ default: m.DashboardPage })),
|
import { SettingsPage } from './pages/SettingsPage';
|
||||||
);
|
|
||||||
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}>
|
||||||
@ -72,66 +29,61 @@ export default function App() {
|
|||||||
<ThemedRoot>
|
<ThemedRoot>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Suspense fallback={<PageFallback />}>
|
<Routes>
|
||||||
<Routes>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route
|
||||||
|
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="/"
|
path="admin/sites"
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireRole role="Admin">
|
||||||
<AppLayout />
|
<AdminSitesPage />
|
||||||
</RequireAuth>
|
</RequireRole>
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route
|
||||||
<Route path="dashboards" element={<DashboardsPage />} />
|
path="admin/customers"
|
||||||
<Route path="measurements" element={<MeasurementsPage />} />
|
element={
|
||||||
<Route path="profile" element={<MyProfilePage />} />
|
<RequireRole role="Admin">
|
||||||
<Route
|
<AdminCustomersPage />
|
||||||
path="admin/sites"
|
</RequireRole>
|
||||||
element={
|
}
|
||||||
<RequireRole role="Admin">
|
/>
|
||||||
<AdminSitesPage />
|
<Route
|
||||||
</RequireRole>
|
path="admin/customers/:id"
|
||||||
}
|
element={
|
||||||
/>
|
<RequireRole role="Admin">
|
||||||
<Route
|
<AdminCustomerDetailPage />
|
||||||
path="admin/customers"
|
</RequireRole>
|
||||||
element={
|
}
|
||||||
<RequireRole role="Admin">
|
/>
|
||||||
<AdminCustomersPage />
|
<Route
|
||||||
</RequireRole>
|
path="settings"
|
||||||
}
|
element={
|
||||||
/>
|
<RequireRole role="Admin">
|
||||||
<Route
|
<SettingsPage />
|
||||||
path="admin/customers/:id"
|
</RequireRole>
|
||||||
element={
|
}
|
||||||
<RequireRole role="Admin">
|
/>
|
||||||
<AdminCustomerDetailPage />
|
<Route path="admin/users" element={<Navigate to="/settings" replace />} />
|
||||||
</RequireRole>
|
</Route>
|
||||||
}
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,10 +30,7 @@ export async function updateMyProfile(displayName: string): Promise<CurrentUser>
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function changeMyPassword(
|
export async function changeMyPassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||||
currentPassword: string,
|
|
||||||
newPassword: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await api.post('/auth/me/change-password', { currentPassword, newPassword });
|
await api.post('/auth/me/change-password', { currentPassword, newPassword });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { extractApiError } from './client';
|
|
||||||
|
|
||||||
describe('extractApiError', () => {
|
|
||||||
it('returns the single { error } message', () => {
|
|
||||||
const err = { response: { data: { error: 'Customer code already exists.' } } };
|
|
||||||
expect(extractApiError(err)).toBe('Customer code already exists.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('joins the { errors[] } list (Identity validation shape)', () => {
|
|
||||||
const err = { response: { data: { errors: ['Too short.', 'Needs a digit.'] } } };
|
|
||||||
expect(extractApiError(err)).toBe('Too short.; Needs a digit.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prefers error over errors when both are present', () => {
|
|
||||||
const err = { response: { data: { error: 'primary', errors: ['secondary'] } } };
|
|
||||||
expect(extractApiError(err)).toBe('primary');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the default for a non-axios error', () => {
|
|
||||||
expect(extractApiError(new Error('boom'))).toBe('Request failed.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses a custom fallback when provided', () => {
|
|
||||||
expect(extractApiError(null, 'Save failed.')).toBe('Save failed.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back when the response body has no recognised field', () => {
|
|
||||||
const err = { response: { data: { unexpected: true } } };
|
|
||||||
expect(extractApiError(err)).toBe('Request failed.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,41 +1,7 @@
|
|||||||
import axios, { type AxiosError } from 'axios';
|
import axios 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -24,10 +24,7 @@ export interface DashboardSummary {
|
|||||||
chart: DashboardChartPoint[];
|
chart: DashboardChartPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDashboardSummary(
|
export async function fetchDashboardSummary(fromUtc: string, toUtc: string): Promise<DashboardSummary> {
|
||||||
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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -151,8 +151,9 @@ export async function fetchFleetCustomerCost(
|
|||||||
fromUtc: string,
|
fromUtc: string,
|
||||||
toUtc: string,
|
toUtc: string,
|
||||||
): Promise<FleetCost> {
|
): Promise<FleetCost> {
|
||||||
const { data } = await api.get<FleetCost>(`/fleet/customers/${id}/cost`, {
|
const { data } = await api.get<FleetCost>(
|
||||||
params: { from: fromUtc, to: toUtc },
|
`/fleet/customers/${id}/cost`,
|
||||||
});
|
{ params: { from: fromUtc, to: toUtc } },
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,81 +0,0 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
||||||
import { api } from './client';
|
|
||||||
import { fetchRawMeasurements, downloadRawMeasurementsExport } from './measurements';
|
|
||||||
|
|
||||||
vi.mock('./client', () => ({
|
|
||||||
api: {
|
|
||||||
get: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('measurements API client', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(api.get).mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes the device-id filter as a comma-separated string', async () => {
|
|
||||||
vi.mocked(api.get).mockResolvedValueOnce({
|
|
||||||
data: { totalCount: 0, limit: 200, offset: 0, rows: [] },
|
|
||||||
});
|
|
||||||
await fetchRawMeasurements({
|
|
||||||
fromUtc: '2026-05-01T00:00:00Z',
|
|
||||||
toUtc: '2026-05-02T00:00:00Z',
|
|
||||||
deviceIds: ['aaa', 'bbb', 'ccc'],
|
|
||||||
limit: 200,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
expect(api.get).toHaveBeenCalledWith(
|
|
||||||
'/measurements/raw',
|
|
||||||
expect.objectContaining({
|
|
||||||
params: expect.objectContaining({ deviceIds: 'aaa,bbb,ccc' }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits the device-id filter when the list is empty', async () => {
|
|
||||||
vi.mocked(api.get).mockResolvedValueOnce({
|
|
||||||
data: { totalCount: 0, limit: 200, offset: 0, rows: [] },
|
|
||||||
});
|
|
||||||
await fetchRawMeasurements({
|
|
||||||
fromUtc: '2026-05-01T00:00:00Z',
|
|
||||||
toUtc: '2026-05-02T00:00:00Z',
|
|
||||||
deviceIds: [],
|
|
||||||
limit: 200,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
const call = vi.mocked(api.get).mock.calls[0][1] as { params: Record<string, unknown> };
|
|
||||||
expect(call.params.deviceIds).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds the download URL with the full filter set', () => {
|
|
||||||
const originalLocation = window.location;
|
|
||||||
const setHref = vi.fn();
|
|
||||||
Object.defineProperty(window, 'location', {
|
|
||||||
writable: true,
|
|
||||||
value: {
|
|
||||||
...originalLocation,
|
|
||||||
set href(v: string) {
|
|
||||||
setHref(v);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
downloadRawMeasurementsExport({
|
|
||||||
fromUtc: '2026-05-01T00:00:00.000Z',
|
|
||||||
toUtc: '2026-05-02T00:00:00.000Z',
|
|
||||||
deviceIds: ['device-1', 'device-2'],
|
|
||||||
rowCap: 50000,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(setHref).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('/api/measurements/raw/export.xlsx?'),
|
|
||||||
);
|
|
||||||
const url = setHref.mock.calls[0][0] as string;
|
|
||||||
expect(url).toContain('from=2026-05-01T00%3A00%3A00.000Z');
|
|
||||||
expect(url).toContain('to=2026-05-02T00%3A00%3A00.000Z');
|
|
||||||
expect(url).toContain('deviceIds=device-1%2Cdevice-2');
|
|
||||||
expect(url).toContain('rowCap=50000');
|
|
||||||
|
|
||||||
Object.defineProperty(window, 'location', { writable: true, value: originalLocation });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -46,8 +46,7 @@ export async function fetchRawMeasurements(params: {
|
|||||||
params: {
|
params: {
|
||||||
from: params.fromUtc,
|
from: params.fromUtc,
|
||||||
to: params.toUtc,
|
to: params.toUtc,
|
||||||
deviceIds:
|
deviceIds: params.deviceIds && params.deviceIds.length > 0 ? params.deviceIds.join(',') : undefined,
|
||||||
params.deviceIds && params.deviceIds.length > 0 ? params.deviceIds.join(',') : undefined,
|
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
offset: params.offset,
|
offset: params.offset,
|
||||||
},
|
},
|
||||||
@ -65,8 +64,7 @@ export function downloadRawMeasurementsExport(params: {
|
|||||||
from: params.fromUtc,
|
from: params.fromUtc,
|
||||||
to: params.toUtc,
|
to: params.toUtc,
|
||||||
});
|
});
|
||||||
if (params.deviceIds && params.deviceIds.length > 0)
|
if (params.deviceIds && params.deviceIds.length > 0) q.set('deviceIds', params.deviceIds.join(','));
|
||||||
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()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,9 +77,7 @@ 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[]>(
|
const { data } = await api.get<TariffSummary[]>(`/rates/municipalities/${municipalityId}/tariffs`);
|
||||||
`/rates/municipalities/${municipalityId}/tariffs`,
|
|
||||||
);
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,10 +86,7 @@ export async function getTariff(tariffId: number): Promise<TariffDetail> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTariff(
|
export async function createTariff(municipalityId: number, payload: UpsertTariff): Promise<TariffDetail> {
|
||||||
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,
|
||||||
|
|||||||
@ -9,9 +9,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}
|
|
||||||
>
|
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { RequireRole } from './RequireRole';
|
|
||||||
import * as useAuthModule from '../hooks/useAuth';
|
|
||||||
|
|
||||||
function mockAuth(user: { roles: string[] } | null) {
|
|
||||||
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
|
|
||||||
user: user as never,
|
|
||||||
loading: false,
|
|
||||||
login: vi.fn(),
|
|
||||||
logout: vi.fn(),
|
|
||||||
setUser: vi.fn(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('<RequireRole>', () => {
|
|
||||||
it('renders children when the user has the required role', () => {
|
|
||||||
mockAuth({ roles: ['Admin'] });
|
|
||||||
render(
|
|
||||||
<RequireRole role="Admin">
|
|
||||||
<div>Admin-only content</div>
|
|
||||||
</RequireRole>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText('Admin-only content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a 403 when the user is missing the required role', () => {
|
|
||||||
mockAuth({ roles: ['User'] });
|
|
||||||
render(
|
|
||||||
<RequireRole role="Admin">
|
|
||||||
<div>Admin-only content</div>
|
|
||||||
</RequireRole>,
|
|
||||||
);
|
|
||||||
expect(screen.queryByText('Admin-only content')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText('403')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a 403 when the user is null (unauthenticated edge)', () => {
|
|
||||||
mockAuth(null);
|
|
||||||
render(
|
|
||||||
<RequireRole role="Admin">
|
|
||||||
<div>Admin-only content</div>
|
|
||||||
</RequireRole>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText('403')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -7,7 +7,11 @@ export function RequireRole({ role, children }: { role: string; children: ReactN
|
|||||||
|
|
||||||
if (!user || !user.roles.includes(role)) {
|
if (!user || !user.roles.includes(role)) {
|
||||||
return (
|
return (
|
||||||
<Result status="403" title="403" subTitle="You do not have permission to view this page." />
|
<Result
|
||||||
|
status="403"
|
||||||
|
title="403"
|
||||||
|
subTitle="You do not have permission to view this page."
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
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 {
|
import type { CustomerListItem, CreateCustomerPayload, UpdateCustomerPayload } from '../../api/customers';
|
||||||
CustomerListItem,
|
|
||||||
CreateCustomerPayload,
|
|
||||||
UpdateCustomerPayload,
|
|
||||||
} from '../../api/customers';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@ -72,11 +68,7 @@ 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
|
<Form.Item name="name" label="Display name" rules={[{ required: true, message: 'Required' }]}>
|
||||||
name="name"
|
|
||||||
label="Display name"
|
|
||||||
rules={[{ required: true, message: 'Required' }]}
|
|
||||||
>
|
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
@ -86,8 +78,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{' '}
|
A push token will be generated and shown once. Set it as <Text code>FleetIngest__Token</Text> in
|
||||||
<Text code>FleetIngest__Token</Text> in the customer's environment.
|
the customer's environment.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@ -53,19 +53,16 @@ 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 within
|
work for 24h. Update the customer's <Text code>.env</Text> and restart their portal
|
||||||
that window to avoid dropped pushes.
|
within 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}>
|
<Button icon={<CopyOutlined />} onClick={copy}>Copy</Button>
|
||||||
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}>
|
||||||
|
|||||||
@ -1,15 +1,9 @@
|
|||||||
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,
|
DashboardOutlined, SettingOutlined, LogoutOutlined,
|
||||||
SettingOutlined,
|
LineChartOutlined, ApartmentOutlined, TeamOutlined, TableOutlined,
|
||||||
LogoutOutlined,
|
UserOutlined, DownOutlined,
|
||||||
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';
|
||||||
@ -67,22 +61,13 @@ export function AppLayout() {
|
|||||||
<img
|
<img
|
||||||
src={branding.logoUrl}
|
src={branding.logoUrl}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{ maxHeight: 36, background: 'rgba(255,255,255,0.1)', padding: 2, borderRadius: 2 }}
|
||||||
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 && (
|
{adminMode && <Tag color="gold" style={{ marginLeft: 8 }}>FLEET ADMIN</Tag>}
|
||||||
<Tag color="gold" style={{ marginLeft: 8 }}>
|
|
||||||
FLEET ADMIN
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
<UserMenu
|
<UserMenu
|
||||||
displayName={user?.displayName ?? user?.email ?? ''}
|
displayName={user?.displayName ?? user?.email ?? ''}
|
||||||
@ -119,9 +104,7 @@ export function AppLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserMenu({
|
function UserMenu({
|
||||||
displayName,
|
displayName, onProfile, onLogout,
|
||||||
onProfile,
|
|
||||||
onLogout,
|
|
||||||
}: {
|
}: {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
onProfile: () => void;
|
onProfile: () => void;
|
||||||
|
|||||||
@ -1,17 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import { Form, Input, Button, Row, Col, Card, Upload, ColorPicker, Space, message, Typography } from 'antd';
|
||||||
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';
|
||||||
@ -38,10 +26,7 @@ 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({
|
const { data: branding, isLoading } = useQuery({ queryKey: ['branding'], queryFn: fetchBranding });
|
||||||
queryKey: ['branding'],
|
|
||||||
queryFn: fetchBranding,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!branding) return;
|
if (!branding) return;
|
||||||
@ -90,8 +75,7 @@ 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)
|
else if ('originFileObj' in file && file.originFileObj) uploadMut.mutate(file.originFileObj as File);
|
||||||
uploadMut.mutate(file.originFileObj as File);
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -119,13 +103,7 @@ export function BrandingForm() {
|
|||||||
<img
|
<img
|
||||||
src={preview.logoUrl}
|
src={preview.logoUrl}
|
||||||
alt="Current logo"
|
alt="Current logo"
|
||||||
style={{
|
style={{ maxHeight: 64, maxWidth: 200, background: '#f5f5f5', padding: 8, borderRadius: 4 }}
|
||||||
maxHeight: 64,
|
|
||||||
maxWidth: 200,
|
|
||||||
background: '#f5f5f5',
|
|
||||||
padding: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Upload
|
<Upload
|
||||||
@ -165,12 +143,7 @@ export function BrandingForm() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button
|
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<SaveOutlined />}>
|
||||||
type="primary"
|
|
||||||
htmlType="submit"
|
|
||||||
loading={saveMut.isPending}
|
|
||||||
icon={<SaveOutlined />}
|
|
||||||
>
|
|
||||||
Save branding
|
Save branding
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@ -191,22 +164,11 @@ export function BrandingForm() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{preview?.logoUrl && (
|
{preview?.logoUrl && (
|
||||||
<img
|
<img src={preview.logoUrl} alt="" style={{ maxHeight: 28, background: '#fff', padding: 2, borderRadius: 2 }} />
|
||||||
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
|
<div style={{ background: preview?.secondaryColor ?? '#374151', color: '#fff', padding: 8, marginTop: 4 }}>
|
||||||
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 }}>
|
||||||
|
|||||||
@ -25,13 +25,7 @@ export function ConfigOverviewCard() {
|
|||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
<Descriptions
|
<Descriptions title="Application" column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||||
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'}>
|
||||||
@ -46,13 +40,7 @@ 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
|
<Descriptions title="Database" column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||||
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>
|
||||||
@ -61,130 +49,62 @@ 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 ? (
|
{data.database.migrateOnStartup ? <Tag color="green">Yes</Tag> : <Tag color="red">No</Tag>}
|
||||||
<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 ? (
|
{data.database.autoProvisionLocalTimescaleDb ? <Tag>Yes</Tag> : <Tag color="green">No</Tag>}
|
||||||
<Tag>Yes</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color="green">No</Tag>
|
|
||||||
)}
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Descriptions
|
<Descriptions title="Grafana" column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||||
title="Grafana"
|
<Descriptions.Item label="Base URL" span={2}>{data.grafana.baseUrl}</Descriptions.Item>
|
||||||
column={2}
|
<Descriptions.Item label="Internal URL" span={2}>{data.grafana.internalUrl}</Descriptions.Item>
|
||||||
size="small"
|
<Descriptions.Item label="Embed path prefix">{data.grafana.embedPathPrefix}</Descriptions.Item>
|
||||||
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">
|
<Descriptions.Item label="Default dashboard UID">{data.grafana.defaultDashboardUid || '(unset)'}</Descriptions.Item>
|
||||||
{data.grafana.defaultDashboardUid || '(unset)'}
|
<Descriptions.Item label="Dashboards configured" span={2}>{data.grafana.dashboardCount}</Descriptions.Item>
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Dashboards configured" span={2}>
|
|
||||||
{data.grafana.dashboardCount}
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Descriptions
|
<Descriptions title="Monitoring" column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||||
title="Monitoring"
|
<Descriptions.Item label="Chunk time interval">{data.monitoring.chunkTimeInterval}</Descriptions.Item>
|
||||||
column={2}
|
|
||||||
size="small"
|
|
||||||
bordered
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
>
|
|
||||||
<Descriptions.Item label="Chunk time interval">
|
|
||||||
{data.monitoring.chunkTimeInterval}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Hourly aggregates">
|
<Descriptions.Item label="Hourly aggregates">
|
||||||
{data.monitoring.enableHourlyAggregates ? (
|
{data.monitoring.enableHourlyAggregates ? <Tag color="orange">Flag set (not implemented)</Tag> : 'No'}
|
||||||
<Tag color="orange">Flag set (not implemented)</Tag>
|
|
||||||
) : (
|
|
||||||
'No'
|
|
||||||
)}
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Descriptions
|
<Descriptions title="Authentication" column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||||
title="Authentication"
|
<Descriptions.Item label="Cookie name">{data.authentication.cookieName}</Descriptions.Item>
|
||||||
column={2}
|
<Descriptions.Item label="Require confirmed email">{data.authentication.requireConfirmedEmail ? 'Yes' : 'No'}</Descriptions.Item>
|
||||||
size="small"
|
<Descriptions.Item label="Default admin email" span={2}>{data.authentication.defaultAdminEmail}</Descriptions.Item>
|
||||||
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">
|
<Descriptions.Item label="Assembly version">{data.build.assemblyVersion}</Descriptions.Item>
|
||||||
{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}>
|
<Descriptions.Item label="Started" span={2}>{new Date(data.build.startedAtUtc).toLocaleString()}</Descriptions.Item>
|
||||||
{new Date(data.build.startedAtUtc).toLocaleString()}
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
{data.fleetIngest && (
|
{data.fleetIngest && (
|
||||||
<>
|
<>
|
||||||
<Descriptions
|
<Descriptions title="Fleet push (Client → Admin)" column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||||
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>
|
||||||
@ -210,44 +130,23 @@ 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: 'Resource',
|
title: 'Last cursor', dataIndex: 'lastCursor', key: 'lc',
|
||||||
dataIndex: 'resourceType',
|
render: (v: string | null) => v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>
|
||||||
key: 'rt',
|
|
||||||
render: (v: string) => <Text strong>{v}</Text>,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Last cursor',
|
title: 'Last sync', dataIndex: 'lastSyncedAt', key: 'ls',
|
||||||
dataIndex: 'lastCursor',
|
render: (v: string | null) => v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>
|
||||||
key: 'lc',
|
|
||||||
render: (v: string | null) =>
|
|
||||||
v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Last sync',
|
title: 'Failures', dataIndex: 'consecutiveFailures', key: 'cf',
|
||||||
dataIndex: 'lastSyncedAt',
|
render: (n: number) => n === 0
|
||||||
key: 'ls',
|
? <Tag color="green">0</Tag>
|
||||||
render: (v: string | null) =>
|
: <Tag color={n > 5 ? 'red' : 'orange'}>{n}</Tag>
|
||||||
v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Failures',
|
title: 'Last error', dataIndex: 'lastError', key: 'le',
|
||||||
dataIndex: 'consecutiveFailures',
|
render: (v: string | null) => v ? <Text type="danger" style={{ fontSize: 12 }}>{v}</Text> : <Text type="secondary">—</Text>
|
||||||
key: 'cf',
|
|
||||||
render: (n: number) =>
|
|
||||||
n === 0 ? <Tag color="green">0</Tag> : <Tag color={n > 5 ? 'red' : 'orange'}>{n}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Last error',
|
|
||||||
dataIndex: 'lastError',
|
|
||||||
key: 'le',
|
|
||||||
render: (v: string | null) =>
|
|
||||||
v ? (
|
|
||||||
<Text type="danger" style={{ fontSize: 12 }}>
|
|
||||||
{v}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text type="secondary">—</Text>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -11,10 +11,7 @@ interface DashboardRow extends GrafanaDashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GrafanaInfoCard() {
|
export function GrafanaInfoCard() {
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({ queryKey: ['grafana-config'], queryFn: fetchGrafanaConfig });
|
||||||
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) => ({
|
||||||
@ -41,9 +38,7 @@ export function GrafanaInfoCard() {
|
|||||||
key: 'url',
|
key: 'url',
|
||||||
render: (url: string) => (
|
render: (url: string) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Text code style={{ fontSize: 11 }}>
|
<Text code style={{ fontSize: 11 }}>{url}</Text>
|
||||||
{url}
|
|
||||||
</Text>
|
|
||||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(url)} />
|
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(url)} />
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@ -57,11 +52,7 @@ export function GrafanaInfoCard() {
|
|||||||
<Space>
|
<Space>
|
||||||
<Text code>{data?.baseUrl}</Text>
|
<Text code>{data?.baseUrl}</Text>
|
||||||
{data?.baseUrl && (
|
{data?.baseUrl && (
|
||||||
<Button
|
<Button size="small" icon={<LinkOutlined />} onClick={() => window.open(data.baseUrl, '_blank')}>
|
||||||
size="small"
|
|
||||||
icon={<LinkOutlined />}
|
|
||||||
onClick={() => window.open(data.baseUrl, '_blank')}
|
|
||||||
>
|
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@ -70,14 +61,12 @@ 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">
|
<Descriptions.Item label="Dashboards configured">{data?.dashboards.length ?? 0}</Descriptions.Item>
|
||||||
{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
|
Add dashboards by dropping JSON into <Text code>grafana/dashboards/</Text> and adding a matching entry
|
||||||
matching entry to <Text code>Grafana.Dashboards</Text> in configuration.
|
to <Text code>Grafana.Dashboards</Text> in configuration.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|
||||||
<Table<DashboardRow>
|
<Table<DashboardRow>
|
||||||
|
|||||||
@ -1,33 +1,14 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table, Button, Space, Modal, Input, Switch, Tag, Popconfirm, Form, Typography, message, Card,
|
||||||
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,
|
listMunicipalities, createMunicipality, updateMunicipality, deleteMunicipality,
|
||||||
createMunicipality,
|
listTariffs, getTariff, deleteTariff,
|
||||||
updateMunicipality,
|
type Municipality, type TariffSummary, type UpsertMunicipality, type TariffDetail,
|
||||||
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';
|
||||||
|
|
||||||
@ -60,29 +41,17 @@ export function MunicipalityList() {
|
|||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: (payload: UpsertMunicipality) => createMunicipality(payload),
|
mutationFn: (payload: UpsertMunicipality) => createMunicipality(payload),
|
||||||
onSuccess: () => {
|
onSuccess: () => { message.success('Created'); closeMuni(); qc.invalidateQueries({ queryKey: ['municipalities'] }); },
|
||||||
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 }) =>
|
mutationFn: ({ id, payload }: { id: number; payload: UpsertMunicipality }) => updateMunicipality(id, payload),
|
||||||
updateMunicipality(id, payload),
|
onSuccess: () => { message.success('Updated'); closeMuni(); qc.invalidateQueries({ queryKey: ['municipalities'] }); },
|
||||||
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: () => {
|
onSuccess: () => { message.success('Deleted'); qc.invalidateQueries({ queryKey: ['municipalities'] }); },
|
||||||
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({
|
||||||
@ -124,37 +93,27 @@ 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) =>
|
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>),
|
||||||
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)}>
|
<Button size="small" icon={<EditOutlined />} onClick={() => openEditMuni(m)}>Edit</Button>
|
||||||
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 />}>
|
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@ -164,12 +123,8 @@ 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">
|
<Text type="secondary">Configure municipalities and their tariffs. Expand a row to see tariffs.</Text>
|
||||||
Configure municipalities and their tariffs. Expand a row to see tariffs.
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateMuni}>New municipality</Button>
|
||||||
</Text>
|
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateMuni}>
|
|
||||||
New municipality
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Table<Municipality>
|
<Table<Municipality>
|
||||||
@ -199,12 +154,7 @@ export function MunicipalityList() {
|
|||||||
onOk={() => muniForm.submit()}
|
onOk={() => muniForm.submit()}
|
||||||
confirmLoading={createMut.isPending || updateMut.isPending}
|
confirmLoading={createMut.isPending || updateMut.isPending}
|
||||||
>
|
>
|
||||||
<Form<MuniFormShape>
|
<Form<MuniFormShape> form={muniForm} layout="vertical" onFinish={onMuniSubmit} requiredMark={false}>
|
||||||
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>
|
||||||
@ -248,23 +198,9 @@ 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: 'Effective',
|
{ title: 'Default rate', dataIndex: 'defaultRatePerKwh', key: 'rate', render: (v: number) => v.toFixed(4) },
|
||||||
key: 'effective',
|
{ title: 'Fixed', dataIndex: 'fixedMonthlyCharge', key: 'fixed', render: (v: number) => v.toFixed(2) },
|
||||||
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' },
|
||||||
{
|
{
|
||||||
@ -277,18 +213,14 @@ function TariffSubTable({
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
render: (_, t) => (
|
render: (_, t) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button size="small" icon={<EditOutlined />} onClick={() => onEdit(t.id)}>
|
<Button size="small" icon={<EditOutlined />} onClick={() => onEdit(t.id)}>Edit</Button>
|
||||||
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 />}>
|
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@ -298,9 +230,7 @@ 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}>
|
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onCreate}>New tariff</Button>
|
||||||
New tariff
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
<Table<TariffSummary>
|
<Table<TariffSummary>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@ -68,10 +68,7 @@ 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
|
<Tooltip key={dIdx} title={['Mon','Tue','Wed','Thu','Fri','Sat','Sun'][dIdx]}>
|
||||||
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'}
|
||||||
|
|||||||
@ -1,33 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer, Form, Input, InputNumber, Switch, DatePicker, Row, Col, Button, Space, Alert, Typography,
|
||||||
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 {
|
import { createTariff, updateTariff, type TariffDetail, type TariffPeriod, type UpsertTariff } from '../../../api/rates';
|
||||||
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 = { kind: 'create'; municipalityId: number } | { kind: 'edit'; tariff: TariffDetail };
|
type Mode =
|
||||||
|
| { kind: 'create'; municipalityId: number }
|
||||||
|
| { kind: 'edit'; tariff: TariffDetail };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -90,7 +74,7 @@ export function TariffDrawer({ open, mode, onClose }: Props) {
|
|||||||
qc.invalidateQueries({ queryKey: ['municipalities'] });
|
qc.invalidateQueries({ queryKey: ['municipalities'] });
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => setError(extractApiError(err, 'Save failed.')),
|
onError: (err: unknown) => setError(extractError(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (values: FormShape) => {
|
const handleSubmit = (values: FormShape) => {
|
||||||
@ -127,11 +111,7 @@ export function TariffDrawer({ open, mode, onClose }: Props) {
|
|||||||
<Form<FormShape> form={form} layout="vertical" onFinish={handleSubmit} requiredMark={false}>
|
<Form<FormShape> form={form} layout="vertical" onFinish={handleSubmit} requiredMark={false}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item name="name" label="Tariff name" rules={[{ required: true, message: 'Required' }]}>
|
||||||
name="name"
|
|
||||||
label="Tariff name"
|
|
||||||
rules={[{ required: true, message: 'Required' }]}
|
|
||||||
>
|
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
@ -185,11 +165,17 @@ export function TariffDrawer({ open, mode, onClose }: Props) {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Title level={5} style={{ marginTop: 8 }}>
|
<Title level={5} style={{ marginTop: 8 }}>Time-of-use periods</Title>
|
||||||
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.';
|
||||||
|
}
|
||||||
|
|||||||
@ -111,11 +111,7 @@ 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 && (
|
{!c.isActive && <Tag color="red" style={{ marginLeft: 8 }}>Disabled</Tag>}
|
||||||
<Tag color="red" style={{ marginLeft: 8 }}>
|
|
||||||
Disabled
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
</Checkbox.Group>
|
</Checkbox.Group>
|
||||||
|
|||||||
@ -4,7 +4,9 @@ import type { UserListItem, CreateUserPayload, UpdateUserPayload } from '../../a
|
|||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
type Mode = { kind: 'create' } | { kind: 'edit'; user: UserListItem };
|
type Mode =
|
||||||
|
| { kind: 'create' }
|
||||||
|
| { kind: 'edit'; user: UserListItem };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -97,9 +99,7 @@ export function UserFormDrawer({ open, mode, submitting, error, onClose, onSubmi
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name="password"
|
name="password"
|
||||||
label="Password"
|
label="Password"
|
||||||
rules={[
|
rules={[{ required: true, min: 8, message: 'Min 8 characters with upper, lower, and digit' }]}
|
||||||
{ 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,22 +109,15 @@ 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 }}>
|
<Text type="secondary" style={{ display: 'block', fontSize: 12 }}>Regular user — Dashboard + Dashboards only.</Text>
|
||||||
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 }}>
|
<Text type="secondary" style={{ display: 'block', fontSize: 12 }}>Full access to all customers, settings, user mgmt.</Text>
|
||||||
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 }}>
|
<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>
|
||||||
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>
|
||||||
|
|||||||
@ -1,44 +1,21 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card, Descriptions, Tabs, Table, Tag, Typography, Button, Space, Spin, Result, Tooltip,
|
||||||
Descriptions,
|
DatePicker, Statistic, Row, Col, Alert,
|
||||||
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,
|
ArrowLeftOutlined, LineChartOutlined, ThunderboltOutlined, DollarOutlined, DownloadOutlined,
|
||||||
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,
|
fetchFleetCustomerDetail, fetchFleetCustomerCost,
|
||||||
fetchFleetCustomerCost,
|
type FleetSite, type FleetDevice,
|
||||||
type FleetSite,
|
type FleetRecentMeasurement, type FleetIngestEvent,
|
||||||
type FleetDevice,
|
type FleetTariffView, type FleetTariffPeriodView,
|
||||||
type FleetRecentMeasurement,
|
type FleetCostDay, type FleetCostDeviceRow,
|
||||||
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';
|
||||||
@ -70,122 +47,47 @@ export function AdminCustomerDetailPage() {
|
|||||||
return `${base}/d/${encodeURIComponent(CUSTOMER_DRILLDOWN_UID)}?orgId=1&kiosk=tv&theme=light&var-customer=${encodeURIComponent(data.id)}`;
|
return `${base}/d/${encodeURIComponent(CUSTOMER_DRILLDOWN_UID)}?orgId=1&kiosk=tv&theme=light&var-customer=${encodeURIComponent(data.id)}`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading) return <div style={{ textAlign: 'center', padding: 64 }}><Spin size="large" /></div>;
|
||||||
return (
|
|
||||||
<div style={{ textAlign: 'center', padding: 64 }}>
|
|
||||||
<Spin size="large" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
return (
|
return <Result status="404" title="Customer not found" extra={<Button onClick={() => navigate('/admin/customers')}>Back</Button>} />;
|
||||||
<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: 'Address',
|
{ title: 'Active', dataIndex: 'isActive', key: 'a', render: (v: boolean) => v ? <Tag color="green">Active</Tag> : <Tag>Inactive</Tag> },
|
||||||
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: 'External ID',
|
{ title: 'Description', dataIndex: 'description', key: 'desc', render: v => v ?? <Text type="secondary">—</Text> },
|
||||||
dataIndex: 'externalId',
|
{ title: 'Active', dataIndex: 'isActive', key: 'a', render: (v: boolean) => v ? <Tag color="green">Active</Tag> : <Tag>Inactive</Tag> },
|
||||||
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: 'Active power (kW)',
|
{ title: 'kWh imported (cumulative)', dataIndex: 'energyImportedKwh', key: 'e', render: (v: number | null) => v == null ? '—' : v.toFixed(2) },
|
||||||
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: 'Time spread',
|
{ title: 'Error', dataIndex: 'error', key: 'e', render: (v: string | null) => v ? <Tag color="red">{v}</Tag> : '—' },
|
||||||
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')}>
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/customers')}>Customers</Button>
|
||||||
Customers
|
<Text strong style={{ fontSize: 20 }}>{data.code} · {data.name}</Text>
|
||||||
</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
|
||||||
@ -209,23 +111,9 @@ 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">
|
<Descriptions.Item label="Created">{new Date(data.createdAt).toLocaleString()}</Descriptions.Item>
|
||||||
{new Date(data.createdAt).toLocaleString()}
|
<Descriptions.Item label="First seen">{data.firstSeenAt ? new Date(data.firstSeenAt).toLocaleString() : <Text type="secondary">Never</Text>}</Descriptions.Item>
|
||||||
</Descriptions.Item>
|
<Descriptions.Item label="Last seen" span={3}>{data.lastSeenAt ? new Date(data.lastSeenAt).toLocaleString() : <Text type="secondary">Never</Text>}</Descriptions.Item>
|
||||||
<Descriptions.Item label="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>
|
||||||
|
|
||||||
@ -236,64 +124,27 @@ export function AdminCustomerDetailPage() {
|
|||||||
{
|
{
|
||||||
key: 'ingest',
|
key: 'ingest',
|
||||||
label: `Recent ingest (${data.recentIngestEvents.length})`,
|
label: `Recent ingest (${data.recentIngestEvents.length})`,
|
||||||
children: (
|
children: <Table<FleetIngestEvent> rowKey="receivedAt" columns={eventCols} dataSource={data.recentIngestEvents} pagination={false} size="small" />,
|
||||||
<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: (
|
children: <Table<FleetRecentMeasurement> rowKey={(r) => `${r.time}-${r.deviceId}`} columns={measCols} dataSource={data.recentMeasurements} pagination={false} size="small" />,
|
||||||
<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: (
|
children: <Table<FleetSite> rowKey="id" columns={siteCols} dataSource={data.sites} pagination={false} size="small" />,
|
||||||
<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: (
|
children: <Table<FleetDevice> rowKey="id" columns={deviceCols} dataSource={data.devices} pagination={false} size="small" />,
|
||||||
<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: (
|
children: <TariffsTab tariffs={data.tariffs} municipalitiesCount={data.municipalities.length} />,
|
||||||
<TariffsTab
|
|
||||||
tariffs={data.tariffs}
|
|
||||||
municipalitiesCount={data.municipalities.length}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'cost',
|
key: 'cost',
|
||||||
@ -308,13 +159,7 @@ 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({
|
function TariffsTab({ tariffs, municipalitiesCount }: { tariffs: FleetTariffView[]; municipalitiesCount: number }) {
|
||||||
tariffs,
|
|
||||||
municipalitiesCount,
|
|
||||||
}: {
|
|
||||||
tariffs: FleetTariffView[];
|
|
||||||
municipalitiesCount: number;
|
|
||||||
}) {
|
|
||||||
if (tariffs.length === 0) {
|
if (tariffs.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
@ -325,26 +170,14 @@ function TariffsTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const periodCols: ColumnsType<FleetTariffPeriodView> = [
|
const periodCols: ColumnsType<FleetTariffPeriodView> = [
|
||||||
|
{ title: 'Period', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
|
||||||
{
|
{
|
||||||
title: 'Period',
|
title: 'Days', dataIndex: 'daysOfWeek', key: 'd',
|
||||||
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 (
|
||||||
@ -363,18 +196,13 @@ function TariffsTab({
|
|||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{t.effectiveFrom}
|
{t.effectiveFrom}{t.effectiveTo ? ` → ${t.effectiveTo}` : ' →'}
|
||||||
{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">
|
<Descriptions.Item label="Default rate">{t.defaultRatePerKwh.toFixed(4)}/kWh</Descriptions.Item>
|
||||||
{t.defaultRatePerKwh.toFixed(4)}/kWh
|
<Descriptions.Item label="Fixed monthly">{t.fixedMonthlyCharge.toFixed(2)}</Descriptions.Item>
|
||||||
</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 ? (
|
||||||
@ -419,35 +247,18 @@ 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: 'Device',
|
title: 'Municipality', dataIndex: 'municipalityName', key: 'm',
|
||||||
dataIndex: 'deviceName',
|
render: (v: string | null) => v ?? <Text type="secondary">—</Text>
|
||||||
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 (
|
||||||
@ -459,19 +270,11 @@ function CostTab({ customerId }: { customerId: string }) {
|
|||||||
allowClear={false}
|
allowClear={false}
|
||||||
value={range}
|
value={range}
|
||||||
showTime={false}
|
showTime={false}
|
||||||
onChange={(v) =>
|
onChange={(v) => v && v[0] && v[1] && setRange([v[0].startOf('day'), v[1].endOf('day')])}
|
||||||
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': [
|
'Last 7d': [dayjs().subtract(7, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')],
|
||||||
dayjs().subtract(7, 'day').startOf('day'),
|
'Last 30d': [dayjs().subtract(30, 'day').startOf('day'), 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')],
|
'This month': [dayjs().startOf('month'), dayjs().add(1, 'day').startOf('day')],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -486,58 +289,15 @@ function CostTab({ customerId }: { customerId: string }) {
|
|||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
{isLoading && <Spin />}
|
{isLoading && <Spin />}
|
||||||
{error && (
|
{error && <Alert type="error" message="Failed to compute cost" description={(error as Error).message} />}
|
||||||
<Alert
|
|
||||||
type="error"
|
|
||||||
message="Failed to compute cost"
|
|
||||||
description={(error as Error).message}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={6}>
|
<Col span={6}><Card><Statistic title="Total kWh" value={data.totalKwh} precision={3} prefix={<ThunderboltOutlined />} /></Card></Col>
|
||||||
<Card>
|
<Col span={6}><Card><Statistic title="Base cost" value={data.totalBaseCost} precision={2} prefix={<DollarOutlined />} /></Card></Col>
|
||||||
<Statistic
|
<Col span={6}><Card><Statistic title="VAT" value={data.totalVatAmount} precision={2} prefix={<DollarOutlined />} /></Card></Col>
|
||||||
title="Total kWh"
|
<Col span={6}><Card><Statistic title="Total" value={data.totalCost} precision={2} valueStyle={{ color: '#3f8600' }} prefix={<DollarOutlined />} /></Card></Col>
|
||||||
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 && (
|
||||||
@ -558,21 +318,15 @@ function CostTab({ customerId }: { customerId: string }) {
|
|||||||
|
|
||||||
<Card title="Per day" size="small">
|
<Card title="Per day" size="small">
|
||||||
<Table<FleetCostDay>
|
<Table<FleetCostDay>
|
||||||
rowKey="date"
|
rowKey="date" size="small" pagination={false}
|
||||||
size="small"
|
columns={dayCols} dataSource={data.daily}
|
||||||
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"
|
rowKey="deviceId" size="small" pagination={false}
|
||||||
size="small"
|
columns={deviceCols} dataSource={data.perDevice}
|
||||||
pagination={false}
|
|
||||||
columns={deviceCols}
|
|
||||||
dataSource={data.perDevice}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -5,18 +5,11 @@ 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,
|
listCustomers, createCustomer, updateCustomer, rotateCustomerToken, deleteCustomer,
|
||||||
createCustomer,
|
type CustomerListItem, type CreateCustomerPayload, type UpdateCustomerPayload,
|
||||||
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;
|
||||||
|
|
||||||
@ -27,14 +20,8 @@ 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<{
|
const [tokenModal, setTokenModal] = useState<{ open: boolean; code: string | null; token: string | null }>({
|
||||||
open: boolean;
|
open: false, code: null, token: null,
|
||||||
code: string | null;
|
|
||||||
token: string | null;
|
|
||||||
}>({
|
|
||||||
open: false,
|
|
||||||
code: null,
|
|
||||||
token: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: customers = [], isLoading } = useQuery({
|
const { data: customers = [], isLoading } = useQuery({
|
||||||
@ -52,19 +39,18 @@ 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(extractApiError(err)),
|
onError: (err: unknown) => setFormError(extractError(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMut = useMutation({
|
const updateMut = useMutation({
|
||||||
mutationFn: ({ id, payload }: { id: string; payload: UpdateCustomerPayload }) =>
|
mutationFn: ({ id, payload }: { id: string; payload: UpdateCustomerPayload }) => updateCustomer(id, payload),
|
||||||
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(extractApiError(err)),
|
onError: (err: unknown) => setFormError(extractError(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const rotateMut = useMutation({
|
const rotateMut = useMutation({
|
||||||
@ -73,16 +59,13 @@ 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(extractApiError(err)),
|
onError: (err: unknown) => message.error(extractError(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMut = useMutation({
|
const deleteMut = useMutation({
|
||||||
mutationFn: (id: string) => deleteCustomer(id),
|
mutationFn: (id: string) => deleteCustomer(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => { message.success('Customer deleted'); invalidate(); },
|
||||||
message.success('Customer deleted');
|
onError: (err: unknown) => message.error(extractError(err)),
|
||||||
invalidate();
|
|
||||||
},
|
|
||||||
onError: (err: unknown) => message.error(extractApiError(err)),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (payload: CreateCustomerPayload | UpdateCustomerPayload) => {
|
const handleSubmit = (payload: CreateCustomerPayload | UpdateCustomerPayload) => {
|
||||||
@ -101,8 +84,7 @@ export function AdminCustomersPage() {
|
|||||||
title: 'Status',
|
title: 'Status',
|
||||||
dataIndex: 'isActive',
|
dataIndex: 'isActive',
|
||||||
key: 'isActive',
|
key: 'isActive',
|
||||||
render: (v: boolean) =>
|
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>),
|
||||||
v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Last push',
|
title: 'Last push',
|
||||||
@ -124,9 +106,7 @@ export function AdminCustomersPage() {
|
|||||||
return (
|
return (
|
||||||
<Space size={4} direction="vertical">
|
<Space size={4} direction="vertical">
|
||||||
{issued}
|
{issued}
|
||||||
<Tooltip
|
<Tooltip title={`Old token still accepted by ingest until ${exp.toLocaleString()}. Customer ops should update their .env before then.`}>
|
||||||
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>
|
||||||
@ -140,14 +120,7 @@ export function AdminCustomersPage() {
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
render: (_, c) => (
|
render: (_, c) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button size="small" icon={<EditOutlined />} onClick={() => { setFormError(null); setFormMode({ kind: 'edit', customer: c }); }}>
|
||||||
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.">
|
||||||
@ -158,11 +131,7 @@ export function AdminCustomersPage() {
|
|||||||
okButtonProps={{ danger: true }}
|
okButtonProps={{ danger: true }}
|
||||||
onConfirm={() => rotateMut.mutate(c.id)}
|
onConfirm={() => rotateMut.mutate(c.id)}
|
||||||
>
|
>
|
||||||
<Button
|
<Button size="small" icon={<ReloadOutlined />} loading={rotateMut.isPending && rotateMut.variables === c.id}>
|
||||||
size="small"
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
loading={rotateMut.isPending && rotateMut.variables === c.id}
|
|
||||||
>
|
|
||||||
Rotate token
|
Rotate token
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
@ -174,9 +143,7 @@ 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 />}>
|
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@ -190,10 +157,7 @@ export function AdminCustomersPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => {
|
onClick={() => { setFormError(null); setFormMode({ kind: 'create' }); }}
|
||||||
setFormError(null);
|
|
||||||
setFormMode({ kind: 'create' });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Register customer
|
Register customer
|
||||||
</Button>
|
</Button>
|
||||||
@ -220,10 +184,7 @@ export function AdminCustomersPage() {
|
|||||||
mode={formMode}
|
mode={formMode}
|
||||||
submitting={createMut.isPending || updateMut.isPending}
|
submitting={createMut.isPending || updateMut.isPending}
|
||||||
error={formError}
|
error={formError}
|
||||||
onClose={() => {
|
onClose={() => { setFormMode(null); setFormError(null); }}
|
||||||
setFormMode(null);
|
|
||||||
setFormError(null);
|
|
||||||
}}
|
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -236,3 +197,11 @@ 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.';
|
||||||
|
}
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
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,
|
ApartmentOutlined, ThunderboltOutlined, TeamOutlined, CheckCircleOutlined, DollarOutlined,
|
||||||
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';
|
||||||
@ -27,38 +23,26 @@ 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',
|
title: 'Status', dataIndex: 'isActive', key: 'active',
|
||||||
dataIndex: 'isActive',
|
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>)
|
||||||
key: 'active',
|
|
||||||
render: (v: boolean) =>
|
|
||||||
v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Last push',
|
title: 'Last push', dataIndex: 'lastSeenAt', key: 'last',
|
||||||
dataIndex: 'lastSeenAt',
|
render: (v: string | null) => v ? lagDescription(v) : <Text type="secondary">Never</Text>
|
||||||
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)',
|
title: 'Today (rows)', dataIndex: 'measurementsToday', key: 'mt',
|
||||||
dataIndex: 'measurementsToday',
|
render: (n: number) => n.toLocaleString()
|
||||||
key: 'mt',
|
|
||||||
render: (n: number) => n.toLocaleString(),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Today (kWh imp.)',
|
title: 'Today (kWh imp.)', dataIndex: 'kwhImportedToday', key: 'kwh',
|
||||||
dataIndex: 'kwhImportedToday',
|
render: (v: number | null) => v == null ? '—' : v.toFixed(2)
|
||||||
key: 'kwh',
|
|
||||||
render: (v: number | null) => (v == null ? '—' : v.toFixed(2)),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Today (cost)',
|
title: 'Today (cost)', dataIndex: 'costToday', key: 'cost',
|
||||||
dataIndex: 'costToday',
|
render: (v: number | null) => v == null ? <Text type="secondary">—</Text> : <Text strong>{v.toFixed(2)}</Text>
|
||||||
key: 'cost',
|
|
||||||
render: (v: number | null) =>
|
|
||||||
v == null ? <Text type="secondary">—</Text> : <Text strong>{v.toFixed(2)}</Text>,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -67,12 +51,7 @@ export function AdminFleetDashboardPage() {
|
|||||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
<Col span={5}>
|
<Col span={5}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Statistic title="Customers" value={data?.totalCustomers ?? 0} prefix={<TeamOutlined />} loading={isLoading} />
|
||||||
title="Customers"
|
|
||||||
value={data?.totalCustomers ?? 0}
|
|
||||||
prefix={<TeamOutlined />}
|
|
||||||
loading={isLoading}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={5}>
|
<Col span={5}>
|
||||||
|
|||||||
@ -4,18 +4,9 @@ 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,
|
listSites, createSite, updateSite, deleteSite,
|
||||||
createSite,
|
listSiteDevices, createDevice, updateDevice, deleteDevice,
|
||||||
updateSite,
|
type Site, type Device, type UpsertSite, type UpsertDevice,
|
||||||
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';
|
||||||
@ -24,18 +15,9 @@ const { Text } = Typography;
|
|||||||
|
|
||||||
export function AdminSitesPage() {
|
export function AdminSitesPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [siteModal, setSiteModal] = useState<{ open: boolean; editing: Site | null }>({
|
const [siteModal, setSiteModal] = useState<{ open: boolean; editing: Site | null }>({ open: false, editing: null });
|
||||||
open: false,
|
const [deviceModal, setDeviceModal] = useState<{ open: boolean; siteId: string | null; editing: Device | null }>({
|
||||||
editing: null,
|
open: false, siteId: 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[]>([]);
|
||||||
|
|
||||||
@ -43,34 +25,22 @@ export function AdminSitesPage() {
|
|||||||
|
|
||||||
const createSiteMut = useMutation({
|
const createSiteMut = useMutation({
|
||||||
mutationFn: (p: UpsertSite) => createSite(p),
|
mutationFn: (p: UpsertSite) => createSite(p),
|
||||||
onSuccess: () => {
|
onSuccess: () => { message.success('Site created'); setSiteModal({ open: false, editing: null }); qc.invalidateQueries({ queryKey: ['sites'] }); },
|
||||||
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: () => {
|
onSuccess: () => { message.success('Site updated'); setSiteModal({ open: false, editing: null }); qc.invalidateQueries({ queryKey: ['sites'] }); },
|
||||||
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: () => {
|
onSuccess: () => { message.success('Site deleted'); qc.invalidateQueries({ queryKey: ['sites'] }); },
|
||||||
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 }) =>
|
mutationFn: ({ siteId, payload }: { siteId: string; payload: UpsertDevice }) => createDevice(siteId, payload),
|
||||||
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 });
|
||||||
@ -80,8 +50,7 @@ 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 }) =>
|
mutationFn: ({ deviceId, payload }: { deviceId: string; payload: UpsertDevice }) => updateDevice(deviceId, payload),
|
||||||
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 });
|
||||||
@ -115,40 +84,26 @@ 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) =>
|
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>),
|
||||||
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
|
<Button size="small" icon={<EditOutlined />} onClick={() => setSiteModal({ open: true, editing: site })}>Edit</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 />}>
|
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@ -159,11 +114,7 @@ export function AdminSitesPage() {
|
|||||||
<Card
|
<Card
|
||||||
title="Sites"
|
title="Sites"
|
||||||
extra={
|
extra={
|
||||||
<Button
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setSiteModal({ open: true, editing: null })}>
|
||||||
type="primary"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => setSiteModal({ open: true, editing: null })}
|
|
||||||
>
|
|
||||||
New site
|
New site
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@ -208,10 +159,7 @@ export function AdminSitesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DeviceSubTable({
|
function DeviceSubTable({
|
||||||
site,
|
site, onCreate, onEdit, onDelete,
|
||||||
onCreate,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
}: {
|
||||||
site: Site;
|
site: Site;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
@ -237,18 +185,14 @@ function DeviceSubTable({
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
render: (_, d) => (
|
render: (_, d) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button size="small" icon={<EditOutlined />} onClick={() => onEdit(d)}>
|
<Button size="small" icon={<EditOutlined />} onClick={() => onEdit(d)}>Edit</Button>
|
||||||
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 />}>
|
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@ -258,9 +202,7 @@ 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}>
|
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onCreate}>New device</Button>
|
||||||
New device
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
<Table<Device>
|
<Table<Device>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@ -1,24 +1,9 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert, Button, Card, Col, DatePicker, Row, Space, Spin, Statistic, Table, Tag, Typography,
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Col,
|
|
||||||
DatePicker,
|
|
||||||
Row,
|
|
||||||
Space,
|
|
||||||
Spin,
|
|
||||||
Statistic,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
Typography,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
DollarOutlined,
|
DollarOutlined, DownloadOutlined, ThunderboltOutlined, ApartmentOutlined, ClockCircleOutlined,
|
||||||
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';
|
||||||
@ -27,11 +12,8 @@ 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,
|
fetchDashboardSummary, downloadDashboardSummaryXlsx, downloadRawMeasurementsXlsx,
|
||||||
downloadDashboardSummaryXlsx,
|
type DashboardDeviceRow, type DashboardSummary,
|
||||||
downloadRawMeasurementsXlsx,
|
|
||||||
type DashboardDeviceRow,
|
|
||||||
type DashboardSummary,
|
|
||||||
} from '../api/dashboard';
|
} from '../api/dashboard';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@ -66,20 +48,12 @@ function ClientDashboard() {
|
|||||||
<RangePicker
|
<RangePicker
|
||||||
allowClear={false}
|
allowClear={false}
|
||||||
value={range}
|
value={range}
|
||||||
onChange={(v) =>
|
onChange={(v) => v && v[0] && v[1] && setRange([v[0].startOf('day'), v[1].endOf('day')])}
|
||||||
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': [
|
'Last 7d': [dayjs().subtract(7, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')],
|
||||||
dayjs().subtract(7, 'day').startOf('day'),
|
'Last 30d': [dayjs().subtract(30, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')],
|
||||||
dayjs().add(1, 'day').startOf('day'),
|
'This month': [dayjs().startOf('month'), 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>
|
||||||
@ -102,8 +76,7 @@ function ClientDashboard() {
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error" showIcon
|
||||||
showIcon
|
|
||||||
message="Failed to load dashboard"
|
message="Failed to load dashboard"
|
||||||
description={(error as Error).message}
|
description={(error as Error).message}
|
||||||
/>
|
/>
|
||||||
@ -196,93 +169,46 @@ function ChartPanel({ data, loading }: { data: DashboardSummary | undefined; loa
|
|||||||
name: 'kW',
|
name: 'kW',
|
||||||
nameTextStyle: { fontSize: 11 },
|
nameTextStyle: { fontSize: 11 },
|
||||||
},
|
},
|
||||||
series: [
|
series: [{
|
||||||
{
|
name: 'Active power',
|
||||||
name: 'Active power',
|
type: 'line',
|
||||||
type: 'line',
|
smooth: true,
|
||||||
smooth: true,
|
showSymbol: false,
|
||||||
showSymbol: false,
|
areaStyle: { opacity: 0.15 },
|
||||||
areaStyle: { opacity: 0.15 },
|
data: points.map(p => [p.time, p.totalKw]),
|
||||||
data: points.map((p) => [p.time, p.totalKw]),
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Spin />
|
||||||
<Spin />
|
</div>;
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.chart.length === 0) {
|
if (!data || data.chart.length === 0) {
|
||||||
return (
|
return <div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
|
||||||
<div
|
<Space>
|
||||||
style={{
|
<ClockCircleOutlined />
|
||||||
height: 240,
|
<Text type="secondary">No measurements yet in this window.</Text>
|
||||||
display: 'flex',
|
</Space>
|
||||||
alignItems: 'center',
|
</div>;
|
||||||
justifyContent: 'center',
|
|
||||||
color: '#999',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space>
|
|
||||||
<ClockCircleOutlined />
|
|
||||||
<Text type="secondary">No measurements yet in this window.</Text>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <ReactECharts option={option} style={{ height: 240, width: '100%' }} notMerge lazyUpdate />;
|
||||||
<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: 'Device',
|
{ title: 'Site', dataIndex: 'siteName', key: 's', render: (v: string | null) => v ?? <Text type="secondary">—</Text> },
|
||||||
dataIndex: 'deviceName',
|
{ title: 'kWh', dataIndex: 'kwh', key: 'k', render: (v: number) => v.toFixed(3), align: 'right' as const },
|
||||||
key: 'd',
|
{ title: 'Peak kW', dataIndex: 'peakKw', key: 'p',
|
||||||
render: (v: string) => <Text strong>{v}</Text>,
|
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: 'Site',
|
{ title: 'Cost', dataIndex: 'cost', key: 'c', align: 'right' as const,
|
||||||
dataIndex: 'siteName',
|
render: (v: number | null) => v == null ? <Text type="secondary">—</Text> : <Text strong>{v.toFixed(2)}</Text> },
|
||||||
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 (
|
||||||
|
|||||||
@ -14,16 +14,15 @@ export function DashboardsPage() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const dashboards = useMemo(() => data?.dashboards ?? [], [data]);
|
const dashboards = data?.dashboards ?? [];
|
||||||
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 =
|
const initial = data?.defaultDashboardUid && dashboards.some((d) => d.uid === data.defaultDashboardUid)
|
||||||
data?.defaultDashboardUid && dashboards.some((d) => d.uid === data.defaultDashboardUid)
|
? data.defaultDashboardUid
|
||||||
? data.defaultDashboardUid
|
: dashboards[0]?.uid ?? null;
|
||||||
: (dashboards[0]?.uid ?? null);
|
|
||||||
if (initial) setSelected(initial);
|
if (initial) setSelected(initial);
|
||||||
}, [data, dashboards, selected]);
|
}, [data, dashboards, selected]);
|
||||||
|
|
||||||
|
|||||||
@ -1,81 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
|
||||||
import { LoginPage } from './LoginPage';
|
|
||||||
import * as useAuthModule from '../hooks/useAuth';
|
|
||||||
import * as useBrandingModule from '../hooks/useBranding';
|
|
||||||
|
|
||||||
function mockHooks(loginImpl: (email: string, pw: string) => Promise<void>) {
|
|
||||||
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
|
|
||||||
user: null,
|
|
||||||
loading: false,
|
|
||||||
login: loginImpl,
|
|
||||||
logout: vi.fn(),
|
|
||||||
setUser: vi.fn(),
|
|
||||||
});
|
|
||||||
vi.spyOn(useBrandingModule, 'useBranding').mockReturnValue({
|
|
||||||
branding: {
|
|
||||||
applicationName: 'Test Portal',
|
|
||||||
logoUrl: '',
|
|
||||||
primaryColor: '#000',
|
|
||||||
secondaryColor: '#111',
|
|
||||||
accentColor: '#222',
|
|
||||||
footerText: 'footer',
|
|
||||||
},
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('<LoginPage>', () => {
|
|
||||||
it('calls login() with the submitted credentials', async () => {
|
|
||||||
const login = vi.fn().mockResolvedValue(undefined);
|
|
||||||
mockHooks(login);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/login']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
<Route path="/" element={<div>home</div>} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText(/email/i), 'admin@example.com');
|
|
||||||
await userEvent.type(screen.getByLabelText(/password/i), 'ChangeMe123!');
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
|
|
||||||
|
|
||||||
expect(login).toHaveBeenCalledWith('admin@example.com', 'ChangeMe123!');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows an inline error when login() rejects', async () => {
|
|
||||||
const login = vi.fn().mockRejectedValue(new Error('bad creds'));
|
|
||||||
mockHooks(login);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/login']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText(/email/i), 'a@b.c');
|
|
||||||
await userEvent.type(screen.getByLabelText(/password/i), 'xx');
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
|
|
||||||
|
|
||||||
expect(await screen.findByText(/invalid email or password/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the branded application name in the header', () => {
|
|
||||||
mockHooks(vi.fn());
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/login']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText('Test Portal')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -19,13 +19,7 @@ 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);
|
||||||
|
|
||||||
// Return-to comes from either the ?returnTo= query param (set by the global
|
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';
|
||||||
// 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);
|
||||||
@ -83,10 +77,7 @@ export function LoginPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
{branding.footerText && (
|
{branding.footerText && (
|
||||||
<Text
|
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: 16, fontSize: 12 }}>
|
||||||
type="secondary"
|
|
||||||
style={{ display: 'block', textAlign: 'center', marginTop: 16, fontSize: 12 }}
|
|
||||||
>
|
|
||||||
{branding.footerText}
|
{branding.footerText}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,24 +1,13 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert, Button, Card, DatePicker, InputNumber, Select, Space, Table, Tag, Typography,
|
||||||
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,
|
fetchRawMeasurements, listAllDevices, downloadRawMeasurementsExport,
|
||||||
listAllDevices,
|
|
||||||
downloadRawMeasurementsExport,
|
|
||||||
type RawMeasurementRow,
|
type RawMeasurementRow,
|
||||||
} from '../api/measurements';
|
} from '../api/measurements';
|
||||||
|
|
||||||
@ -48,97 +37,42 @@ export function MeasurementsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading, isFetching, error, refetch } = useQuery({
|
const { data, isLoading, isFetching, error, refetch } = useQuery({
|
||||||
queryKey: [
|
queryKey: ['raw-measurements', fromIso, toIso, selectedDeviceIds.sort().join(','), limit, offset],
|
||||||
'raw-measurements',
|
queryFn: () => fetchRawMeasurements({
|
||||||
fromIso,
|
fromUtc: fromIso,
|
||||||
toIso,
|
toUtc: toIso,
|
||||||
selectedDeviceIds.sort().join(','),
|
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined,
|
||||||
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(
|
const deviceOptions = useMemo(() => devices.map(d => ({
|
||||||
() =>
|
value: d.id,
|
||||||
devices.map((d) => ({
|
label: `${d.name} — ${d.siteName}`,
|
||||||
value: d.id,
|
disabled: !d.isActive,
|
||||||
label: `${d.name} — ${d.siteName}`,
|
})), [devices]);
|
||||||
disabled: !d.isActive,
|
|
||||||
})),
|
|
||||||
[devices],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns: ColumnsType<RawMeasurementRow> = [
|
const columns: ColumnsType<RawMeasurementRow> = [
|
||||||
{
|
{
|
||||||
title: 'Time (UTC)',
|
title: 'Time (UTC)', dataIndex: 'time', key: 't', width: 180,
|
||||||
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: 'Device',
|
{ title: 'Site', dataIndex: 'siteName', key: 's', render: (v: string | null) => v ?? <Text type="secondary">—</Text> },
|
||||||
dataIndex: 'deviceName',
|
{ title: 'Active kW', dataIndex: 'activePowerKw', key: 'kw', align: 'right' as const,
|
||||||
key: 'd',
|
render: (v: number) => v.toFixed(3) },
|
||||||
render: (v: string) => <Text strong>{v}</Text>,
|
{ 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,
|
||||||
title: 'Site',
|
render: (v: number | null) => v == null ? '—' : v.toFixed(2) },
|
||||||
dataIndex: 'siteName',
|
{ title: 'PF', dataIndex: 'powerFactor', key: 'pf', align: 'right' as const,
|
||||||
key: 's',
|
render: (v: number | null) => v == null ? '—' : v.toFixed(3) },
|
||||||
render: (v: string | null) => v ?? <Text type="secondary">—</Text>,
|
{ 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,
|
||||||
title: 'Active kW',
|
render: (v: number | null) => v == null ? '—' : v.toFixed(2) },
|
||||||
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;
|
||||||
@ -157,43 +91,27 @@ export function MeasurementsPage() {
|
|||||||
allowClear={false}
|
allowClear={false}
|
||||||
value={range}
|
value={range}
|
||||||
showTime={false}
|
showTime={false}
|
||||||
onChange={(v) =>
|
onChange={(v) => v && v[0] && v[1] && (setRange([v[0].startOf('day'), v[1].endOf('day')]), setOffset(0))}
|
||||||
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': [
|
'Last 7d': [dayjs().subtract(7, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')],
|
||||||
dayjs().subtract(7, 'day').startOf('day'),
|
'Last 30d': [dayjs().subtract(30, 'day').startOf('day'), dayjs().add(1, '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')],
|
||||||
'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 }}>
|
<Text strong style={{ minWidth: 80 }}>Meters:</Text>
|
||||||
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) => {
|
onChange={(v) => { setSelectedDeviceIds(v); setOffset(0); }}
|
||||||
setSelectedDeviceIds(v);
|
|
||||||
setOffset(0);
|
|
||||||
}}
|
|
||||||
options={deviceOptions}
|
options={deviceOptions}
|
||||||
maxTagCount="responsive"
|
maxTagCount="responsive"
|
||||||
showSearch
|
showSearch
|
||||||
@ -205,11 +123,8 @@ export function MeasurementsPage() {
|
|||||||
<Text strong>Preview rows:</Text>
|
<Text strong>Preview rows:</Text>
|
||||||
<Select
|
<Select
|
||||||
value={limit}
|
value={limit}
|
||||||
onChange={(v) => {
|
onChange={(v) => { setLimit(v); setOffset(0); }}
|
||||||
setLimit(v);
|
options={PREVIEW_LIMIT_OPTIONS.map(n => ({ value: n, label: n.toString() }))}
|
||||||
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>
|
||||||
@ -231,8 +146,7 @@ export function MeasurementsPage() {
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error" showIcon
|
||||||
showIcon
|
|
||||||
message="Failed to load measurements"
|
message="Failed to load measurements"
|
||||||
description={(error as Error).message}
|
description={(error as Error).message}
|
||||||
/>
|
/>
|
||||||
@ -244,9 +158,7 @@ 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>
|
<Tag>{selectedDeviceIds.length} meter{selectedDeviceIds.length === 1 ? '' : 's'} selected</Tag>
|
||||||
{selectedDeviceIds.length} meter{selectedDeviceIds.length === 1 ? '' : 's'} selected
|
|
||||||
</Tag>
|
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
@ -259,14 +171,11 @@ export function MeasurementsPage() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
icon={<DownloadOutlined />}
|
icon={<DownloadOutlined />}
|
||||||
disabled={totalCount === 0}
|
disabled={totalCount === 0}
|
||||||
onClick={() =>
|
onClick={() => downloadRawMeasurementsExport({
|
||||||
downloadRawMeasurementsExport({
|
fromUtc: fromIso, toUtc: toIso,
|
||||||
fromUtc: fromIso,
|
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined,
|
||||||
toUtc: toIso,
|
rowCap: exportRowCap,
|
||||||
deviceIds: selectedDeviceIds.length > 0 ? selectedDeviceIds : undefined,
|
})}
|
||||||
rowCap: exportRowCap,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Export to Excel
|
Export to Excel
|
||||||
</Button>
|
</Button>
|
||||||
@ -290,10 +199,7 @@ export function MeasurementsPage() {
|
|||||||
: `Showing ${showingFrom.toLocaleString()}–${showingTo.toLocaleString()} of ${totalCount.toLocaleString()}`}
|
: `Showing ${showingFrom.toLocaleString()}–${showingTo.toLocaleString()} of ${totalCount.toLocaleString()}`}
|
||||||
</Text>
|
</Text>
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button disabled={!canPrev || isFetching} onClick={() => setOffset(Math.max(0, offset - limit))}>
|
||||||
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)}>
|
||||||
|
|||||||
@ -1,22 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tag, Typography, message,
|
||||||
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;
|
||||||
|
|
||||||
@ -49,7 +37,7 @@ export function MyProfilePage() {
|
|||||||
setUser(updated);
|
setUser(updated);
|
||||||
message.success('Profile updated');
|
message.success('Profile updated');
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setProfileError(extractApiError(err));
|
setProfileError(extractError(err));
|
||||||
} finally {
|
} finally {
|
||||||
setSavingProfile(false);
|
setSavingProfile(false);
|
||||||
}
|
}
|
||||||
@ -63,7 +51,7 @@ export function MyProfilePage() {
|
|||||||
passwordForm.resetFields();
|
passwordForm.resetFields();
|
||||||
message.success('Password changed');
|
message.success('Password changed');
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setPasswordError(extractApiError(err));
|
setPasswordError(extractError(err));
|
||||||
} finally {
|
} finally {
|
||||||
setSavingPassword(false);
|
setSavingPassword(false);
|
||||||
}
|
}
|
||||||
@ -72,39 +60,20 @@ export function MyProfilePage() {
|
|||||||
return (
|
return (
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={[24, 24]}>
|
||||||
<Col xs={24} md={12}>
|
<Col xs={24} md={12}>
|
||||||
<Card
|
<Card title={<Space><UserOutlined /><Title level={5} style={{ margin: 0 }}>My profile</Title></Space>}>
|
||||||
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 }}>
|
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>(read-only)</Text>
|
||||||
(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 && (
|
{profileError && <Alert type="error" message={profileError} style={{ marginBottom: 16 }} />}
|
||||||
<Alert type="error" message={profileError} style={{ marginBottom: 16 }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form<ProfileFormValues>
|
<Form<ProfileFormValues>
|
||||||
form={profileForm}
|
form={profileForm}
|
||||||
@ -124,12 +93,7 @@ export function MyProfilePage() {
|
|||||||
<Input autoComplete="name" />
|
<Input autoComplete="name" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item style={{ marginBottom: 0 }}>
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
<Button
|
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={savingProfile}>
|
||||||
type="primary"
|
|
||||||
htmlType="submit"
|
|
||||||
icon={<SaveOutlined />}
|
|
||||||
loading={savingProfile}
|
|
||||||
>
|
|
||||||
Save profile
|
Save profile
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@ -138,19 +102,8 @@ export function MyProfilePage() {
|
|||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} md={12}>
|
<Col xs={24} md={12}>
|
||||||
<Card
|
<Card title={<Space><LockOutlined /><Title level={5} style={{ margin: 0 }}>Change password</Title></Space>}>
|
||||||
title={
|
{passwordError && <Alert type="error" message={passwordError} style={{ marginBottom: 16 }} />}
|
||||||
<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}
|
||||||
@ -198,12 +151,7 @@ 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
|
<Button type="primary" htmlType="submit" icon={<LockOutlined />} loading={savingPassword}>
|
||||||
type="primary"
|
|
||||||
htmlType="submit"
|
|
||||||
icon={<LockOutlined />}
|
|
||||||
loading={savingPassword}
|
|
||||||
>
|
|
||||||
Change password
|
Change password
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@ -213,3 +161,11 @@ 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.';
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,7 @@
|
|||||||
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 {
|
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, ApartmentOutlined } from '@ant-design/icons';
|
||||||
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,
|
||||||
@ -19,7 +13,6 @@ 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';
|
||||||
@ -50,19 +43,18 @@ export function UsersPage() {
|
|||||||
setDrawerError(null);
|
setDrawerError(null);
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => setDrawerError(extractApiError(err)),
|
onError: (err: unknown) => setDrawerError(errorMessage(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMut = useMutation({
|
const updateMut = useMutation({
|
||||||
mutationFn: ({ id, payload }: { id: string; payload: UpdateUserPayload }) =>
|
mutationFn: ({ id, payload }: { id: string; payload: UpdateUserPayload }) => updateUser(id, payload),
|
||||||
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(extractApiError(err)),
|
onError: (err: unknown) => setDrawerError(errorMessage(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMut = useMutation({
|
const deleteMut = useMutation({
|
||||||
@ -71,7 +63,7 @@ export function UsersPage() {
|
|||||||
message.success('User deleted');
|
message.success('User deleted');
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => message.error(extractApiError(err)),
|
onError: (err: unknown) => message.error(errorMessage(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetMut = useMutation({
|
const resetMut = useMutation({
|
||||||
@ -82,7 +74,7 @@ export function UsersPage() {
|
|||||||
setResetTarget(null);
|
setResetTarget(null);
|
||||||
setResetValue('');
|
setResetValue('');
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => message.error(extractApiError(err)),
|
onError: (err: unknown) => message.error(errorMessage(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (values: CreateUserPayload | UpdateUserPayload) => {
|
const handleSubmit = async (values: CreateUserPayload | UpdateUserPayload) => {
|
||||||
@ -102,22 +94,15 @@ 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) =>
|
render: (active: boolean) => active ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
|
||||||
active ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Created',
|
title: 'Created',
|
||||||
@ -133,25 +118,23 @@ export function UsersPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
onClick={() => {
|
onClick={() => { setDrawerError(null); setDrawerMode({ kind: 'edit', user }); }}
|
||||||
setDrawerError(null);
|
|
||||||
setDrawerMode({ kind: 'edit', user });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
{isAdminMode && user.roles.includes('RestrictedAdmin') && (
|
{isAdminMode && user.roles.includes('RestrictedAdmin') && (
|
||||||
<Button size="small" icon={<ApartmentOutlined />} onClick={() => setAccessTarget(user)}>
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<ApartmentOutlined />}
|
||||||
|
onClick={() => setAccessTarget(user)}
|
||||||
|
>
|
||||||
Customer access
|
Customer access
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<KeyOutlined />}
|
icon={<KeyOutlined />}
|
||||||
onClick={() => {
|
onClick={() => { setResetValue(''); setResetTarget(user); }}
|
||||||
setResetValue('');
|
|
||||||
setResetTarget(user);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Reset password
|
Reset password
|
||||||
</Button>
|
</Button>
|
||||||
@ -161,9 +144,7 @@ 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 />}>
|
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@ -177,10 +158,7 @@ export function UsersPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => {
|
onClick={() => { setDrawerError(null); setDrawerMode({ kind: 'create' }); }}
|
||||||
setDrawerError(null);
|
|
||||||
setDrawerMode({ kind: 'create' });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
New user
|
New user
|
||||||
</Button>
|
</Button>
|
||||||
@ -199,10 +177,7 @@ export function UsersPage() {
|
|||||||
mode={drawerMode}
|
mode={drawerMode}
|
||||||
submitting={createMut.isPending || updateMut.isPending}
|
submitting={createMut.isPending || updateMut.isPending}
|
||||||
error={drawerError}
|
error={drawerError}
|
||||||
onClose={() => {
|
onClose={() => { setDrawerMode(null); setDrawerError(null); }}
|
||||||
setDrawerMode(null);
|
|
||||||
setDrawerError(null);
|
|
||||||
}}
|
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -210,10 +185,7 @@ 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={() => {
|
onCancel={() => { setResetTarget(null); setResetValue(''); }}
|
||||||
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 }}
|
||||||
@ -234,3 +206,12 @@ 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.';
|
||||||
|
}
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
|
||||||
import { afterEach, vi } from 'vitest';
|
|
||||||
import { cleanup } from '@testing-library/react';
|
|
||||||
|
|
||||||
// React Testing Library auto-cleans between tests; we still want to reset
|
|
||||||
// any spies/mocks per test for predictable behaviour.
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// AntD's responsive observer uses matchMedia which jsdom doesn't implement.
|
|
||||||
// Stub with a plain function (not vi.fn()) so vi.restoreAllMocks() in afterEach
|
|
||||||
// doesn't strip its implementation between tests.
|
|
||||||
const noop = () => undefined;
|
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
|
||||||
writable: true,
|
|
||||||
value: (query: string) => ({
|
|
||||||
matches: false,
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addListener: noop, // legacy
|
|
||||||
removeListener: noop, // legacy
|
|
||||||
addEventListener: noop,
|
|
||||||
removeEventListener: noop,
|
|
||||||
dispatchEvent: () => false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ResizeObserver is used by AntD Layout — also missing in jsdom.
|
|
||||||
class MockResizeObserver {
|
|
||||||
observe() {}
|
|
||||||
unobserve() {}
|
|
||||||
disconnect() {}
|
|
||||||
}
|
|
||||||
(globalThis as unknown as { ResizeObserver: typeof MockResizeObserver }).ResizeObserver =
|
|
||||||
MockResizeObserver;
|
|
||||||
@ -1,7 +1,4 @@
|
|||||||
// defineConfig from vitest/config is a superset of vite's — it accepts the
|
import { defineConfig } from 'vite';
|
||||||
// `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({
|
||||||
@ -23,12 +20,4 @@ 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.
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user