From b667fe10822df89f70ece1b5999f9d8ed3f0c567 Mon Sep 17 00:00:00 2001 From: kaushik Date: Thu, 9 Jul 2026 17:37:54 +0530 Subject: [PATCH] feat: implement client overview dashboard and admin portal structure with API services and page components --- .env | 1 - .env.production | 2 - src/App.tsx | 6 +- src/api/accountService.ts | 41 ++- src/api/adminService.ts | 254 ++++++++++++++ src/api/axiosClient.ts | 74 +++- src/components/common/AssetMultiSelect.tsx | 219 ++++++++++++ src/components/layout/Header.tsx | 45 ++- src/pages/account/Login.tsx | 55 ++- src/pages/admin/AdminConsole.tsx | 37 ++ src/pages/admin/Allocation.tsx | 384 +++++++++++++++++++++ src/pages/admin/AuditHistory.tsx | 352 +++++++++++++++++++ src/pages/admin/DevTickets.tsx | 234 +++++++++++++ src/pages/admin/LoginHistory.tsx | 77 +++++ src/pages/admin/UserManagement.tsx | 234 +++++++++++++ src/pages/clients/ClientsOverview.tsx | 241 ++++++++++++- src/store/authStore.ts | 13 +- 17 files changed, 2196 insertions(+), 73 deletions(-) create mode 100644 src/api/adminService.ts create mode 100644 src/components/common/AssetMultiSelect.tsx create mode 100644 src/pages/admin/AdminConsole.tsx create mode 100644 src/pages/admin/Allocation.tsx create mode 100644 src/pages/admin/AuditHistory.tsx create mode 100644 src/pages/admin/DevTickets.tsx create mode 100644 src/pages/admin/LoginHistory.tsx create mode 100644 src/pages/admin/UserManagement.tsx 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]} + /> +
+ )} + + setBulkConfirmCount(null)}> + Confirm bulk allocation + + + Assign the first {bulkConfirmCount?.toLocaleString()} {sort} unaudited {type} record(s) in{' '} + {selectedOrg?.org_name || dbName} (matching the current date/asset/site filters) to {selectedUser?.username}? + If fewer records are available, all of them will be assigned. + + + + + + + +
+ ); +}; 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 */} + setAddOpen(false)} maxWidth="xs" fullWidth> + Add User + + setNewUser({ ...newUser, username: e.target.value })} fullWidth /> + setNewUser({ ...newUser, email: e.target.value })} fullWidth /> + setNewUser({ ...newUser, name: e.target.value })} fullWidth /> + setNewUser({ ...newUser, password: e.target.value })} fullWidth /> + + Role + + + + Organization + + + + + + + + + + {/* Edit User Dialog */} + setEditUser(null)} maxWidth="xs" fullWidth> + Edit User + + setEditForm({ ...editForm, username: e.target.value })} fullWidth /> + + Role + + + setEditForm({ ...editForm, disabled: !e.target.checked })} />} + label={editForm.disabled ? 'Disabled' : 'Active'} + /> + + + + + + +
+ ); +}; 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()); }, }), {