feat: implement client overview dashboard and admin portal structure with API services and page components
This commit is contained in:
1
.env
1
.env
@@ -1,2 +1 @@
|
||||
VITE_DASHBOARD_API_URL=https://sr-backend-api.takeleap.in
|
||||
VITE_AUDIT_API_URL=http://localhost:7514
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
254
src/api/adminService.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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.';
|
||||
|
||||
219
src/components/common/AssetMultiSelect.tsx
Normal file
219
src/components/common/AssetMultiSelect.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
{hasRole('ADMIN') && (
|
||||
<Button
|
||||
onClick={() => navigate('/clients')}
|
||||
onClick={() => navigate('/admin')}
|
||||
sx={{
|
||||
color: location.pathname.includes('clients') ? 'primary.main' : 'text.secondary',
|
||||
color: location.pathname.includes('admin') ? 'primary.main' : 'text.secondary',
|
||||
fontWeight: 'bold',
|
||||
borderBottom: location.pathname.includes('clients') ? '2px solid #3b82f6' : '2px solid transparent',
|
||||
borderBottom: location.pathname.includes('admin') ? '2px solid #3b82f6' : '2px solid transparent',
|
||||
borderRadius: 0,
|
||||
px: 2,
|
||||
py: 1
|
||||
}}
|
||||
>
|
||||
Clients
|
||||
Admin
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
|
||||
@@ -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,7 +12,7 @@ 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();
|
||||
@@ -28,51 +29,42 @@ export const Login: React.FC = () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const userData = await accountService.login(username, password);
|
||||
const result = await accountService.login(username, password);
|
||||
|
||||
if (!userData) {
|
||||
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);
|
||||
// Every user picks which org/DB to work in after login.
|
||||
setTempLoginResult(result);
|
||||
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.");
|
||||
}
|
||||
} 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>
|
||||
);
|
||||
|
||||
37
src/pages/admin/AdminConsole.tsx
Normal file
37
src/pages/admin/AdminConsole.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
384
src/pages/admin/Allocation.tsx
Normal file
384
src/pages/admin/Allocation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
352
src/pages/admin/AuditHistory.tsx
Normal file
352
src/pages/admin/AuditHistory.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
234
src/pages/admin/DevTickets.tsx
Normal file
234
src/pages/admin/DevTickets.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
src/pages/admin/LoginHistory.tsx
Normal file
77
src/pages/admin/LoginHistory.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
234
src/pages/admin/UserManagement.tsx
Normal file
234
src/pages/admin/UserManagement.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
if (!user || !user.role) return false;
|
||||
|
||||
const userRoles = user.user_roles.split(',').map(r => r.trim().toLowerCase());
|
||||
const neededRoles = requiredRole.split(',').map(r => r.trim().toLowerCase());
|
||||
|
||||
return neededRoles.some(role => userRoles.includes(role));
|
||||
return neededRoles.includes(user.role.toLowerCase());
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user