diff --git a/.env b/.env
index 353c7e2..017179e 100644
--- a/.env
+++ b/.env
@@ -1,2 +1 @@
-VITE_DASHBOARD_API_URL=https://sr-backend-api.takeleap.in
VITE_AUDIT_API_URL=http://localhost:7514
diff --git a/.env.production b/.env.production
index ab2941c..6d0c216 100644
--- a/.env.production
+++ b/.env.production
@@ -1,4 +1,2 @@
-# VITE_DASHBOARD_API_URL=https://python.seekright.com
# VITE_AUDIT_API_URL=https://node-audit.seekright.com
-VITE_DASHBOARD_API_URL=https://sr-backend-api.takeleap.in
VITE_AUDIT_API_URL=https://psb7wrm5-7514.inc1.devtunnels.ms
diff --git a/src/App.tsx b/src/App.tsx
index 1ec5d0b..dc73656 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -42,8 +42,8 @@ const theme = createTheme({
import { Login } from './pages/account/Login';
import { Dashboard } from './pages/dashboard/Dashboard';
import { GlobalFeed } from './pages/activity-feeds/GlobalFeed';
-import { ClientsOverview } from './pages/clients/ClientsOverview';
import { AuditSession } from './pages/audit-session/AuditSession';
+import { AdminConsole } from './pages/admin/AdminConsole';
import { ToastProvider } from './components/common/ToastProvider';
export default function App() {
@@ -63,7 +63,9 @@ export default function App() {
} />
} />
} />
- } />
+ }>
+ } />
+
diff --git a/src/api/accountService.ts b/src/api/accountService.ts
index c15cc57..0c8e3d0 100644
--- a/src/api/accountService.ts
+++ b/src/api/accountService.ts
@@ -1,15 +1,40 @@
import { axiosClient } from './axiosClient';
+interface LoginResponse {
+ access_token: string;
+ refresh_token: string;
+ expires_in: string;
+ user: {
+ user_id: number;
+ username: string;
+ email: string;
+ name: string;
+ org_id: number;
+ db_name: string;
+ role_id: number;
+ role_name: string;
+ };
+}
+
+interface RefreshResponse {
+ access_token: string;
+ refresh_token: string;
+ expires_in: string;
+}
+
export const accountService = {
login: async (username: string, password: string) => {
- // Replicating the environment.origin behavior if needed, currently hardcoded
- const body = {
- username,
- password,
- origin: "AUDITOR_REMOTE",
- };
- const response = await axiosClient.post('/api/dashboard/Master/login', body);
- return (response as any).responseData?.[0]?.records?.[0]; // matching the Angular extraction logic
+ const response = await axiosClient.post('/api/audit/account/login', { username, password });
+ return response as unknown as LoginResponse;
+ },
+
+ refresh: async (refresh_token: string) => {
+ const response = await axiosClient.post('/api/audit/account/refresh', { refresh_token });
+ return response as unknown as RefreshResponse;
+ },
+
+ logout: async (refresh_token: string) => {
+ await axiosClient.post('/api/audit/account/logout', { refresh_token });
},
getOrganizations: async () => {
diff --git a/src/api/adminService.ts b/src/api/adminService.ts
new file mode 100644
index 0000000..f324555
--- /dev/null
+++ b/src/api/adminService.ts
@@ -0,0 +1,254 @@
+import { axiosClient } from './axiosClient';
+
+export interface AdminUser {
+ user_id: number;
+ username: string;
+ email: string;
+ name: string;
+ org_id: number | null;
+ db_name: string;
+ role_id: number;
+ role_name: string;
+ disabled: boolean;
+ last_login: string | null;
+ created_on: string;
+}
+
+export interface Role {
+ id: number;
+ role_name: string;
+}
+
+export interface UserSession {
+ id: number;
+ user_id: number;
+ username: string;
+ login_at: string;
+ logout_at: string | null;
+ ip_address: string | null;
+ user_agent: string | null;
+}
+
+export interface CreateUserPayload {
+ username: string;
+ email: string;
+ name: string;
+ password: string;
+ role_id: number;
+ org_id: number;
+ db_name: string;
+}
+
+export interface UpdateUserPayload {
+ username?: string;
+ role_id?: number;
+ disabled?: boolean;
+}
+
+export interface AllocationRecord {
+ id: number;
+ Asset: string | null;
+ Plaza: string | null;
+ Chainage: string | null;
+ video_date: string | null;
+ assigned_user_id: number | null;
+ assigned_username: string | null;
+}
+
+export interface AllocationRecordsResult {
+ records: AllocationRecord[];
+ total: number;
+}
+
+interface AllocationFilters {
+ dateFrom?: string;
+ dateTo?: string;
+ assetId?: number | string;
+ siteId?: number | string;
+}
+
+export interface AuditHistoryUserCount {
+ user_id: number;
+ username: string;
+ assigned: number;
+ completed: number;
+ // null when nothing was assigned in this window (they may still have
+ // completed backlog items - that's not "0% efficient").
+ efficiency: number | null;
+}
+
+export interface AuditHistorySummary {
+ total: number;
+ audited: number;
+ notAudited: number;
+ byUser: AuditHistoryUserCount[];
+}
+
+export interface AuditHistoryRecord {
+ id: number;
+ Asset: string | null;
+ Plaza: string | null;
+ Chainage: string | null;
+ video_date: string | null;
+ IsAudited: number;
+ Audited_on: string | null;
+ audited_user_id: number | null;
+ audited_username: string | null;
+}
+
+export interface AuditHistoryRecordsResult {
+ records: AuditHistoryRecord[];
+ total: number;
+}
+
+export interface DevTicket {
+ id: number;
+ anomaly_id: number;
+ org_db_name: string | null;
+ asset: string | null;
+ plaza: string | null;
+ dev_handle: string;
+ git_username: string | null;
+ reporter: string | null;
+ message_text: string;
+ channel: string;
+ duplicate_of_id: number | null;
+ rocketchat_sent: boolean;
+ rocketchat_error: string | null;
+ rocketchat_sent_at: string | null;
+ git_status: 'pending' | 'success' | 'failed';
+ git_issue_url: string | null;
+ git_issue_number: number | null;
+ git_error: string | null;
+ retry_count: number;
+ last_retry_at: string | null;
+ created_at: string;
+}
+
+export interface DevTicketsResult {
+ records: DevTicket[];
+ total: number;
+}
+
+export interface ClientOverviewSite {
+ site_id: number;
+ site_name: string;
+ total: number;
+ audited: number;
+ deleted: number;
+ nonAudited: number;
+}
+
+export interface ClientOverviewOrg {
+ org_id: number;
+ org_name: string;
+ db_name: string;
+ total: number;
+ audited: number;
+ deleted: number;
+ nonAudited: number;
+ sites: ClientOverviewSite[];
+ error?: string;
+}
+
+export interface ClientsOverviewResult {
+ type: 'audits' | 'rectification';
+ clients: ClientOverviewOrg[];
+}
+
+export const adminService = {
+ getUsers: async (): Promise => {
+ return (await axiosClient.get('/api/audit/admin/users')) as unknown as AdminUser[];
+ },
+
+ getRoles: async (): Promise => {
+ return (await axiosClient.get('/api/audit/admin/roles')) as unknown as Role[];
+ },
+
+ createUser: async (payload: CreateUserPayload): Promise<{ user_id: number }> => {
+ return (await axiosClient.post('/api/audit/admin/users', payload)) as unknown as { user_id: number };
+ },
+
+ updateUser: async (userId: number, payload: UpdateUserPayload): Promise => {
+ await axiosClient.patch(`/api/audit/admin/users/${userId}`, payload);
+ },
+
+ getSessions: async (params?: { username?: string; limit?: number; offset?: number }): Promise => {
+ return (await axiosClient.get('/api/audit/admin/sessions', { params })) as unknown as UserSession[];
+ },
+
+ getAllocationRecords: async (params: {
+ dbName: string;
+ type: 'audits' | 'rectification';
+ sort: 'oldest' | 'newest';
+ limit?: number;
+ offset?: number;
+ } & AllocationFilters): Promise => {
+ return (await axiosClient.get('/api/audit/admin/allocation/records', { params })) as unknown as AllocationRecordsResult;
+ },
+
+ assignAllocation: async (payload: {
+ dbName: string;
+ type: 'audits' | 'rectification';
+ ids?: number[];
+ count?: number;
+ sort?: 'oldest' | 'newest';
+ user_id: number;
+ } & AllocationFilters): Promise<{ assigned: number }> => {
+ return (await axiosClient.post('/api/audit/admin/allocation/assign', payload)) as unknown as { assigned: number };
+ },
+
+ getAuditHistorySummary: async (params: {
+ dbName: string;
+ type: 'audits' | 'rectification';
+ from?: string;
+ to?: string;
+ }): Promise => {
+ return (await axiosClient.get('/api/audit/admin/audit-history/summary', { params })) as unknown as AuditHistorySummary;
+ },
+
+ getAuditHistoryUserRecords: async (params: {
+ dbName: string;
+ type: 'audits' | 'rectification';
+ userId: number;
+ from?: string;
+ to?: string;
+ limit?: number;
+ offset?: number;
+ }): Promise => {
+ return (await axiosClient.get('/api/audit/admin/audit-history/records', { params })) as unknown as AuditHistoryRecordsResult;
+ },
+
+ getDevTickets: async (params: {
+ search?: string;
+ gitStatus?: 'pending' | 'success' | 'failed';
+ dateFrom?: string;
+ dateTo?: string;
+ limit?: number;
+ offset?: number;
+ }): Promise => {
+ return (await axiosClient.get('/api/audit/admin/dev-tickets', { params })) as unknown as DevTicketsResult;
+ },
+
+ // Keyed by dev_ticket id (not Gitea issue number) - the backend resolves
+ // each ticket's own stored git_issue_url, so this is correct even for
+ // tickets filed before a repo migration.
+ getIssueStates: async (ticketIds: number[]): Promise> => {
+ const result = (await axiosClient.get('/api/audit/admin/dev-tickets/issue-states', {
+ params: { ticketIds: ticketIds.join(',') },
+ })) as unknown as { states: Record };
+ return result.states;
+ },
+
+ getClientsOverview: async (params: {
+ type: 'audits' | 'rectification';
+ dateFrom?: string;
+ dateTo?: string;
+ assetIds?: number[];
+ }): Promise => {
+ const { assetIds, ...rest } = params;
+ return (await axiosClient.get('/api/audit/admin/clients-overview', {
+ params: { ...rest, assetIds: assetIds && assetIds.length > 0 ? assetIds.join(',') : undefined },
+ })) as unknown as ClientsOverviewResult;
+ },
+};
diff --git a/src/api/axiosClient.ts b/src/api/axiosClient.ts
index 9e0a11a..6deec0f 100644
--- a/src/api/axiosClient.ts
+++ b/src/api/axiosClient.ts
@@ -26,10 +26,7 @@ axiosClient.interceptors.request.use(
config.params = { ...config.params, dbName: user.db_name };
}
- if (config.url?.startsWith('/api/dashboard')) {
- const dashboardUrl = import.meta.env.VITE_DASHBOARD_API_URL || 'https://sr-backend-api.takeleap.in';
- config.url = config.url.replace('/api/dashboard', dashboardUrl);
- } else if (config.url?.startsWith('/api/audit')) {
+ if (config.url?.startsWith('/api/audit')) {
const auditUrl = import.meta.env.VITE_AUDIT_API_URL || 'https://sr-audit.takeleap.in';
config.url = config.url.replace('/api/audit', auditUrl);
}
@@ -39,6 +36,22 @@ axiosClient.interceptors.request.use(
(error) => Promise.reject(error)
);
+// Coalesces concurrent 401s behind a single in-flight refresh call. Required
+// (not just an optimization): refresh tokens rotate on every use, so two
+// simultaneous refresh calls would race and the second would fail against an
+// already-consumed token.
+let isRefreshing = false;
+let refreshSubscribers: Array<(token: string) => void> = [];
+
+const subscribeTokenRefresh = (cb: (token: string) => void) => {
+ refreshSubscribers.push(cb);
+};
+
+const onRefreshed = (token: string) => {
+ refreshSubscribers.forEach((cb) => cb(token));
+ refreshSubscribers = [];
+};
+
// Response Interceptor: Global Error Handling (like Angular's HttpService)
axiosClient.interceptors.response.use(
(response) => response.data,
@@ -49,6 +62,59 @@ axiosClient.interceptors.response.use(
}
const { status, data } = error.response;
+ const originalRequest = error.config;
+
+ const isRefreshCall = originalRequest?.url?.includes('/account/refresh');
+ const isLoginCall = originalRequest?.url?.includes('/account/login');
+
+ // A 401 from login itself just means "wrong credentials" - let the caller
+ // (Login.tsx) handle it directly. It must not trigger the
+ // already-logged-in-session-expired logic below (which force-redirects
+ // to /login), since there's no session to expire yet.
+ if (isLoginCall) {
+ return Promise.reject(error);
+ }
+
+ if (status === 401 && !isRefreshCall && !originalRequest?._retry) {
+ const user = useAuthStore.getState().user;
+
+ if (!user?.refresh_token) {
+ useAuthStore.getState().logout();
+ window.location.href = '/login';
+ return Promise.reject(error);
+ }
+
+ originalRequest._retry = true;
+
+ if (isRefreshing) {
+ return new Promise((resolve) => {
+ subscribeTokenRefresh((newToken: string) => {
+ originalRequest.headers['x-access-token'] = newToken;
+ resolve(axiosClient(originalRequest));
+ });
+ });
+ }
+
+ isRefreshing = true;
+
+ return axiosClient
+ .post('/api/audit/account/refresh', { refresh_token: user.refresh_token })
+ .then((refreshResponse: any) => {
+ const { access_token, refresh_token } = refreshResponse;
+ useAuthStore.setState({ user: { ...user, access_token, refresh_token } });
+ isRefreshing = false;
+ onRefreshed(access_token);
+ originalRequest.headers['x-access-token'] = access_token;
+ return axiosClient(originalRequest);
+ })
+ .catch((refreshError) => {
+ isRefreshing = false;
+ refreshSubscribers = [];
+ useAuthStore.getState().logout();
+ window.location.href = '/login';
+ return Promise.reject(refreshError);
+ });
+ }
if (status === 400) {
const message = data?.responseStatus?.responseMessage || data?.errorMessage || 'Something went wrong. Please try again.';
diff --git a/src/components/common/AssetMultiSelect.tsx b/src/components/common/AssetMultiSelect.tsx
new file mode 100644
index 0000000..f083ad0
--- /dev/null
+++ b/src/components/common/AssetMultiSelect.tsx
@@ -0,0 +1,219 @@
+import React, { useState, useMemo, useCallback, memo } from 'react';
+import {
+ Box, Button, Popover, TextField, Checkbox, FormControlLabel, Typography,
+ InputAdornment, Divider,
+} from '@mui/material';
+import SearchIcon from '@mui/icons-material/Search';
+import { useFeedStore } from '../../store/feedStore';
+
+interface AssetMultiSelectProps {
+ value: number[];
+ onChange: (ids: number[]) => void;
+}
+
+// Mirrors the grouped asset picker in FeedFilters.tsx (search + per-category
+// select-all + counts) as a standalone controlled component, so it can be
+// dropped into an inline filter bar instead of a full-page dialog.
+const AssetRow = memo(({
+ assetId, assetName, checked, onToggle,
+}: {
+ assetId: number;
+ assetName: string;
+ checked: boolean;
+ onToggle: (id: number) => void;
+}) => (
+ onToggle(assetId)}
+ sx={{ color: '#475569', '&.Mui-checked': { color: '#3b82f6' }, py: 0.3 }}
+ />
+ }
+ label={{assetName}}
+ />
+));
+
+const GroupHeader = memo(({
+ typeName, assetCount, allGroupSelected, someGroupSelected, onToggle,
+}: {
+ typeName: string;
+ assetCount: number;
+ allGroupSelected: boolean;
+ someGroupSelected: boolean;
+ onToggle: () => void;
+}) => (
+
+ }
+ label={
+
+ {typeName} ({assetCount})
+
+ }
+ sx={{ mb: 0.5 }}
+ />
+));
+
+export const AssetMultiSelect: React.FC = ({ value, onChange }) => {
+ const { assetTypes } = useFeedStore();
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [draftSet, setDraftSet] = useState>(() => new Set(value));
+ const [search, setSearch] = useState('');
+
+ const open = Boolean(anchorEl);
+
+ const handleOpen = (e: React.MouseEvent) => {
+ setDraftSet(new Set(value));
+ setSearch('');
+ setAnchorEl(e.currentTarget);
+ };
+
+ const commitAndClose = () => {
+ onChange(Array.from(draftSet));
+ setAnchorEl(null);
+ };
+
+ const allAssetIds = useMemo(
+ () => assetTypes.flatMap((t: any) => t.assets.map((a: any) => a.asset_id as number)),
+ [assetTypes],
+ );
+
+ const filteredAssetTypes = useMemo(() => {
+ if (!search.trim()) return assetTypes;
+ const q = search.toLowerCase();
+ return assetTypes
+ .map((type: any) => ({
+ ...type,
+ assets: type.assets.filter((a: any) => a.asset_name.toLowerCase().includes(q)),
+ }))
+ .filter((type: any) => type.assets.length > 0);
+ }, [assetTypes, search]);
+
+ const allSelected = allAssetIds.length > 0 && allAssetIds.every((id) => draftSet.has(id));
+ const someSelected = draftSet.size > 0 && !allSelected;
+
+ const handleToggleAsset = useCallback((assetId: number) => {
+ setDraftSet((prev) => {
+ const next = new Set(prev);
+ if (next.has(assetId)) next.delete(assetId); else next.add(assetId);
+ return next;
+ });
+ }, []);
+
+ const handleToggleAll = useCallback(() => {
+ setDraftSet((prev) => (prev.size === allAssetIds.length ? new Set() : new Set(allAssetIds)));
+ }, [allAssetIds]);
+
+ const makeGroupToggle = useCallback((groupIds: number[]) => () => {
+ setDraftSet((prev) => {
+ const allIn = groupIds.every((id) => prev.has(id));
+ const next = new Set(prev);
+ if (allIn) groupIds.forEach((id) => next.delete(id));
+ else groupIds.forEach((id) => next.add(id));
+ return next;
+ });
+ }, []);
+
+ return (
+ <>
+
+
+
+ setSearch(e.target.value)}
+ sx={{ mb: 1.5, bgcolor: 'rgba(255,255,255,0.05)', borderRadius: 1, input: { color: '#f8fafc' }, '& fieldset': { borderColor: '#334155' } }}
+ slotProps={{ input: { startAdornment: } }}
+ />
+
+
+ }
+ label={Select All ({allAssetIds.length})}
+ />
+
+
+
+
+ {filteredAssetTypes.length === 0 ? (
+ No assets match your search.
+ ) : (
+ filteredAssetTypes.map((type: any) => {
+ const groupIds: number[] = type.assets.map((a: any) => a.asset_id);
+ const allGroupSelected = groupIds.every((id) => draftSet.has(id));
+ const someGroupSelected = groupIds.some((id) => draftSet.has(id)) && !allGroupSelected;
+
+ return (
+
+
+
+ {type.assets.map((asset: any) => (
+
+ ))}
+
+
+ );
+ })
+ )}
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index 04643dc..d7cf849 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -4,13 +4,14 @@ import MenuIcon from '@mui/icons-material/Menu';
import AccountCircle from '@mui/icons-material/AccountCircle';
import { useAuthStore } from '../../store/authStore';
import { useNavigate, useLocation } from 'react-router-dom';
+import { accountService } from '../../api/accountService';
interface HeaderProps {
toggleSidebar?: () => void;
}
export const Header: React.FC = ({ toggleSidebar }) => {
- const { user, logout } = useAuthStore();
+ const { user, logout, hasRole } = useAuthStore();
const navigate = useNavigate();
const location = useLocation();
const [anchorEl, setAnchorEl] = useState(null);
@@ -25,8 +26,20 @@ export const Header: React.FC = ({ toggleSidebar }) => {
setAnchorEl(null);
};
- const handleLogout = () => {
+ const handleLogout = async () => {
handleClose();
+ const refreshToken = user?.refresh_token;
+ // Best-effort: tell the server the session ended (clears the refresh
+ // token and sets user_sessions.logout_at) before clearing local state.
+ // If this fails (network blip, already-expired token), still log out
+ // locally rather than trapping the user in a broken session.
+ if (refreshToken) {
+ try {
+ await accountService.logout(refreshToken);
+ } catch (err) {
+ console.error('Failed to notify server of logout', err);
+ }
+ }
logout();
navigate('/login', { replace: true });
};
@@ -78,19 +91,21 @@ export const Header: React.FC = ({ toggleSidebar }) => {
>
Activity Feeds
-
+ {hasRole('ADMIN') && (
+
+ )}
diff --git a/src/pages/account/Login.tsx b/src/pages/account/Login.tsx
index 8a7975d..d5a6cbf 100644
--- a/src/pages/account/Login.tsx
+++ b/src/pages/account/Login.tsx
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
import { accountService } from '../../api/accountService';
import { DbSelectionDialog } from './DbSelectionDialog';
+import { useToastStore } from '../../store/toastStore';
export const Login: React.FC = () => {
const [username, setUsername] = useState('');
@@ -11,8 +12,8 @@ export const Login: React.FC = () => {
const [isInvalid, setIsInvalid] = useState(false);
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
- const [tempUserData, setTempUserData] = useState(null);
-
+ const [tempLoginResult, setTempLoginResult] = useState> | null>(null);
+
const { login, isAuthenticated } = useAuthStore();
const navigate = useNavigate();
@@ -26,53 +27,44 @@ export const Login: React.FC = () => {
const handleLogin = async () => {
setIsInvalid(false);
setLoading(true);
-
+
try {
- const userData = await accountService.login(username, password);
-
- if (!userData) {
+ const result = await accountService.login(username, password);
+
+ if (!result?.user) {
setIsInvalid(true);
+ useToastStore.getState().showToast('Invalid username or password.', 'error');
setLoading(false);
return;
}
- const roles = userData.user_roles ? userData.user_roles.toLowerCase().split(',') : [];
-
- if (roles.includes('sr_auditor')) {
- // Open DB Selection Dialog
- setTempUserData(userData);
- setDialogOpen(true);
- } else if (roles.includes('auditor')) {
- // Direct login
- login(userData);
- navigate('/activity-feeds', { replace: true });
- } else if (roles.includes('video_uploader')) {
- // Direct login for dashboard
- login(userData);
- navigate('/activity-feeds', { replace: true });
- } else {
- alert("Your account is not configured to use this website. Please contact your admin for more info.");
- }
+ // Every user picks which org/DB to work in after login.
+ setTempLoginResult(result);
+ setDialogOpen(true);
} catch (error) {
setIsInvalid(true);
+ useToastStore.getState().showToast('Invalid username or password.', 'error');
} finally {
setLoading(false);
}
};
- const handleDbSelection = (selectedOrg?: any) => {
+ const handleDbSelection = (selectedOrg?: { org_id: string; db_name: string; org_name?: string }) => {
setDialogOpen(false);
- if (selectedOrg && tempUserData) {
- // Merge selected org details into user object
- const finalUserData = {
- ...tempUserData,
+ if (selectedOrg && tempLoginResult) {
+ login({
+ access_token: tempLoginResult.access_token,
+ refresh_token: tempLoginResult.refresh_token,
+ user_id: tempLoginResult.user.user_id,
+ username: tempLoginResult.user.username,
+ email: tempLoginResult.user.email,
+ role: tempLoginResult.user.role_name,
org_id: selectedOrg.org_id,
db_name: selectedOrg.db_name,
- selected_org_name: selectedOrg.org_name
- };
- login(finalUserData);
+ });
navigate('/activity-feeds', { replace: true });
}
+ setTempLoginResult(null);
};
return (
@@ -206,7 +198,6 @@ export const Login: React.FC = () => {
- {/* Database Selection Dialog for SR_AUDITOR */}
);
diff --git a/src/pages/admin/AdminConsole.tsx b/src/pages/admin/AdminConsole.tsx
new file mode 100644
index 0000000..42c8ca3
--- /dev/null
+++ b/src/pages/admin/AdminConsole.tsx
@@ -0,0 +1,37 @@
+import React, { useState } from 'react';
+import { Box, Tabs, Tab } from '@mui/material';
+import { ClientsOverview } from '../clients/ClientsOverview';
+import { UserManagement } from './UserManagement';
+import { LoginHistory } from './LoginHistory';
+import { Allocation } from './Allocation';
+import { AuditHistory } from './AuditHistory';
+import { DevTickets } from './DevTickets';
+
+export const AdminConsole: React.FC = () => {
+ const [tab, setTab] = useState(0);
+
+ return (
+ // MainLayout's is height:100vh + overflow:hidden (by design, so other
+ // pages can own their own internal scroll regions - see AnomalyTable.tsx/
+ // GlobalFeed.tsx for the same pattern). This tab's content (filters + large
+ // tables) can exceed the viewport, so it needs its own bounded, scrollable
+ // container rather than relying on the page ever growing taller than 100vh.
+
+ setTab(value)} sx={{ mb: 3 }}>
+
+
+
+
+
+
+
+
+ {tab === 0 && }
+ {tab === 1 && }
+ {tab === 2 && }
+ {tab === 3 && }
+ {tab === 4 && }
+ {tab === 5 && }
+
+ );
+};
diff --git a/src/pages/admin/Allocation.tsx b/src/pages/admin/Allocation.tsx
new file mode 100644
index 0000000..af3c428
--- /dev/null
+++ b/src/pages/admin/Allocation.tsx
@@ -0,0 +1,384 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import {
+ Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
+ Paper, Checkbox, FormControl, InputLabel, Select, MenuItem, ToggleButtonGroup,
+ ToggleButton, Button, CircularProgress, TablePagination, Typography, Divider,
+ Dialog, DialogTitle, DialogContent, DialogActions, DialogContentText, TextField,
+} from '@mui/material';
+import { adminService } from '../../api/adminService';
+import type { AllocationRecord, AdminUser } from '../../api/adminService';
+import { accountService } from '../../api/accountService';
+import { activityFeedsService } from '../../api/activityFeedsService';
+import { useFeedStore } from '../../store/feedStore';
+import { useToastStore } from '../../store/toastStore';
+
+interface Organization {
+ org_id: string;
+ org_name: string;
+ db_name: string;
+}
+
+interface SiteOption {
+ site_id: string;
+ site_name: string;
+}
+
+// Mirrors the site-parsing logic in ClientsOverview.tsx/MainLayout.tsx —
+// /Master/site's response shape varies (responseData[0].records, nested
+// sub_sites, or a plain array).
+const parseSites = (sitesRes: any): SiteOption[] => {
+ let parsedSites: any[] = [];
+ if (sitesRes?.responseData?.[0]?.records) {
+ parsedSites = sitesRes.responseData[0].records;
+ if (parsedSites.length === 1 && Array.isArray(parsedSites[0])) {
+ parsedSites = parsedSites[0];
+ } else if (parsedSites.length > 0 && Array.isArray(parsedSites[0])) {
+ parsedSites = parsedSites.flat();
+ }
+ } else if (Array.isArray(sitesRes)) {
+ parsedSites = sitesRes;
+ } else if (sitesRes?.data && Array.isArray(sitesRes.data)) {
+ parsedSites = sitesRes.data;
+ }
+
+ return parsedSites
+ .map((site) => ({
+ site_id: site.site_id || (site.sub_sites || []).map((s: any) => s.site_id).filter(Boolean).join(','),
+ site_name: site.site_name || site.name || site.siteName || site.site_id,
+ }))
+ .filter((s) => s.site_id);
+};
+
+const PAGE_SIZE = 25;
+const BULK_PRESETS = [1000, 2000, 5000, 10000];
+
+export const Allocation: React.FC = () => {
+ const { assets } = useFeedStore();
+ const [organizations, setOrganizations] = useState([]);
+ const [dbName, setDbName] = useState('');
+ const [type, setType] = useState<'audits' | 'rectification'>('audits');
+ const [sort, setSort] = useState<'oldest' | 'newest'>('oldest');
+
+ const [dateFrom, setDateFrom] = useState('');
+ const [dateTo, setDateTo] = useState('');
+ const [assetId, setAssetId] = useState('');
+ const [siteId, setSiteId] = useState('');
+ const [sites, setSites] = useState([]);
+
+ const [users, setUsers] = useState([]);
+ const [selectedUserId, setSelectedUserId] = useState('');
+
+ const [records, setRecords] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(0);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [loading, setLoading] = useState(false);
+ const [assigning, setAssigning] = useState(false);
+ const [bulkConfirmCount, setBulkConfirmCount] = useState(null);
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const [orgsRes, usersRes] = await Promise.all([
+ accountService.getOrganizations(),
+ adminService.getUsers(),
+ ]);
+ const orgs = (orgsRes as any) || [];
+ setOrganizations(orgs);
+ setUsers(usersRes.filter((u) => !u.disabled));
+ if (orgs.length > 0) setDbName(orgs[0].db_name);
+ } catch (err) {
+ useToastStore.getState().showToast('Failed to load organizations/users.', 'error');
+ }
+ })();
+ }, []);
+
+ // Sites are org-scoped, and the org being managed here may differ from the
+ // admin's own logged-in org, so this refetches whenever the selection changes
+ // rather than reusing the global feedStore (which is scoped to the admin's own org).
+ useEffect(() => {
+ const org = organizations.find((o) => o.db_name === dbName);
+ if (!org) return;
+ (async () => {
+ try {
+ const sitesRes = await activityFeedsService.getOrganizationSites(org.org_id);
+ setSites(parseSites(sitesRes));
+ } catch (err) {
+ useToastStore.getState().showToast('Failed to load sites for this organization.', 'error');
+ }
+ })();
+ setSiteId('');
+ }, [dbName, organizations]);
+
+ // dateTo is a bare date (e.g. "2026-07-09"); video_date is a full timestamp,
+ // so comparing "<= 2026-07-09" would exclude same-day records after
+ // midnight - extend it to end-of-day. Also normalize the order: both
+ // fields are independent and unset by default, so it's easy to end up with
+ // From after To while filling them in, which would silently match zero
+ // rows instead of erroring.
+ const [effectiveDateFrom, effectiveDateTo] = (() => {
+ if (!dateFrom || !dateTo) return [dateFrom || undefined, dateTo ? `${dateTo}T23:59:59` : undefined];
+ return dateFrom <= dateTo
+ ? [dateFrom, `${dateTo}T23:59:59`]
+ : [dateTo, `${dateFrom}T23:59:59`];
+ })();
+
+ const load = useCallback(async () => {
+ if (!dbName) return;
+ setLoading(true);
+ try {
+ const result = await adminService.getAllocationRecords({
+ dbName, type, sort, limit: PAGE_SIZE, offset: page * PAGE_SIZE,
+ dateFrom: effectiveDateFrom,
+ dateTo: effectiveDateTo,
+ assetId: assetId || undefined,
+ siteId: siteId || undefined,
+ });
+ setRecords(result.records);
+ setTotal(result.total);
+ } catch (err) {
+ useToastStore.getState().showToast('Failed to load records.', 'error');
+ } finally {
+ setLoading(false);
+ }
+ }, [dbName, type, sort, page, effectiveDateFrom, effectiveDateTo, assetId, siteId]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ useEffect(() => {
+ setPage(0);
+ setSelectedIds(new Set());
+ }, [dbName, type, sort, dateFrom, dateTo, assetId, siteId]);
+
+ const toggleRow = (id: number) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const toggleAll = () => {
+ if (selectedIds.size === records.length) {
+ setSelectedIds(new Set());
+ } else {
+ setSelectedIds(new Set(records.map((r) => r.id)));
+ }
+ };
+
+ const handleAllocate = async () => {
+ if (selectedIds.size === 0 || !selectedUserId) return;
+ setAssigning(true);
+ try {
+ const result = await adminService.assignAllocation({
+ dbName, type, ids: Array.from(selectedIds), user_id: Number(selectedUserId),
+ });
+ useToastStore.getState().showToast(`Allocated ${result.assigned} record(s).`, 'success');
+ setSelectedIds(new Set());
+ load();
+ } catch (err) {
+ useToastStore.getState().showToast('Failed to allocate records.', 'error');
+ } finally {
+ setAssigning(false);
+ }
+ };
+
+ const handleBulkAllocate = async (count: number) => {
+ if (!selectedUserId) return;
+ setBulkConfirmCount(null);
+ setAssigning(true);
+ try {
+ const result = await adminService.assignAllocation({
+ dbName, type, count, sort, user_id: Number(selectedUserId),
+ dateFrom: effectiveDateFrom,
+ dateTo: effectiveDateTo,
+ assetId: assetId || undefined,
+ siteId: siteId || undefined,
+ });
+ useToastStore.getState().showToast(`Allocated ${result.assigned} record(s).`, 'success');
+ setSelectedIds(new Set());
+ setPage(0);
+ load();
+ } catch (err) {
+ useToastStore.getState().showToast('Failed to bulk-allocate records.', 'error');
+ } finally {
+ setAssigning(false);
+ }
+ };
+
+ const selectedUser = users.find((u) => String(u.user_id) === selectedUserId);
+ const selectedOrg = organizations.find((o) => o.db_name === dbName);
+
+ return (
+
+
+
+ Organization
+
+
+
+ value && setType(value)}
+ size="small"
+ >
+ Audits
+ Rectification
+
+
+ value && setSort(value)}
+ size="small"
+ >
+ Oldest first
+ Newest first
+
+
+
+
+ setDateFrom(e.target.value)}
+ slotProps={{ inputLabel: { shrink: true } }}
+ />
+ setDateTo(e.target.value)}
+ slotProps={{ inputLabel: { shrink: true } }}
+ />
+
+ Asset
+
+
+
+ Site
+
+
+
+
+
+ Allocate to
+
+
+
+
+ {selectedIds.size} selected on this page
+
+
+
+
+ Quick bulk assign (matches current filters):
+ {BULK_PRESETS.map((count) => (
+
+ ))}
+
+
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+
+ 0 && selectedIds.size === records.length}
+ indeterminate={selectedIds.size > 0 && selectedIds.size < records.length}
+ onChange={toggleAll}
+ />
+
+ Anomaly ID
+ Asset
+ Plaza
+ Chainage
+ Video Date
+ Assigned To
+
+
+
+ {records.map((r) => (
+
+
+ toggleRow(r.id)} />
+
+ {r.id}
+ {r.Asset || '—'}
+ {r.Plaza || '—'}
+ {r.Chainage || '—'}
+ {r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'}
+ {r.assigned_username || '—'}
+
+ ))}
+
+
+ setPage(newPage)}
+ rowsPerPage={PAGE_SIZE}
+ rowsPerPageOptions={[PAGE_SIZE]}
+ />
+
+ )}
+
+
+
+ );
+};
diff --git a/src/pages/admin/AuditHistory.tsx b/src/pages/admin/AuditHistory.tsx
new file mode 100644
index 0000000..65df1f9
--- /dev/null
+++ b/src/pages/admin/AuditHistory.tsx
@@ -0,0 +1,352 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import {
+ Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
+ Paper, FormControl, InputLabel, Select, MenuItem, ToggleButtonGroup,
+ ToggleButton, CircularProgress, Typography, TextField, Chip, TablePagination,
+} from '@mui/material';
+import { adminService } from '../../api/adminService';
+import type { AuditHistoryUserCount, AuditHistoryRecord } from '../../api/adminService';
+import { accountService } from '../../api/accountService';
+import { useToastStore } from '../../store/toastStore';
+
+interface Organization {
+ org_id: string;
+ org_name: string;
+ db_name: string;
+}
+
+type Preset = 'today' | 'overall' | 'custom_day' | 'custom_range';
+
+const startOfDay = (dateStr: string) => `${dateStr}T00:00:00`;
+const endOfDay = (dateStr: string) => `${dateStr}T23:59:59`;
+const todayStr = () => new Date().toISOString().split('T')[0];
+const RECORDS_PAGE_SIZE = 25;
+
+const efficiencyColor = (value: number): 'success' | 'warning' | 'error' => {
+ if (value >= 75) return 'success';
+ if (value >= 40) return 'warning';
+ return 'error';
+};
+
+// "Assigned" is video_date-based (work whose video arrived in this window)
+// and "Completed" is Audited_on-based (work actually finished in this
+// window) - two independent measures, not two views of the same row set. A
+// user can complete backlog items today with nothing newly assigned today.
+
+const SummaryCard: React.FC<{ label: string; value: string; color?: string }> = ({ label, value, color }) => (
+
+ {label}
+ {value}
+
+);
+
+export const AuditHistory: React.FC = () => {
+ const [organizations, setOrganizations] = useState([]);
+ const [dbName, setDbName] = useState('');
+ const [type, setType] = useState<'audits' | 'rectification'>('audits');
+ const [preset, setPreset] = useState('today');
+ const [customDay, setCustomDay] = useState(todayStr());
+ const [rangeFrom, setRangeFrom] = useState(todayStr());
+ const [rangeTo, setRangeTo] = useState(todayStr());
+
+ const [total, setTotal] = useState(0);
+ const [audited, setAudited] = useState(0);
+ const [notAudited, setNotAudited] = useState(0);
+ const [byUser, setByUser] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ // Drill-down: which user's individual records are currently shown below.
+ const [drillUser, setDrillUser] = useState(null);
+ const [drillRecords, setDrillRecords] = useState([]);
+ const [drillTotal, setDrillTotal] = useState(0);
+ const [drillPage, setDrillPage] = useState(0);
+ const [drillLoading, setDrillLoading] = useState(false);
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const orgsRes = await accountService.getOrganizations();
+ const orgs = (orgsRes as any) || [];
+ setOrganizations(orgs);
+ if (orgs.length > 0) setDbName(orgs[0].db_name);
+ } catch (err) {
+ useToastStore.getState().showToast('Failed to load organizations.', 'error');
+ }
+ })();
+ }, []);
+
+ const { from, to } = (() => {
+ switch (preset) {
+ case 'today':
+ return { from: startOfDay(todayStr()), to: endOfDay(todayStr()) };
+ case 'custom_day':
+ return { from: startOfDay(customDay), to: endOfDay(customDay) };
+ case 'custom_range':
+ // Both fields default to today, so it's easy to end up with an
+ // inverted range while adjusting them one at a time (e.g. picking a
+ // past "To" before touching "From") - that would silently match zero
+ // rows instead of erroring, so normalize the order here.
+ return rangeFrom <= rangeTo
+ ? { from: startOfDay(rangeFrom), to: endOfDay(rangeTo) }
+ : { from: startOfDay(rangeTo), to: endOfDay(rangeFrom) };
+ case 'overall':
+ default:
+ return { from: undefined, to: undefined };
+ }
+ })();
+
+ const load = useCallback(async () => {
+ if (!dbName) return;
+ setLoading(true);
+ try {
+ const result = await adminService.getAuditHistorySummary({ dbName, type, from, to });
+ setTotal(result.total);
+ setAudited(result.audited);
+ setNotAudited(result.notAudited);
+ setByUser(result.byUser);
+ } catch (err) {
+ useToastStore.getState().showToast('Failed to load audit history.', 'error');
+ } finally {
+ setLoading(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [dbName, type, from, to]);
+
+ useEffect(() => {
+ load();
+ // Changing org/type/date invalidates whatever drill-down was open.
+ setDrillUser(null);
+ }, [load]);
+
+ const loadDrillRecords = useCallback(async (user: AuditHistoryUserCount, pageIndex: number) => {
+ setDrillLoading(true);
+ try {
+ const result = await adminService.getAuditHistoryUserRecords({
+ dbName, type, userId: user.user_id, from, to,
+ limit: RECORDS_PAGE_SIZE, offset: pageIndex * RECORDS_PAGE_SIZE,
+ });
+ setDrillRecords(result.records);
+ setDrillTotal(result.total);
+ } catch (err) {
+ useToastStore.getState().showToast('Failed to load user records.', 'error');
+ } finally {
+ setDrillLoading(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [dbName, type, from, to]);
+
+ const handleRowClick = (user: AuditHistoryUserCount) => {
+ if (drillUser?.user_id === user.user_id) {
+ setDrillUser(null);
+ return;
+ }
+ setDrillUser(user);
+ setDrillPage(0);
+ loadDrillRecords(user, 0);
+ };
+
+ const handleDrillPageChange = (newPage: number) => {
+ setDrillPage(newPage);
+ if (drillUser) loadDrillRecords(drillUser, newPage);
+ };
+
+ const completionRate = total > 0 ? Math.round((audited / total) * 1000) / 10 : 0;
+
+ return (
+
+
+
+ Organization
+
+
+
+ value && setType(value)}
+ size="small"
+ >
+ Audits
+ Rectification
+
+
+ value && setPreset(value)}
+ size="small"
+ >
+ Today
+ Overall
+ Custom day
+ Custom range
+
+
+
+
+ {preset === 'custom_day' && (
+ setCustomDay(e.target.value)}
+ slotProps={{ inputLabel: { shrink: true } }}
+ />
+ )}
+ {preset === 'custom_range' && (
+ <>
+ setRangeFrom(e.target.value)}
+ slotProps={{ inputLabel: { shrink: true } }}
+ />
+ setRangeTo(e.target.value)}
+ slotProps={{ inputLabel: { shrink: true } }}
+ />
+ >
+ )}
+
+
+ {loading ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+ Per-user activity — click a row for their individual records
+
+
+ Assigned = video arrived in this window · Completed = audited in this window (may include older backlog)
+
+
+
+
+
+ User
+ Assigned
+ Completed
+ Efficiency
+
+
+
+ {byUser.length === 0 && (
+
+
+ No activity in this range.
+
+
+ )}
+ {byUser.map((u) => (
+ handleRowClick(u)}
+ sx={{ cursor: 'pointer' }}
+ >
+ {u.username}
+ {u.assigned.toLocaleString()}
+ {u.completed.toLocaleString()}
+
+ {u.efficiency === null ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+
+
+ {drillUser && (
+ <>
+
+ Records assigned to {drillUser.username}
+
+ {drillLoading ? (
+
+ ) : (
+
+
+
+
+ Anomaly ID
+ Asset
+ Plaza
+ Chainage
+ Video Date
+ Status
+ Audited On
+ Audited By
+
+
+
+ {drillRecords.length === 0 && (
+
+
+ No records found.
+
+
+ )}
+ {drillRecords.map((r) => (
+
+ {r.id}
+ {r.Asset || '—'}
+ {r.Plaza || '—'}
+ {r.Chainage || '—'}
+ {r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'}
+
+ {r.IsAudited ? (
+
+ ) : (
+
+ )}
+
+ {r.Audited_on ? new Date(r.Audited_on).toLocaleString() : '—'}
+
+ {r.audited_username
+ ? r.audited_username === drillUser.username
+ ? r.audited_username
+ : `${r.audited_username} (reassigned)`
+ : '—'}
+
+
+ ))}
+
+
+ handleDrillPageChange(newPage)}
+ rowsPerPage={RECORDS_PAGE_SIZE}
+ rowsPerPageOptions={[RECORDS_PAGE_SIZE]}
+ />
+
+ )}
+ >
+ )}
+ >
+ )}
+
+ );
+};
diff --git a/src/pages/admin/DevTickets.tsx b/src/pages/admin/DevTickets.tsx
new file mode 100644
index 0000000..35f5fe1
--- /dev/null
+++ b/src/pages/admin/DevTickets.tsx
@@ -0,0 +1,234 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import {
+ Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
+ Paper, TextField, FormControl, InputLabel, Select, MenuItem, CircularProgress,
+ Chip, TablePagination, Link, Tooltip,
+} from '@mui/material';
+import { adminService } from '../../api/adminService';
+import type { DevTicket } from '../../api/adminService';
+import { useToastStore } from '../../store/toastStore';
+
+const PAGE_SIZE = 25;
+
+const gitStatusColor = (status: DevTicket['git_status']): 'success' | 'warning' | 'error' => {
+ if (status === 'success') return 'success';
+ if (status === 'failed') return 'error';
+ return 'warning';
+};
+
+const truncate = (text: string, max = 80) => (text.length > max ? `${text.slice(0, max)}…` : text);
+
+export const DevTickets: React.FC = () => {
+ const [search, setSearch] = useState('');
+ const [gitStatus, setGitStatus] = useState<'' | 'pending' | 'success' | 'failed'>('');
+ const [dateFrom, setDateFrom] = useState('');
+ const [dateTo, setDateTo] = useState('');
+ const [records, setRecords] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(0);
+ const [loading, setLoading] = useState(false);
+
+ // dateTo is a bare date; created_at is a full timestamp, and both fields
+ // are independently editable, so normalize the order and extend "to" to
+ // end-of-day - see Allocation.tsx/AuditHistory.tsx for the same fix after
+ // hitting this exact bug there (an inverted range silently matches zero
+ // rows instead of erroring).
+ const [effectiveDateFrom, effectiveDateTo] = (() => {
+ if (!dateFrom || !dateTo) return [dateFrom || undefined, dateTo ? `${dateTo}T23:59:59` : undefined];
+ return dateFrom <= dateTo
+ ? [dateFrom, `${dateTo}T23:59:59`]
+ : [dateTo, `${dateFrom}T23:59:59`];
+ })();
+
+ // Live Gitea issue state (open/closed) - git_status only ever records
+ // whether we succeeded in *filing* the issue, never what happened to it
+ // afterward, so this is fetched separately from the main list.
+ const [issueStates, setIssueStates] = useState>({});
+ const [issueStatesLoading, setIssueStatesLoading] = useState(false);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await adminService.getDevTickets({
+ search: search || undefined,
+ gitStatus: gitStatus || undefined,
+ dateFrom: effectiveDateFrom,
+ dateTo: effectiveDateTo,
+ limit: PAGE_SIZE,
+ offset: page * PAGE_SIZE,
+ });
+ setRecords(result.records);
+ setTotal(result.total);
+ } catch (err) {
+ useToastStore.getState().showToast('Failed to load dev tickets.', 'error');
+ } finally {
+ setLoading(false);
+ }
+ }, [search, gitStatus, effectiveDateFrom, effectiveDateTo, page]);
+
+ useEffect(() => {
+ const timeout = setTimeout(load, 300);
+ return () => clearTimeout(timeout);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [search, gitStatus, effectiveDateFrom, effectiveDateTo, page]);
+
+ useEffect(() => {
+ setPage(0);
+ }, [search, gitStatus, effectiveDateFrom, effectiveDateTo]);
+
+ useEffect(() => {
+ // Keyed by ticket id, not issue number - the backend resolves each
+ // ticket's own stored git_issue_url, so this stays correct even for
+ // tickets filed before a repo migration.
+ const ticketIds = records
+ .filter((t) => t.git_status === 'success' && t.git_issue_number != null)
+ .map((t) => t.id);
+
+ if (ticketIds.length === 0) {
+ setIssueStates({});
+ return;
+ }
+
+ setIssueStatesLoading(true);
+ adminService.getIssueStates(ticketIds)
+ .then(setIssueStates)
+ .catch(() => useToastStore.getState().showToast('Failed to check live Gitea issue status.', 'error'))
+ .finally(() => setIssueStatesLoading(false));
+ }, [records]);
+
+ return (
+
+
+ setSearch(e.target.value)}
+ sx={{ minWidth: 300 }}
+ />
+
+ Git status
+
+
+ setDateFrom(e.target.value)}
+ slotProps={{ inputLabel: { shrink: true } }}
+ />
+ setDateTo(e.target.value)}
+ slotProps={{ inputLabel: { shrink: true } }}
+ />
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+ Anomaly ID
+ Asset / Plaza
+ Dev
+ Reporter
+ Message
+ RocketChat
+ Git
+ Issue status
+ Created
+
+
+
+ {records.length === 0 && (
+
+
+ No dev tickets found.
+
+
+ )}
+ {records.map((t) => {
+ const liveState = t.git_status === 'success' && t.git_issue_number != null ? issueStates[t.id] : undefined;
+ return (
+
+ {t.anomaly_id}
+ {[t.asset, t.plaza].filter(Boolean).join(' / ') || '—'}
+ @{t.dev_handle}
+ {t.reporter || '—'}
+
+
+ {truncate(t.message_text)}
+
+ {t.duplicate_of_id && (
+
+ )}
+
+
+ {t.rocketchat_sent ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {t.git_issue_url ? (
+
+
+
+ ) : (
+
+
+
+ )}
+ {t.retry_count > 0 && (
+
+ )}
+
+
+ {t.git_status !== 'success' ? (
+ '—'
+ ) : issueStatesLoading && liveState === undefined ? (
+
+ ) : liveState === 'closed' ? (
+
+ ) : liveState === 'open' ? (
+
+ ) : (
+
+
+
+ )}
+
+ {new Date(t.created_at).toLocaleString()}
+
+ );
+ })}
+
+
+ setPage(newPage)}
+ rowsPerPage={PAGE_SIZE}
+ rowsPerPageOptions={[PAGE_SIZE]}
+ />
+
+ )}
+
+ );
+};
diff --git a/src/pages/admin/LoginHistory.tsx b/src/pages/admin/LoginHistory.tsx
new file mode 100644
index 0000000..31a8cc2
--- /dev/null
+++ b/src/pages/admin/LoginHistory.tsx
@@ -0,0 +1,77 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import {
+ Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
+ Paper, TextField, CircularProgress, Chip,
+} from '@mui/material';
+import { adminService } from '../../api/adminService';
+import type { UserSession } from '../../api/adminService';
+import { useToastStore } from '../../store/toastStore';
+
+const formatDate = (value: string | null) => (value ? new Date(value).toLocaleString() : '—');
+
+export const LoginHistory: React.FC = () => {
+ const [sessions, setSessions] = useState([]);
+ const [username, setUsername] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const load = useCallback(async (usernameFilter: string) => {
+ setLoading(true);
+ try {
+ const result = await adminService.getSessions({ username: usernameFilter || undefined });
+ setSessions(result);
+ } catch (err) {
+ useToastStore.getState().showToast('Failed to load login history.', 'error');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ load('');
+ }, [load]);
+
+ useEffect(() => {
+ const timeout = setTimeout(() => load(username), 300);
+ return () => clearTimeout(timeout);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [username]);
+
+ return (
+
+ setUsername(e.target.value)}
+ size="small"
+ sx={{ mb: 2, width: 300 }}
+ />
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+ Username
+ Login At
+ Logout At
+
+
+
+ {sessions.map((s) => (
+
+ {s.username}
+ {formatDate(s.login_at)}
+
+ {s.logout_at ? formatDate(s.logout_at) : }
+
+
+ ))}
+
+
+
+ )}
+
+ );
+};
diff --git a/src/pages/admin/UserManagement.tsx b/src/pages/admin/UserManagement.tsx
new file mode 100644
index 0000000..d2b752f
--- /dev/null
+++ b/src/pages/admin/UserManagement.tsx
@@ -0,0 +1,234 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import {
+ Box, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
+ Paper, Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem,
+ Select, FormControl, InputLabel, Switch, FormControlLabel, CircularProgress, Chip,
+} from '@mui/material';
+import { adminService } from '../../api/adminService';
+import type { AdminUser, Role } from '../../api/adminService';
+import { accountService } from '../../api/accountService';
+import { useToastStore } from '../../store/toastStore';
+
+interface Organization {
+ org_id: string;
+ org_name: string;
+ db_name: string;
+}
+
+export const UserManagement: React.FC = () => {
+ const [users, setUsers] = useState([]);
+ const [roles, setRoles] = useState([]);
+ const [organizations, setOrganizations] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [search, setSearch] = useState('');
+
+ const [addOpen, setAddOpen] = useState(false);
+ const [newUser, setNewUser] = useState({ username: '', email: '', name: '', password: '', role_id: '', org_id: '', db_name: '' });
+
+ const [editUser, setEditUser] = useState(null);
+ const [editForm, setEditForm] = useState({ username: '', role_id: '', disabled: false });
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const [usersRes, rolesRes, orgsRes] = await Promise.all([
+ adminService.getUsers(),
+ adminService.getRoles(),
+ accountService.getOrganizations(),
+ ]);
+ setUsers(usersRes);
+ setRoles(rolesRes);
+ setOrganizations((orgsRes as any) || []);
+ } catch (err) {
+ useToastStore.getState().showToast('Failed to load users.', 'error');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const handleOrgChange = (orgId: string | number, setter: (v: any) => void, current: any) => {
+ // MUI Select passes e.target.value through with its original type - org_id
+ // comes back as a number from the API, so compare both sides as strings.
+ const org = organizations.find((o) => String(o.org_id) === String(orgId));
+ setter({ ...current, org_id: orgId, db_name: org?.db_name || '' });
+ };
+
+ const handleCreate = async () => {
+ if (!newUser.username || !newUser.password || !newUser.role_id || !newUser.db_name) {
+ useToastStore.getState().showToast('Username, password, role and org are required.', 'error');
+ return;
+ }
+ try {
+ await adminService.createUser({
+ username: newUser.username,
+ email: newUser.email,
+ name: newUser.name,
+ password: newUser.password,
+ role_id: Number(newUser.role_id),
+ org_id: Number(newUser.org_id),
+ db_name: newUser.db_name,
+ });
+ useToastStore.getState().showToast('User created.', 'success');
+ setAddOpen(false);
+ setNewUser({ username: '', email: '', name: '', password: '', role_id: '', org_id: '', db_name: '' });
+ load();
+ } catch (err: any) {
+ const message = err?.response?.status === 409 ? 'That username is already taken.' : 'Failed to create user.';
+ useToastStore.getState().showToast(message, 'error');
+ }
+ };
+
+ const openEdit = (user: AdminUser) => {
+ setEditUser(user);
+ setEditForm({ username: user.username, role_id: String(user.role_id), disabled: user.disabled });
+ };
+
+ const handleUpdate = async () => {
+ if (!editUser) return;
+ try {
+ await adminService.updateUser(editUser.user_id, {
+ username: editForm.username,
+ role_id: Number(editForm.role_id),
+ disabled: editForm.disabled,
+ });
+ useToastStore.getState().showToast('User updated.', 'success');
+ setEditUser(null);
+ load();
+ } catch (err: any) {
+ const message = err?.response?.status === 409 ? 'That username is already taken.' : 'Failed to update user.';
+ useToastStore.getState().showToast(message, 'error');
+ }
+ };
+
+ const filteredUsers = users.filter((u) => {
+ if (!search.trim()) return true;
+ const needle = search.trim().toLowerCase();
+ return (
+ u.username.toLowerCase().includes(needle) ||
+ u.email?.toLowerCase().includes(needle) ||
+ u.name?.toLowerCase().includes(needle) ||
+ u.role_name.toLowerCase().includes(needle)
+ );
+ });
+
+ return (
+
+
+ setSearch(e.target.value)}
+ sx={{ minWidth: 280 }}
+ />
+
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+ Username
+ Email
+ Name
+ Role
+ Org (DB)
+ Status
+ Actions
+
+
+
+ {filteredUsers.length === 0 && (
+
+
+ No users match "{search}".
+
+
+ )}
+ {filteredUsers.map((u) => (
+
+ {u.username}
+ {u.email}
+ {u.name}
+ {u.role_name}
+ {u.db_name}
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Add User Dialog */}
+
+
+ {/* Edit User Dialog */}
+
+
+ );
+};
diff --git a/src/pages/clients/ClientsOverview.tsx b/src/pages/clients/ClientsOverview.tsx
index 1302402..7243a06 100644
--- a/src/pages/clients/ClientsOverview.tsx
+++ b/src/pages/clients/ClientsOverview.tsx
@@ -1,11 +1,20 @@
-import React, { useEffect, useState, useCallback, useMemo } from 'react';
-import { Box, Typography, Paper, TextField, Button, CircularProgress, FormControl, Select, MenuItem } from '@mui/material';
+import React, { useEffect, useState, useCallback, useMemo, Fragment } from 'react';
+import {
+ Box, Typography, Paper, TextField, Button, CircularProgress, FormControl, Select, MenuItem,
+ Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, Collapse,
+} from '@mui/material';
+import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
+import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
} from 'recharts';
import { accountService } from '../../api/accountService';
import { activityFeedsService } from '../../api/activityFeedsService';
import { auditSummaryService } from '../../api/auditSummaryService';
+import { adminService } from '../../api/adminService';
+import type { ClientOverviewOrg } from '../../api/adminService';
+import { useToastStore } from '../../store/toastStore';
+import { AssetMultiSelect } from '../../components/common/AssetMultiSelect';
interface ClientBarData {
client: string;
@@ -209,6 +218,73 @@ export const ClientsOverview: React.FC = () => {
? (availableSites.find((s) => s.site_id === selectedSite)?.site_name || selectedSite)
: selectedClient;
+ // Per-org / per-site record counts, independent of the chart above's date
+ // range and single-site drill-down. All-time by default (empty date
+ // fields), optionally scoped by a date range and/or one-or-more assets.
+ const [overviewType, setOverviewType] = useState<'audits' | 'rectification'>('audits');
+ const [overviewDateFrom, setOverviewDateFrom] = useState('');
+ const [overviewDateTo, setOverviewDateTo] = useState('');
+ const [overviewAssetIds, setOverviewAssetIds] = useState([]);
+ const [overview, setOverview] = useState([]);
+ const [overviewLoading, setOverviewLoading] = useState(true);
+ const [expandedOrgs, setExpandedOrgs] = useState>(new Set());
+
+ // Two independently-editable date fields can produce an inverted range
+ // that silently matches zero rows - normalize the order and extend "to"
+ // to end-of-day, same fix as Allocation.tsx/AuditHistory.tsx/DevTickets.tsx.
+ const [effectiveOverviewFrom, effectiveOverviewTo] = (() => {
+ if (!overviewDateFrom || !overviewDateTo) {
+ return [overviewDateFrom || undefined, overviewDateTo ? `${overviewDateTo}T23:59:59` : undefined];
+ }
+ return overviewDateFrom <= overviewDateTo
+ ? [overviewDateFrom, `${overviewDateTo}T23:59:59`]
+ : [overviewDateTo, `${overviewDateFrom}T23:59:59`];
+ })();
+
+ const fetchOverview = useCallback(async () => {
+ setOverviewLoading(true);
+ try {
+ const result = await adminService.getClientsOverview({
+ type: overviewType,
+ dateFrom: effectiveOverviewFrom,
+ dateTo: effectiveOverviewTo,
+ assetIds: overviewAssetIds,
+ });
+ setOverview(result.clients);
+ } catch (err) {
+ useToastStore.getState().showToast('Failed to load client records overview.', 'error');
+ } finally {
+ setOverviewLoading(false);
+ }
+ }, [overviewType, effectiveOverviewFrom, effectiveOverviewTo, overviewAssetIds]);
+
+ useEffect(() => {
+ fetchOverview();
+ }, [fetchOverview]);
+
+ const toggleOrgExpanded = (orgId: number) => {
+ setExpandedOrgs((prev) => {
+ const next = new Set(prev);
+ if (next.has(orgId)) next.delete(orgId);
+ else next.add(orgId);
+ return next;
+ });
+ };
+
+ const overviewTotals = useMemo(
+ () =>
+ overview.reduce(
+ (acc, o) => ({
+ total: acc.total + o.total,
+ audited: acc.audited + o.audited,
+ deleted: acc.deleted + o.deleted,
+ nonAudited: acc.nonAudited + o.nonAudited,
+ }),
+ { total: 0, audited: 0, deleted: 0, nonAudited: 0 }
+ ),
+ [overview]
+ );
+
return (
@@ -347,6 +423,167 @@ export const ClientsOverview: React.FC = () => {
)}
+
+
+
+
+ All Clients — Records by Site
+ {(overviewDateFrom || overviewDateTo || overviewAssetIds.length > 0) ? '' : ' (all-time)'}
+
+
+ setOverviewDateFrom(e.target.value)}
+ slotProps={{ inputLabel: { shrink: true } }}
+ sx={{ '& .MuiOutlinedInput-root': { color: '#f8fafc', bgcolor: 'rgba(255,255,255,0.1)' } }}
+ />
+ setOverviewDateTo(e.target.value)}
+ slotProps={{ inputLabel: { shrink: true } }}
+ sx={{ '& .MuiOutlinedInput-root': { color: '#f8fafc', bgcolor: 'rgba(255,255,255,0.1)' } }}
+ />
+
+
+
+
+
+
+ {overviewLoading ? (
+
+
+
+ ) : overview.length === 0 ? (
+ No data found
+ ) : (
+
+
+
+
+
+ Organization / Site
+ Total
+ Audited
+ Deleted
+ Non-Audited
+
+
+
+ {overview.map((org) => {
+ const isExpanded = expandedOrgs.has(org.org_id);
+ return (
+
+
+
+ {org.sites.length > 0 && (
+ toggleOrgExpanded(org.org_id)} sx={{ color: '#94a3b8' }}>
+ {isExpanded ? : }
+
+ )}
+
+
+ {org.org_name}
+ {org.error && (
+
+ (failed to load: {org.error})
+
+ )}
+
+
+ {org.total.toLocaleString()}
+
+
+ {org.audited.toLocaleString()}
+
+
+ {org.deleted.toLocaleString()}
+
+
+ {org.nonAudited.toLocaleString()}
+
+
+ {org.sites.length > 0 && (
+
+
+
+
+
+ {org.sites.map((site) => (
+
+
+
+ {site.site_name}
+
+
+ {site.total.toLocaleString()}
+
+
+ {site.audited.toLocaleString()}
+
+
+ {site.deleted.toLocaleString()}
+
+
+ {site.nonAudited.toLocaleString()}
+
+
+ ))}
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+
+ All Clients
+
+ {overviewTotals.total.toLocaleString()}
+
+
+ {overviewTotals.audited.toLocaleString()}
+
+
+ {overviewTotals.deleted.toLocaleString()}
+
+
+ {overviewTotals.nonAudited.toLocaleString()}
+
+
+
+
+
+ )}
+
);
diff --git a/src/store/authStore.ts b/src/store/authStore.ts
index b0c235a..7678327 100644
--- a/src/store/authStore.ts
+++ b/src/store/authStore.ts
@@ -3,11 +3,11 @@ import { persist } from 'zustand/middleware';
import { useFeedStore } from './feedStore';
export interface User {
- token: string;
access_token: string;
+ refresh_token: string;
email: string;
username: string;
- user_roles: string;
+ role: string;
db_name?: string;
org_id?: string;
user_id?: string | number;
@@ -36,12 +36,11 @@ export const useAuthStore = create()(
},
hasRole: (requiredRole: string) => {
const { user } = get();
- if (!user || !user.user_roles) return false;
-
- const userRoles = user.user_roles.split(',').map(r => r.trim().toLowerCase());
+ if (!user || !user.role) return false;
+
const neededRoles = requiredRole.split(',').map(r => r.trim().toLowerCase());
-
- return neededRoles.some(role => userRoles.includes(role));
+
+ return neededRoles.includes(user.role.toLowerCase());
},
}),
{