feat: implement client overview dashboard and admin portal structure with API services and page components

This commit is contained in:
2026-07-09 17:37:54 +05:30
parent 3cf9e2997c
commit b667fe1082
17 changed files with 2196 additions and 73 deletions

1
.env
View File

@@ -1,2 +1 @@
VITE_DASHBOARD_API_URL=https://sr-backend-api.takeleap.in
VITE_AUDIT_API_URL=http://localhost:7514

View File

@@ -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

View File

@@ -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() {
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="activity-feeds" element={<GlobalFeed />} />
<Route path="clients" element={<ClientsOverview />} />
<Route element={<ProtectedRoute requiredRole="ADMIN" />}>
<Route path="admin" element={<AdminConsole />} />
</Route>
</Route>
</Route>

View File

@@ -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 () => {

254
src/api/adminService.ts Normal file
View File

@@ -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<AdminUser[]> => {
return (await axiosClient.get('/api/audit/admin/users')) as unknown as AdminUser[];
},
getRoles: async (): Promise<Role[]> => {
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<void> => {
await axiosClient.patch(`/api/audit/admin/users/${userId}`, payload);
},
getSessions: async (params?: { username?: string; limit?: number; offset?: number }): Promise<UserSession[]> => {
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<AllocationRecordsResult> => {
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<AuditHistorySummary> => {
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<AuditHistoryRecordsResult> => {
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<DevTicketsResult> => {
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<Record<number, 'open' | 'closed' | null>> => {
const result = (await axiosClient.get('/api/audit/admin/dev-tickets/issue-states', {
params: { ticketIds: ticketIds.join(',') },
})) as unknown as { states: Record<number, 'open' | 'closed' | null> };
return result.states;
},
getClientsOverview: async (params: {
type: 'audits' | 'rectification';
dateFrom?: string;
dateTo?: string;
assetIds?: number[];
}): Promise<ClientsOverviewResult> => {
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;
},
};

View File

@@ -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.';

View File

@@ -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;
}) => (
<FormControlLabel
control={
<Checkbox
size="small"
checked={checked}
onChange={() => onToggle(assetId)}
sx={{ color: '#475569', '&.Mui-checked': { color: '#3b82f6' }, py: 0.3 }}
/>
}
label={<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.72rem' }}>{assetName}</Typography>}
/>
));
const GroupHeader = memo(({
typeName, assetCount, allGroupSelected, someGroupSelected, onToggle,
}: {
typeName: string;
assetCount: number;
allGroupSelected: boolean;
someGroupSelected: boolean;
onToggle: () => void;
}) => (
<FormControlLabel
control={
<Checkbox
checked={allGroupSelected}
indeterminate={someGroupSelected}
onChange={onToggle}
size="small"
sx={{ color: '#64748b', '&.Mui-checked': { color: '#60a5fa' }, '&.MuiCheckbox-indeterminate': { color: '#60a5fa' } }}
/>
}
label={
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#60a5fa', textTransform: 'uppercase', fontSize: '0.7rem', letterSpacing: 1 }}>
{typeName} ({assetCount})
</Typography>
}
sx={{ mb: 0.5 }}
/>
));
export const AssetMultiSelect: React.FC<AssetMultiSelectProps> = ({ value, onChange }) => {
const { assetTypes } = useFeedStore();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [draftSet, setDraftSet] = useState<Set<number>>(() => new Set(value));
const [search, setSearch] = useState('');
const open = Boolean(anchorEl);
const handleOpen = (e: React.MouseEvent<HTMLElement>) => {
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 (
<>
<Button
variant="outlined"
onClick={handleOpen}
sx={{ borderColor: '#334155', color: value.length > 0 ? '#f8fafc' : '#94a3b8', textTransform: 'none', fontWeight: 'bold', bgcolor: 'rgba(255,255,255,0.05)' }}
>
{value.length > 0 ? `Assets (${value.length})` : 'All Assets'}
</Button>
<Popover
open={open}
anchorEl={anchorEl}
onClose={commitAndClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: { sx: { bgcolor: '#0f172a', border: '1px solid #1e293b', color: '#f8fafc', width: 420 } } }}
>
<Box sx={{ p: 2 }}>
<TextField
placeholder="Search assets..."
variant="outlined"
size="small"
fullWidth
autoFocus
value={search}
onChange={(e) => 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: <InputAdornment position="start"><SearchIcon sx={{ color: '#64748b' }} fontSize="small" /></InputAdornment> } }}
/>
<FormControlLabel
control={
<Checkbox
checked={allSelected}
indeterminate={someSelected}
onChange={handleToggleAll}
size="small"
sx={{ color: '#64748b', '&.Mui-checked': { color: '#3b82f6' }, '&.MuiCheckbox-indeterminate': { color: '#3b82f6' } }}
/>
}
label={<Typography variant="body2" sx={{ color: '#94a3b8', fontWeight: 'bold' }}>Select All ({allAssetIds.length})</Typography>}
/>
<Divider sx={{ borderColor: '#1e293b', my: 1 }} />
<Box sx={{ maxHeight: 320, overflowY: 'auto', pr: 1 }}>
{filteredAssetTypes.length === 0 ? (
<Typography variant="body2" sx={{ color: '#64748b' }}>No assets match your search.</Typography>
) : (
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 (
<Box key={type.type_id ?? 'uncategorized'} sx={{ mb: 1.5 }}>
<GroupHeader
typeName={type.type_name || 'Uncategorized'}
assetCount={type.assets.length}
allGroupSelected={allGroupSelected}
someGroupSelected={someGroupSelected}
onToggle={makeGroupToggle(groupIds)}
/>
<Box sx={{ pl: 3 }}>
{type.assets.map((asset: any) => (
<AssetRow
key={asset.asset_id}
assetId={asset.asset_id}
assetName={asset.asset_name.replace(/_/g, ' ')}
checked={draftSet.has(asset.asset_id)}
onToggle={handleToggleAsset}
/>
))}
</Box>
</Box>
);
})
)}
</Box>
<Divider sx={{ borderColor: '#1e293b', my: 1 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Button size="small" onClick={() => setDraftSet(new Set())} sx={{ color: '#94a3b8', textTransform: 'none' }}>
Clear
</Button>
<Button size="small" variant="contained" onClick={commitAndClose} sx={{ bgcolor: '#3b82f6', textTransform: 'none', '&:hover': { bgcolor: '#2563eb' } }}>
Done
</Button>
</Box>
</Box>
</Popover>
</>
);
};

View File

@@ -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<HeaderProps> = ({ toggleSidebar }) => {
const { user, logout } = useAuthStore();
const { user, logout, hasRole } = useAuthStore();
const navigate = useNavigate();
const location = useLocation();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@@ -25,8 +26,20 @@ export const Header: React.FC<HeaderProps> = ({ 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<HeaderProps> = ({ toggleSidebar }) => {
>
Activity Feeds
</Button>
<Button
onClick={() => navigate('/clients')}
sx={{
color: location.pathname.includes('clients') ? 'primary.main' : 'text.secondary',
fontWeight: 'bold',
borderBottom: location.pathname.includes('clients') ? '2px solid #3b82f6' : '2px solid transparent',
borderRadius: 0,
px: 2,
py: 1
}}
>
Clients
</Button>
{hasRole('ADMIN') && (
<Button
onClick={() => navigate('/admin')}
sx={{
color: location.pathname.includes('admin') ? 'primary.main' : 'text.secondary',
fontWeight: 'bold',
borderBottom: location.pathname.includes('admin') ? '2px solid #3b82f6' : '2px solid transparent',
borderRadius: 0,
px: 2,
py: 1
}}
>
Admin
</Button>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>

View File

@@ -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<any>(null);
const [tempLoginResult, setTempLoginResult] = useState<Awaited<ReturnType<typeof accountService.login>> | 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 = () => {
</Paper>
</Container>
{/* Database Selection Dialog for SR_AUDITOR */}
<DbSelectionDialog open={dialogOpen} onClose={handleDbSelection} />
</Box>
);

View File

@@ -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 <main> 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.
<Box sx={{ p: 3, height: 'calc(100vh - 64px)', overflowY: 'auto' }}>
<Tabs value={tab} onChange={(_, value) => setTab(value)} sx={{ mb: 3 }}>
<Tab label="Clients" />
<Tab label="Users" />
<Tab label="Login History" />
<Tab label="Allocation" />
<Tab label="Audit & Rectification History" />
<Tab label="Dev Tickets" />
</Tabs>
{tab === 0 && <ClientsOverview />}
{tab === 1 && <UserManagement />}
{tab === 2 && <LoginHistory />}
{tab === 3 && <Allocation />}
{tab === 4 && <AuditHistory />}
{tab === 5 && <DevTickets />}
</Box>
);
};

View File

@@ -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<Organization[]>([]);
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<SiteOption[]>([]);
const [users, setUsers] = useState<AdminUser[]>([]);
const [selectedUserId, setSelectedUserId] = useState('');
const [records, setRecords] = useState<AllocationRecord[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(false);
const [assigning, setAssigning] = useState(false);
const [bulkConfirmCount, setBulkConfirmCount] = useState<number | null>(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 (
<Box>
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Organization</InputLabel>
<Select label="Organization" value={dbName} onChange={(e) => setDbName(e.target.value)}>
{organizations.map((o) => <MenuItem key={o.org_id} value={o.db_name}>{o.org_name}</MenuItem>)}
</Select>
</FormControl>
<ToggleButtonGroup
value={type}
exclusive
onChange={(_, value) => value && setType(value)}
size="small"
>
<ToggleButton value="audits">Audits</ToggleButton>
<ToggleButton value="rectification">Rectification</ToggleButton>
</ToggleButtonGroup>
<ToggleButtonGroup
value={sort}
exclusive
onChange={(_, value) => value && setSort(value)}
size="small"
>
<ToggleButton value="oldest">Oldest first</ToggleButton>
<ToggleButton value="newest">Newest first</ToggleButton>
</ToggleButtonGroup>
</Box>
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
label="Video date from"
type="date"
size="small"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="Video date to"
type="date"
size="small"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Asset</InputLabel>
<Select label="Asset" value={assetId} onChange={(e) => setAssetId(e.target.value)}>
<MenuItem value="">All assets</MenuItem>
{assets.map((a: any) => <MenuItem key={a.id} value={a.id}>{a.name}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Site</InputLabel>
<Select label="Site" value={siteId} onChange={(e) => setSiteId(e.target.value)}>
<MenuItem value="">All sites</MenuItem>
{sites.map((s) => <MenuItem key={s.site_id} value={s.site_id}>{s.site_name}</MenuItem>)}
</Select>
</FormControl>
</Box>
<FormControl size="small" sx={{ minWidth: 220, mb: 2 }}>
<InputLabel>Allocate to</InputLabel>
<Select label="Allocate to" value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}>
{users.map((u) => <MenuItem key={u.user_id} value={u.user_id}>{u.username} ({u.role_name})</MenuItem>)}
</Select>
</FormControl>
<Box sx={{ display: 'flex', gap: 2, mb: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2">{selectedIds.size} selected on this page</Typography>
<Button
variant="contained"
disabled={selectedIds.size === 0 || !selectedUserId || assigning}
onClick={handleAllocate}
>
Allocate selected
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 1.5, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>Quick bulk assign (matches current filters):</Typography>
{BULK_PRESETS.map((count) => (
<Button
key={count}
variant="outlined"
size="small"
disabled={!selectedUserId || assigning}
onClick={() => setBulkConfirmCount(count)}
>
{count.toLocaleString()}
</Button>
))}
</Box>
<Divider sx={{ mb: 2 }} />
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
) : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
checked={records.length > 0 && selectedIds.size === records.length}
indeterminate={selectedIds.size > 0 && selectedIds.size < records.length}
onChange={toggleAll}
/>
</TableCell>
<TableCell>Anomaly ID</TableCell>
<TableCell>Asset</TableCell>
<TableCell>Plaza</TableCell>
<TableCell>Chainage</TableCell>
<TableCell>Video Date</TableCell>
<TableCell>Assigned To</TableCell>
</TableRow>
</TableHead>
<TableBody>
{records.map((r) => (
<TableRow key={r.id} selected={selectedIds.has(r.id)}>
<TableCell padding="checkbox">
<Checkbox checked={selectedIds.has(r.id)} onChange={() => toggleRow(r.id)} />
</TableCell>
<TableCell>{r.id}</TableCell>
<TableCell>{r.Asset || '—'}</TableCell>
<TableCell>{r.Plaza || '—'}</TableCell>
<TableCell>{r.Chainage || '—'}</TableCell>
<TableCell>{r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'}</TableCell>
<TableCell>{r.assigned_username || '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={(_, newPage) => setPage(newPage)}
rowsPerPage={PAGE_SIZE}
rowsPerPageOptions={[PAGE_SIZE]}
/>
</TableContainer>
)}
<Dialog open={bulkConfirmCount !== null} onClose={() => setBulkConfirmCount(null)}>
<DialogTitle>Confirm bulk allocation</DialogTitle>
<DialogContent>
<DialogContentText>
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.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setBulkConfirmCount(null)}>Cancel</Button>
<Button
variant="contained"
onClick={() => bulkConfirmCount !== null && handleBulkAllocate(bulkConfirmCount)}
>
Confirm
</Button>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@@ -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 }) => (
<Paper sx={{ p: 2, minWidth: 160 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
<Typography variant="h5" sx={{ fontWeight: 'bold', color }}>{value}</Typography>
</Paper>
);
export const AuditHistory: React.FC = () => {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [dbName, setDbName] = useState('');
const [type, setType] = useState<'audits' | 'rectification'>('audits');
const [preset, setPreset] = useState<Preset>('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<AuditHistoryUserCount[]>([]);
const [loading, setLoading] = useState(false);
// Drill-down: which user's individual records are currently shown below.
const [drillUser, setDrillUser] = useState<AuditHistoryUserCount | null>(null);
const [drillRecords, setDrillRecords] = useState<AuditHistoryRecord[]>([]);
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 (
<Box>
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Organization</InputLabel>
<Select label="Organization" value={dbName} onChange={(e) => setDbName(e.target.value)}>
{organizations.map((o) => <MenuItem key={o.org_id} value={o.db_name}>{o.org_name}</MenuItem>)}
</Select>
</FormControl>
<ToggleButtonGroup
value={type}
exclusive
onChange={(_, value) => value && setType(value)}
size="small"
>
<ToggleButton value="audits">Audits</ToggleButton>
<ToggleButton value="rectification">Rectification</ToggleButton>
</ToggleButtonGroup>
<ToggleButtonGroup
value={preset}
exclusive
onChange={(_, value) => value && setPreset(value)}
size="small"
>
<ToggleButton value="today">Today</ToggleButton>
<ToggleButton value="overall">Overall</ToggleButton>
<ToggleButton value="custom_day">Custom day</ToggleButton>
<ToggleButton value="custom_range">Custom range</ToggleButton>
</ToggleButtonGroup>
</Box>
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center' }}>
{preset === 'custom_day' && (
<TextField
label="Day"
type="date"
size="small"
value={customDay}
onChange={(e) => setCustomDay(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
)}
{preset === 'custom_range' && (
<>
<TextField
label="From"
type="date"
size="small"
value={rangeFrom}
onChange={(e) => setRangeFrom(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="To"
type="date"
size="small"
value={rangeTo}
onChange={(e) => setRangeTo(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</>
)}
</Box>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
) : (
<>
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap' }}>
<SummaryCard label="Total in range" value={total.toLocaleString()} />
<SummaryCard label="Audited" value={audited.toLocaleString()} color="#22c55e" />
<SummaryCard label="Not audited" value={notAudited.toLocaleString()} color="#f59e0b" />
<SummaryCard label="Completion rate" value={`${completionRate}%`} />
</Box>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>
Per-user activity click a row for their individual records
</Typography>
<Typography variant="caption" sx={{ mb: 1, display: 'block', color: 'text.secondary' }}>
Assigned = video arrived in this window · Completed = audited in this window (may include older backlog)
</Typography>
<TableContainer component={Paper} sx={{ mb: drillUser ? 3 : 0 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>User</TableCell>
<TableCell align="right">Assigned</TableCell>
<TableCell align="right">Completed</TableCell>
<TableCell align="right">Efficiency</TableCell>
</TableRow>
</TableHead>
<TableBody>
{byUser.length === 0 && (
<TableRow>
<TableCell colSpan={4} align="center" sx={{ color: 'text.secondary' }}>
No activity in this range.
</TableCell>
</TableRow>
)}
{byUser.map((u) => (
<TableRow
key={u.user_id}
hover
selected={drillUser?.user_id === u.user_id}
onClick={() => handleRowClick(u)}
sx={{ cursor: 'pointer' }}
>
<TableCell>{u.username}</TableCell>
<TableCell align="right">{u.assigned.toLocaleString()}</TableCell>
<TableCell align="right">{u.completed.toLocaleString()}</TableCell>
<TableCell align="right">
{u.efficiency === null ? (
<Chip size="small" label="N/A" variant="outlined" />
) : (
<Chip size="small" label={`${u.efficiency}%`} color={efficiencyColor(u.efficiency)} />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{drillUser && (
<>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Records assigned to {drillUser.username}
</Typography>
{drillLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
) : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Anomaly ID</TableCell>
<TableCell>Asset</TableCell>
<TableCell>Plaza</TableCell>
<TableCell>Chainage</TableCell>
<TableCell>Video Date</TableCell>
<TableCell>Status</TableCell>
<TableCell>Audited On</TableCell>
<TableCell>Audited By</TableCell>
</TableRow>
</TableHead>
<TableBody>
{drillRecords.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ color: 'text.secondary' }}>
No records found.
</TableCell>
</TableRow>
)}
{drillRecords.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.id}</TableCell>
<TableCell>{r.Asset || '—'}</TableCell>
<TableCell>{r.Plaza || '—'}</TableCell>
<TableCell>{r.Chainage || '—'}</TableCell>
<TableCell>{r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'}</TableCell>
<TableCell>
{r.IsAudited ? (
<Chip size="small" label="Audited" color="success" />
) : (
<Chip size="small" label="Pending" color="default" />
)}
</TableCell>
<TableCell>{r.Audited_on ? new Date(r.Audited_on).toLocaleString() : '—'}</TableCell>
<TableCell>
{r.audited_username
? r.audited_username === drillUser.username
? r.audited_username
: `${r.audited_username} (reassigned)`
: '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<TablePagination
component="div"
count={drillTotal}
page={drillPage}
onPageChange={(_, newPage) => handleDrillPageChange(newPage)}
rowsPerPage={RECORDS_PAGE_SIZE}
rowsPerPageOptions={[RECORDS_PAGE_SIZE]}
/>
</TableContainer>
)}
</>
)}
</>
)}
</Box>
);
};

View File

@@ -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<DevTicket[]>([]);
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<Record<number, 'open' | 'closed' | null>>({});
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 (
<Box>
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
label="Search (dev, reporter, message, anomaly ID)"
size="small"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ minWidth: 300 }}
/>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Git status</InputLabel>
<Select label="Git status" value={gitStatus} onChange={(e) => setGitStatus(e.target.value as any)}>
<MenuItem value="">All</MenuItem>
<MenuItem value="pending">Pending</MenuItem>
<MenuItem value="success">Success</MenuItem>
<MenuItem value="failed">Failed</MenuItem>
</Select>
</FormControl>
<TextField
label="Created from"
type="date"
size="small"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="Created to"
type="date"
size="small"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Box>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
) : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Anomaly ID</TableCell>
<TableCell>Asset / Plaza</TableCell>
<TableCell>Dev</TableCell>
<TableCell>Reporter</TableCell>
<TableCell>Message</TableCell>
<TableCell>RocketChat</TableCell>
<TableCell>Git</TableCell>
<TableCell>Issue status</TableCell>
<TableCell>Created</TableCell>
</TableRow>
</TableHead>
<TableBody>
{records.length === 0 && (
<TableRow>
<TableCell colSpan={9} align="center" sx={{ color: 'text.secondary' }}>
No dev tickets found.
</TableCell>
</TableRow>
)}
{records.map((t) => {
const liveState = t.git_status === 'success' && t.git_issue_number != null ? issueStates[t.id] : undefined;
return (
<TableRow key={t.id}>
<TableCell>{t.anomaly_id}</TableCell>
<TableCell>{[t.asset, t.plaza].filter(Boolean).join(' / ') || '—'}</TableCell>
<TableCell>@{t.dev_handle}</TableCell>
<TableCell>{t.reporter || '—'}</TableCell>
<TableCell>
<Tooltip title={t.message_text}>
<span>{truncate(t.message_text)}</span>
</Tooltip>
{t.duplicate_of_id && (
<Chip size="small" label={`dup of #${t.duplicate_of_id}`} sx={{ ml: 1 }} variant="outlined" />
)}
</TableCell>
<TableCell>
{t.rocketchat_sent ? (
<Chip size="small" label="Sent" color="success" />
) : (
<Tooltip title={t.rocketchat_error || 'Not sent'}>
<Chip size="small" label="Failed" color="error" />
</Tooltip>
)}
</TableCell>
<TableCell>
{t.git_issue_url ? (
<Link href={t.git_issue_url} target="_blank" rel="noopener noreferrer">
<Chip size="small" label={`#${t.git_issue_number}`} color={gitStatusColor(t.git_status)} clickable />
</Link>
) : (
<Tooltip title={t.git_error || ''}>
<Chip size="small" label={t.git_status} color={gitStatusColor(t.git_status)} />
</Tooltip>
)}
{t.retry_count > 0 && (
<Chip size="small" label={`retries: ${t.retry_count}`} sx={{ ml: 1 }} variant="outlined" />
)}
</TableCell>
<TableCell>
{t.git_status !== 'success' ? (
'—'
) : issueStatesLoading && liveState === undefined ? (
<CircularProgress size={14} />
) : liveState === 'closed' ? (
<Chip size="small" label="Closed" color="default" />
) : liveState === 'open' ? (
<Chip size="small" label="Open" color="info" />
) : (
<Tooltip title="Could not reach Gitea to check">
<Chip size="small" label="Unknown" variant="outlined" />
</Tooltip>
)}
</TableCell>
<TableCell>{new Date(t.created_at).toLocaleString()}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={(_, newPage) => setPage(newPage)}
rowsPerPage={PAGE_SIZE}
rowsPerPageOptions={[PAGE_SIZE]}
/>
</TableContainer>
)}
</Box>
);
};

View File

@@ -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<UserSession[]>([]);
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 (
<Box>
<TextField
label="Filter by username"
value={username}
onChange={(e) => setUsername(e.target.value)}
size="small"
sx={{ mb: 2, width: 300 }}
/>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
) : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Login At</TableCell>
<TableCell>Logout At</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sessions.map((s) => (
<TableRow key={s.id}>
<TableCell>{s.username}</TableCell>
<TableCell>{formatDate(s.login_at)}</TableCell>
<TableCell>
{s.logout_at ? formatDate(s.logout_at) : <Chip size="small" label="Active" color="success" />}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
);
};

View File

@@ -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<AdminUser[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
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<AdminUser | null>(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 (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, gap: 2, flexWrap: 'wrap' }}>
<TextField
label="Search users"
placeholder="Username, email, name or role"
size="small"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ minWidth: 280 }}
/>
<Button variant="contained" onClick={() => setAddOpen(true)}>Add User</Button>
</Box>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
) : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Email</TableCell>
<TableCell>Name</TableCell>
<TableCell>Role</TableCell>
<TableCell>Org (DB)</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredUsers.length === 0 && (
<TableRow>
<TableCell colSpan={7} align="center" sx={{ color: 'text.secondary' }}>
No users match "{search}".
</TableCell>
</TableRow>
)}
{filteredUsers.map((u) => (
<TableRow key={u.user_id}>
<TableCell>{u.username}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>{u.name}</TableCell>
<TableCell>{u.role_name}</TableCell>
<TableCell>{u.db_name}</TableCell>
<TableCell>
<Chip
size="small"
label={u.disabled ? 'Disabled' : 'Active'}
color={u.disabled ? 'default' : 'success'}
/>
</TableCell>
<TableCell align="right">
<Button size="small" onClick={() => openEdit(u)}>Edit</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{/* Add User Dialog */}
<Dialog open={addOpen} onClose={() => setAddOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Add User</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField label="Username" value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} fullWidth />
<TextField label="Email" value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} fullWidth />
<TextField label="Name" value={newUser.name} onChange={(e) => setNewUser({ ...newUser, name: e.target.value })} fullWidth />
<TextField label="Password" type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} fullWidth />
<FormControl fullWidth>
<InputLabel>Role</InputLabel>
<Select label="Role" value={newUser.role_id} onChange={(e) => setNewUser({ ...newUser, role_id: e.target.value })}>
{roles.map((r) => <MenuItem key={r.id} value={r.id}>{r.role_name}</MenuItem>)}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Organization</InputLabel>
<Select
label="Organization"
value={newUser.org_id}
onChange={(e) => handleOrgChange(e.target.value, setNewUser, newUser)}
>
{organizations.map((o) => <MenuItem key={o.org_id} value={o.org_id}>{o.org_name}</MenuItem>)}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleCreate}>Create</Button>
</DialogActions>
</Dialog>
{/* Edit User Dialog */}
<Dialog open={!!editUser} onClose={() => setEditUser(null)} maxWidth="xs" fullWidth>
<DialogTitle>Edit User</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField label="Username" value={editForm.username} onChange={(e) => setEditForm({ ...editForm, username: e.target.value })} fullWidth />
<FormControl fullWidth>
<InputLabel>Role</InputLabel>
<Select label="Role" value={editForm.role_id} onChange={(e) => setEditForm({ ...editForm, role_id: e.target.value })}>
{roles.map((r) => <MenuItem key={r.id} value={r.id}>{r.role_name}</MenuItem>)}
</Select>
</FormControl>
<FormControlLabel
control={<Switch checked={!editForm.disabled} onChange={(e) => setEditForm({ ...editForm, disabled: !e.target.checked })} />}
label={editForm.disabled ? 'Disabled' : 'Active'}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditUser(null)}>Cancel</Button>
<Button variant="contained" onClick={handleUpdate}>Save</Button>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@@ -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<number[]>([]);
const [overview, setOverview] = useState<ClientOverviewOrg[]>([]);
const [overviewLoading, setOverviewLoading] = useState(true);
const [expandedOrgs, setExpandedOrgs] = useState<Set<number>>(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 (
<Box sx={{ width: '100%', maxWidth: '100vw', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1.5, bgcolor: '#0b1121', flexWrap: 'wrap' }}>
@@ -347,6 +423,167 @@ export const ClientsOverview: React.FC = () => {
</ResponsiveContainer>
)}
</Paper>
<Paper sx={{ p: 3, bgcolor: '#0f172a', border: '1px solid #1e293b', borderRadius: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2, flexWrap: 'wrap', gap: 1.5 }}>
<Typography variant="h6" sx={{ color: '#f8fafc', fontWeight: 'bold' }}>
All Clients Records by Site
{(overviewDateFrom || overviewDateTo || overviewAssetIds.length > 0) ? '' : ' (all-time)'}
</Typography>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
type="date"
size="small"
label="From"
value={overviewDateFrom}
onChange={(e) => setOverviewDateFrom(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ '& .MuiOutlinedInput-root': { color: '#f8fafc', bgcolor: 'rgba(255,255,255,0.1)' } }}
/>
<TextField
type="date"
size="small"
label="To"
value={overviewDateTo}
onChange={(e) => setOverviewDateTo(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ '& .MuiOutlinedInput-root': { color: '#f8fafc', bgcolor: 'rgba(255,255,255,0.1)' } }}
/>
<AssetMultiSelect value={overviewAssetIds} onChange={setOverviewAssetIds} />
<Button
variant="contained"
onClick={() => setOverviewType(overviewType === 'audits' ? 'rectification' : 'audits')}
sx={{
bgcolor: overviewType === 'rectification' ? '#ff4d4f' : 'rgba(255,255,255,0.1)',
color: '#fff',
'&:hover': { bgcolor: overviewType === 'rectification' ? '#ff7875' : 'rgba(255,255,255,0.2)' },
borderRadius: 2,
textTransform: 'none',
fontWeight: 'bold',
boxShadow: 'none',
}}
>
{overviewType === 'rectification' ? 'RECTIFICATION' : 'AUDITS'}
</Button>
<Button
variant="outlined"
onClick={fetchOverview}
sx={{ borderColor: '#334155', color: '#94a3b8', textTransform: 'none', fontWeight: 'bold' }}
>
Refresh
</Button>
</Box>
</Box>
{overviewLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : overview.length === 0 ? (
<Typography sx={{ color: '#64748b', textAlign: 'center', py: 8 }}>No data found</Typography>
) : (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', width: 40 }} />
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }}>Organization / Site</TableCell>
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">Total</TableCell>
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">Audited</TableCell>
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">Deleted</TableCell>
<TableCell sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">Non-Audited</TableCell>
</TableRow>
</TableHead>
<TableBody>
{overview.map((org) => {
const isExpanded = expandedOrgs.has(org.org_id);
return (
<Fragment key={org.org_id}>
<TableRow>
<TableCell sx={{ borderColor: '#1e293b' }}>
{org.sites.length > 0 && (
<IconButton size="small" onClick={() => toggleOrgExpanded(org.org_id)} sx={{ color: '#94a3b8' }}>
{isExpanded ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
</IconButton>
)}
</TableCell>
<TableCell sx={{ color: '#f8fafc', borderColor: '#1e293b', fontWeight: 'bold' }}>
{org.org_name}
{org.error && (
<Typography component="span" sx={{ color: '#ef4444', ml: 1, fontSize: '0.75rem' }}>
(failed to load: {org.error})
</Typography>
)}
</TableCell>
<TableCell sx={{ color: '#3b82f6', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
{org.total.toLocaleString()}
</TableCell>
<TableCell sx={{ color: '#22c55e', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
{org.audited.toLocaleString()}
</TableCell>
<TableCell sx={{ color: '#ef4444', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
{org.deleted.toLocaleString()}
</TableCell>
<TableCell sx={{ color: '#eab308', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
{org.nonAudited.toLocaleString()}
</TableCell>
</TableRow>
{org.sites.length > 0 && (
<TableRow>
<TableCell colSpan={6} sx={{ p: 0, borderColor: '#1e293b' }}>
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Table size="small">
<TableBody>
{org.sites.map((site) => (
<TableRow key={site.site_id}>
<TableCell sx={{ borderColor: '#1e293b', width: 40 }} />
<TableCell sx={{ color: '#cbd5e1', borderColor: '#1e293b', pl: 4 }}>
{site.site_name}
</TableCell>
<TableCell sx={{ color: '#93c5fd', borderColor: '#1e293b' }} align="right">
{site.total.toLocaleString()}
</TableCell>
<TableCell sx={{ color: '#86efac', borderColor: '#1e293b' }} align="right">
{site.audited.toLocaleString()}
</TableCell>
<TableCell sx={{ color: '#fca5a5', borderColor: '#1e293b' }} align="right">
{site.deleted.toLocaleString()}
</TableCell>
<TableCell sx={{ color: '#fde047', borderColor: '#1e293b' }} align="right">
{site.nonAudited.toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Collapse>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
<TableRow>
<TableCell sx={{ borderColor: '#1e293b' }} />
<TableCell sx={{ color: '#f8fafc', borderColor: '#1e293b', fontWeight: 'bold' }}>All Clients</TableCell>
<TableCell sx={{ color: '#3b82f6', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
{overviewTotals.total.toLocaleString()}
</TableCell>
<TableCell sx={{ color: '#22c55e', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
{overviewTotals.audited.toLocaleString()}
</TableCell>
<TableCell sx={{ color: '#ef4444', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
{overviewTotals.deleted.toLocaleString()}
</TableCell>
<TableCell sx={{ color: '#eab308', borderColor: '#1e293b', fontWeight: 'bold' }} align="right">
{overviewTotals.nonAudited.toLocaleString()}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
)}
</Paper>
</Box>
</Box>
);

View File

@@ -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<AuthState>()(
},
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());
},
}),
{