feat: implement administrative audit allocation and management dashboard pages with supporting API services
This commit is contained in:
@@ -22,6 +22,12 @@ interface RefreshResponse {
|
||||
expires_in: string;
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
org_id: number | string;
|
||||
org_name: string;
|
||||
db_name: string;
|
||||
}
|
||||
|
||||
export const accountService = {
|
||||
login: async (username: string, password: string) => {
|
||||
const response = await axiosClient.post('/api/audit/account/login', { username, password });
|
||||
@@ -40,5 +46,16 @@ export const accountService = {
|
||||
getOrganizations: async () => {
|
||||
const response = await axiosClient.get('/api/audit/account/organizations');
|
||||
return response;
|
||||
},
|
||||
|
||||
// Orgs this user can work in: full list for ADMIN/MODERATOR, only
|
||||
// orgs-with-assigned-work for everyone else. Called during the login flow,
|
||||
// BEFORE the user is committed to the auth store - so the token must be
|
||||
// passed explicitly (the axios interceptor has nothing to attach yet).
|
||||
getMyOrganizations: async (accessToken: string): Promise<Organization[]> => {
|
||||
const response = await axiosClient.get('/api/audit/account/my-organizations', {
|
||||
headers: { 'x-access-token': accessToken },
|
||||
});
|
||||
return response as unknown as Organization[];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -81,6 +81,22 @@ export const activityFeedsService = {
|
||||
return response;
|
||||
},
|
||||
|
||||
// Dashboard stats scoped to the logged-in user (org-wide for
|
||||
// ADMIN/MODERATOR): overall workload, today's activity, and the workload
|
||||
// split into batches of 50 in the same order the audit session walks.
|
||||
getUserStats: async (isRectificationEnabled: boolean) => {
|
||||
const response = await axiosClient.get('/api/audit/anomaly/user-stats', {
|
||||
params: { isRectificationEnabled }
|
||||
});
|
||||
return response as unknown as {
|
||||
scoped: boolean;
|
||||
batchSize: number;
|
||||
overall: { total: number; audited: number; pending: number; anomaliesFound: number };
|
||||
today: { audited: number; anomaliesFound: number };
|
||||
batches: { batch: number; total: number; audited: number; pending: number; anomaliesFound: number }[];
|
||||
};
|
||||
},
|
||||
|
||||
getFalseAnomaly: async (params: any) => {
|
||||
const response = await axiosClient.get('/api/audit/asset/get-false-Anomaly', { params });
|
||||
return response;
|
||||
|
||||
@@ -183,19 +183,39 @@ export const adminService = {
|
||||
sort: 'oldest' | 'newest';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
// number = that user's queue; the literal string 'null' = unassigned only
|
||||
assignedUserId?: number | 'null';
|
||||
} & AllocationFilters): Promise<AllocationRecordsResult> => {
|
||||
return (await axiosClient.get('/api/audit/admin/allocation/records', { params })) as unknown as AllocationRecordsResult;
|
||||
},
|
||||
|
||||
// Multi-user assign: a count is the TOTAL, split equally across user_ids
|
||||
// (remainder to the first users); count-mode only ever touches unassigned
|
||||
// records. Explicit ids are round-robined across user_ids and may
|
||||
// deliberately overwrite existing assignments.
|
||||
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 };
|
||||
user_ids: number[];
|
||||
} & AllocationFilters): Promise<{ assigned: number; perUser: { user_id: number; assigned: number }[] }> => {
|
||||
return (await axiosClient.post('/api/audit/admin/allocation/assign', payload)) as unknown as {
|
||||
assigned: number; perUser: { user_id: number; assigned: number }[];
|
||||
};
|
||||
},
|
||||
|
||||
// Revert (toUserId: null) or transfer a user's PENDING allocation; completed
|
||||
// records keep their assignment so history stats stay truthful.
|
||||
reassignAllocation: async (payload: {
|
||||
dbName: string;
|
||||
type: 'audits' | 'rectification';
|
||||
fromUserId?: number;
|
||||
toUserId: number | null;
|
||||
ids?: number[];
|
||||
}): Promise<{ moved: number }> => {
|
||||
return (await axiosClient.post('/api/audit/admin/allocation/reassign', payload)) as unknown as { moved: number };
|
||||
},
|
||||
|
||||
getAuditHistorySummary: async (params: {
|
||||
|
||||
@@ -1,58 +1,33 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Dialog, DialogTitle, DialogContent, List, ListItem, ListItemButton, ListItemText, CircularProgress, Box } from '@mui/material';
|
||||
import { accountService } from '../../api/accountService';
|
||||
|
||||
interface Organization {
|
||||
org_name: string;
|
||||
org_id: string;
|
||||
db_name: string;
|
||||
}
|
||||
import React from 'react';
|
||||
import { Dialog, DialogTitle, DialogContent, List, ListItem, ListItemButton, ListItemText } from '@mui/material';
|
||||
import type { Organization } from '../../api/accountService';
|
||||
|
||||
interface DbSelectionDialogProps {
|
||||
open: boolean;
|
||||
// The org list is decided by the login flow (scoped to orgs where the user
|
||||
// has work, or the full list for supervisors) - this dialog just renders it.
|
||||
organizations: Organization[];
|
||||
onClose: (selectedOrg?: Organization) => void;
|
||||
}
|
||||
|
||||
export const DbSelectionDialog: React.FC<DbSelectionDialogProps> = ({ open, onClose }) => {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLoading(true);
|
||||
accountService.getOrganizations()
|
||||
.then((res: any) => {
|
||||
const sorted = res.sort((a: Organization, b: Organization) =>
|
||||
a.org_name.toLowerCase() > b.org_name.toLowerCase() ? 1 : -1
|
||||
);
|
||||
setOrganizations(sorted);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [open]);
|
||||
export const DbSelectionDialog: React.FC<DbSelectionDialogProps> = ({ open, organizations, onClose }) => {
|
||||
const sorted = [...organizations].sort((a, b) =>
|
||||
a.org_name.toLowerCase() > b.org_name.toLowerCase() ? 1 : -1
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={() => onClose()} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Select the organization</DialogTitle>
|
||||
<DialogContent>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<List>
|
||||
{organizations.map((org, index) => (
|
||||
<ListItem disablePadding key={`${org.org_id || org.org_name}-${index}`}>
|
||||
<ListItemButton onClick={() => onClose(org)}>
|
||||
<ListItemText primary={org.org_name} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
<List>
|
||||
{sorted.map((org, index) => (
|
||||
<ListItem disablePadding key={`${org.org_id || org.org_name}-${index}`}>
|
||||
<ListItemButton onClick={() => onClose(org)}>
|
||||
<ListItemText primary={org.org_name} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Box, Button, TextField, Container, Alert, Paper } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { accountService } from '../../api/accountService';
|
||||
import type { Organization } from '../../api/accountService';
|
||||
import { DbSelectionDialog } from './DbSelectionDialog';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
|
||||
@@ -12,6 +13,7 @@ export const Login: React.FC = () => {
|
||||
const [isInvalid, setIsInvalid] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogOrgs, setDialogOrgs] = useState<Organization[]>([]);
|
||||
const [tempLoginResult, setTempLoginResult] = useState<Awaited<ReturnType<typeof accountService.login>> | null>(null);
|
||||
|
||||
const { login, isAuthenticated } = useAuthStore();
|
||||
@@ -24,6 +26,23 @@ export const Login: React.FC = () => {
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const commitLogin = (
|
||||
result: NonNullable<typeof tempLoginResult>,
|
||||
org: { org_id: number | string; db_name: string },
|
||||
) => {
|
||||
login({
|
||||
access_token: result.access_token,
|
||||
refresh_token: result.refresh_token,
|
||||
user_id: result.user.user_id,
|
||||
username: result.user.username,
|
||||
email: result.user.email,
|
||||
role: result.user.role_name,
|
||||
org_id: String(org.org_id),
|
||||
db_name: org.db_name,
|
||||
});
|
||||
navigate('/activity-feeds', { replace: true });
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
setIsInvalid(false);
|
||||
setLoading(true);
|
||||
@@ -38,9 +57,27 @@ export const Login: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Every user picks which org/DB to work in after login.
|
||||
setTempLoginResult(result);
|
||||
setDialogOpen(true);
|
||||
// Org entry is driven by where the user actually has work: supervisors
|
||||
// get the full list, everyone else only orgs with records assigned to
|
||||
// (or audited by) them. One org -> straight in; several -> pick;
|
||||
// none -> land in their profile org with an empty feed.
|
||||
let orgs: Organization[] = [];
|
||||
try {
|
||||
orgs = await accountService.getMyOrganizations(result.access_token);
|
||||
} catch {
|
||||
orgs = [];
|
||||
}
|
||||
|
||||
if (orgs.length === 1) {
|
||||
commitLogin(result, orgs[0]);
|
||||
} else if (orgs.length === 0) {
|
||||
useToastStore.getState().showToast('No work assigned to you yet.', 'info');
|
||||
commitLogin(result, { org_id: result.user.org_id, db_name: result.user.db_name });
|
||||
} else {
|
||||
setTempLoginResult(result);
|
||||
setDialogOrgs(orgs);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsInvalid(true);
|
||||
useToastStore.getState().showToast('Invalid username or password.', 'error');
|
||||
@@ -49,20 +86,10 @@ export const Login: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDbSelection = (selectedOrg?: { org_id: string; db_name: string; org_name?: string }) => {
|
||||
const handleDbSelection = (selectedOrg?: Organization) => {
|
||||
setDialogOpen(false);
|
||||
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,
|
||||
});
|
||||
navigate('/activity-feeds', { replace: true });
|
||||
commitLogin(tempLoginResult, selectedOrg);
|
||||
}
|
||||
setTempLoginResult(null);
|
||||
};
|
||||
@@ -198,7 +225,7 @@ export const Login: React.FC = () => {
|
||||
</Paper>
|
||||
</Container>
|
||||
|
||||
<DbSelectionDialog open={dialogOpen} onClose={handleDbSelection} />
|
||||
<DbSelectionDialog open={dialogOpen} organizations={dialogOrgs} onClose={handleDbSelection} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Paper, Checkbox, FormControl, InputLabel, Select, MenuItem, ToggleButtonGroup,
|
||||
ToggleButton, Button, CircularProgress, TablePagination, Typography, Divider,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, DialogContentText, TextField,
|
||||
ListItemText, Alert,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { AllocationRecord, AdminUser } from '../../api/adminService';
|
||||
@@ -52,6 +53,19 @@ const parseSites = (sitesRes: any): SiteOption[] => {
|
||||
const PAGE_SIZE = 25;
|
||||
const BULK_PRESETS = [1000, 2000, 5000, 10000];
|
||||
|
||||
// count split equally, remainder to the first users: 100/3 -> 34/33/33
|
||||
const computeSplit = (count: number, n: number): number[] => {
|
||||
const base = Math.floor(count / n);
|
||||
const rem = count % n;
|
||||
return Array.from({ length: n }, (_, i) => base + (i < rem ? 1 : 0));
|
||||
};
|
||||
|
||||
type ConfirmAction =
|
||||
| { kind: 'bulk'; count: number }
|
||||
| { kind: 'unassignAll' }
|
||||
| { kind: 'moveAll'; toUserId: number }
|
||||
| { kind: 'unassignSelected' };
|
||||
|
||||
export const Allocation: React.FC = () => {
|
||||
const { assets } = useFeedStore();
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
@@ -66,7 +80,11 @@ export const Allocation: React.FC = () => {
|
||||
const [sites, setSites] = useState<SiteOption[]>([]);
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
// '' = all, 'null' = unassigned, otherwise a user_id string
|
||||
const [assignedFilter, setAssignedFilter] = useState('');
|
||||
const [customCount, setCustomCount] = useState('');
|
||||
const [moveTargetId, setMoveTargetId] = useState('');
|
||||
|
||||
const [records, setRecords] = useState<AllocationRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -74,7 +92,7 @@ export const Allocation: React.FC = () => {
|
||||
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);
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -133,6 +151,7 @@ export const Allocation: React.FC = () => {
|
||||
dateTo: effectiveDateTo,
|
||||
assetId: assetId || undefined,
|
||||
siteId: siteId || undefined,
|
||||
assignedUserId: assignedFilter === '' ? undefined : assignedFilter === 'null' ? 'null' : Number(assignedFilter),
|
||||
});
|
||||
setRecords(result.records);
|
||||
setTotal(result.total);
|
||||
@@ -141,7 +160,7 @@ export const Allocation: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dbName, type, sort, page, effectiveDateFrom, effectiveDateTo, assetId, siteId]);
|
||||
}, [dbName, type, sort, page, effectiveDateFrom, effectiveDateTo, assetId, siteId, assignedFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -150,7 +169,7 @@ export const Allocation: React.FC = () => {
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
setSelectedIds(new Set());
|
||||
}, [dbName, type, sort, dateFrom, dateTo, assetId, siteId]);
|
||||
}, [dbName, type, sort, dateFrom, dateTo, assetId, siteId, assignedFilter]);
|
||||
|
||||
const toggleRow = (id: number) => {
|
||||
setSelectedIds((prev) => {
|
||||
@@ -169,48 +188,82 @@ export const Allocation: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAllocate = async () => {
|
||||
if (selectedIds.size === 0 || !selectedUserId) return;
|
||||
const selectedUsers = users.filter((u) => selectedUserIds.includes(u.user_id));
|
||||
const selectedOrg = organizations.find((o) => o.db_name === dbName);
|
||||
const filterUser = users.find((u) => String(u.user_id) === assignedFilter);
|
||||
|
||||
const perUserToast = (perUser: { user_id: number; assigned: number }[]) =>
|
||||
perUser
|
||||
.map((p) => `${users.find((u) => u.user_id === p.user_id)?.username || `#${p.user_id}`}: ${p.assigned}`)
|
||||
.join(', ');
|
||||
|
||||
const handleAllocateSelected = async () => {
|
||||
if (selectedIds.size === 0 || selectedUserIds.length === 0) return;
|
||||
setAssigning(true);
|
||||
try {
|
||||
const result = await adminService.assignAllocation({
|
||||
dbName, type, ids: Array.from(selectedIds), user_id: Number(selectedUserId),
|
||||
dbName, type, ids: Array.from(selectedIds), user_ids: selectedUserIds,
|
||||
});
|
||||
useToastStore.getState().showToast(`Allocated ${result.assigned} record(s).`, 'success');
|
||||
useToastStore.getState().showToast(
|
||||
`Allocated ${result.assigned} record(s) — ${perUserToast(result.perUser)}`, 'success');
|
||||
setSelectedIds(new Set());
|
||||
load();
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to allocate records.', 'error');
|
||||
} catch (err: any) {
|
||||
useToastStore.getState().showToast(err?.response?.data?.message || 'Failed to allocate records.', 'error');
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAllocate = async (count: number) => {
|
||||
if (!selectedUserId) return;
|
||||
setBulkConfirmCount(null);
|
||||
if (selectedUserIds.length === 0) return;
|
||||
setConfirmAction(null);
|
||||
setAssigning(true);
|
||||
try {
|
||||
const result = await adminService.assignAllocation({
|
||||
dbName, type, count, sort, user_id: Number(selectedUserId),
|
||||
dbName, type, count, sort, user_ids: selectedUserIds,
|
||||
dateFrom: effectiveDateFrom,
|
||||
dateTo: effectiveDateTo,
|
||||
assetId: assetId || undefined,
|
||||
siteId: siteId || undefined,
|
||||
});
|
||||
useToastStore.getState().showToast(`Allocated ${result.assigned} record(s).`, 'success');
|
||||
useToastStore.getState().showToast(
|
||||
`Allocated ${result.assigned} record(s) — ${perUserToast(result.perUser)}`, 'success');
|
||||
setSelectedIds(new Set());
|
||||
setPage(0);
|
||||
load();
|
||||
} catch (err) {
|
||||
useToastStore.getState().showToast('Failed to bulk-allocate records.', 'error');
|
||||
} catch (err: any) {
|
||||
useToastStore.getState().showToast(err?.response?.data?.message || '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);
|
||||
const handleReassign = async (params: { fromUserId?: number; toUserId: number | null; ids?: number[] }) => {
|
||||
setConfirmAction(null);
|
||||
setAssigning(true);
|
||||
try {
|
||||
const result = await adminService.reassignAllocation({ dbName, type, ...params });
|
||||
const verb = params.toUserId === null ? 'Unassigned' : 'Moved';
|
||||
useToastStore.getState().showToast(`${verb} ${result.moved} pending record(s).`, 'success');
|
||||
setSelectedIds(new Set());
|
||||
setMoveTargetId('');
|
||||
load();
|
||||
} catch (err: any) {
|
||||
useToastStore.getState().showToast(err?.response?.data?.message || 'Failed to update allocation.', 'error');
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const customCountNum = parseInt(customCount, 10);
|
||||
const customCountValid = Number.isInteger(customCountNum) && customCountNum > 0 && customCountNum <= 10000;
|
||||
|
||||
const splitPreview = (count: number) => {
|
||||
if (selectedUsers.length === 0) return '';
|
||||
const split = computeSplit(count, selectedUsers.length);
|
||||
return selectedUsers.map((u, i) => `${u.username}: ${split[i]}`).join(' · ');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -274,12 +327,72 @@ export const Allocation: React.FC = () => {
|
||||
{sites.map((s) => <MenuItem key={s.site_id} value={s.site_id}>{s.site_name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Assigned to</InputLabel>
|
||||
<Select label="Assigned to" value={assignedFilter} onChange={(e) => setAssignedFilter(e.target.value)}>
|
||||
<MenuItem value="">All records</MenuItem>
|
||||
<MenuItem value="null">Unassigned only</MenuItem>
|
||||
{users.map((u) => <MenuItem key={u.user_id} value={String(u.user_id)}>{u.username}</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>)}
|
||||
{/* Absent-user actions: visible when viewing one user's queue */}
|
||||
{filterUser && (
|
||||
<Alert severity="info" sx={{ mb: 2, alignItems: 'center' }}
|
||||
action={
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
|
||||
<Button
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
disabled={assigning || total === 0}
|
||||
onClick={() => setConfirmAction({ kind: 'unassignAll' })}
|
||||
>
|
||||
Unassign all pending
|
||||
</Button>
|
||||
<FormControl size="small" sx={{ minWidth: 170 }}>
|
||||
<InputLabel>Move all pending to</InputLabel>
|
||||
<Select
|
||||
label="Move all pending to"
|
||||
value={moveTargetId}
|
||||
disabled={assigning || total === 0}
|
||||
onChange={(e) => {
|
||||
const target = Number(e.target.value);
|
||||
setMoveTargetId(e.target.value);
|
||||
setConfirmAction({ kind: 'moveAll', toUserId: target });
|
||||
}}
|
||||
>
|
||||
{users.filter((u) => String(u.user_id) !== assignedFilter).map((u) => (
|
||||
<MenuItem key={u.user_id} value={String(u.user_id)}>{u.username}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{total.toLocaleString()} pending record(s) assigned to <strong>{filterUser.username}</strong>
|
||||
{' '}(matching current filters)
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 280, mb: 2 }}>
|
||||
<InputLabel>Allocate to (select one or more)</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
label="Allocate to (select one or more)"
|
||||
value={selectedUserIds}
|
||||
onChange={(e) => setSelectedUserIds(typeof e.target.value === 'string' ? [] : (e.target.value as number[]))}
|
||||
renderValue={(ids) =>
|
||||
users.filter((u) => (ids as number[]).includes(u.user_id)).map((u) => u.username).join(', ')
|
||||
}
|
||||
>
|
||||
{users.map((u) => (
|
||||
<MenuItem key={u.user_id} value={u.user_id}>
|
||||
<Checkbox checked={selectedUserIds.includes(u.user_id)} size="small" />
|
||||
<ListItemText primary={`${u.username} (${u.role_name})`} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
@@ -287,26 +400,53 @@ export const Allocation: React.FC = () => {
|
||||
<Typography variant="body2">{selectedIds.size} selected on this page</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={selectedIds.size === 0 || !selectedUserId || assigning}
|
||||
onClick={handleAllocate}
|
||||
disabled={selectedIds.size === 0 || selectedUserIds.length === 0 || assigning}
|
||||
onClick={handleAllocateSelected}
|
||||
>
|
||||
Allocate selected
|
||||
{selectedUserIds.length > 1 ? 'Split selected equally' : 'Allocate selected'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
disabled={selectedIds.size === 0 || assigning}
|
||||
onClick={() => setConfirmAction({ kind: 'unassignSelected' })}
|
||||
>
|
||||
Unassign 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>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Bulk assign unassigned records (total, split equally · matches current filters):
|
||||
</Typography>
|
||||
{BULK_PRESETS.map((count) => (
|
||||
<Button
|
||||
key={count}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={!selectedUserId || assigning}
|
||||
onClick={() => setBulkConfirmCount(count)}
|
||||
disabled={selectedUserIds.length === 0 || assigning}
|
||||
onClick={() => setConfirmAction({ kind: 'bulk', count })}
|
||||
>
|
||||
{count.toLocaleString()}
|
||||
</Button>
|
||||
))}
|
||||
<TextField
|
||||
label="Custom count"
|
||||
size="small"
|
||||
type="number"
|
||||
value={customCount}
|
||||
onChange={(e) => setCustomCount(e.target.value)}
|
||||
sx={{ width: 130 }}
|
||||
slotProps={{ htmlInput: { min: 1, max: 10000 } }}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={selectedUserIds.length === 0 || !customCountValid || assigning}
|
||||
onClick={() => setConfirmAction({ kind: 'bulk', count: customCountNum })}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
@@ -360,20 +500,57 @@ export const Allocation: React.FC = () => {
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<Dialog open={bulkConfirmCount !== null} onClose={() => setBulkConfirmCount(null)}>
|
||||
<DialogTitle>Confirm bulk allocation</DialogTitle>
|
||||
<Dialog open={confirmAction !== null} onClose={() => { setConfirmAction(null); setMoveTargetId(''); }}>
|
||||
<DialogTitle>
|
||||
{confirmAction?.kind === 'bulk' && 'Confirm bulk allocation'}
|
||||
{confirmAction?.kind === 'unassignAll' && 'Unassign all pending'}
|
||||
{confirmAction?.kind === 'moveAll' && 'Move all pending'}
|
||||
{confirmAction?.kind === 'unassignSelected' && 'Unassign selected'}
|
||||
</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>
|
||||
{confirmAction?.kind === 'bulk' && (
|
||||
<DialogContentText component="div">
|
||||
Split the first {confirmAction.count.toLocaleString()} {sort} <strong>unassigned</strong> {type} record(s) in{' '}
|
||||
{selectedOrg?.org_name || dbName} (matching the current date/asset/site filters) equally:
|
||||
<Box sx={{ mt: 1.5, fontFamily: 'monospace', fontSize: '0.9rem' }}>
|
||||
{splitPreview(confirmAction.count)}
|
||||
</Box>
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
If fewer records are available, users earlier in the list are filled first.
|
||||
</Box>
|
||||
</DialogContentText>
|
||||
)}
|
||||
{confirmAction?.kind === 'unassignAll' && (
|
||||
<DialogContentText>
|
||||
Remove the assignment from all {total.toLocaleString()} pending record(s) currently allocated to{' '}
|
||||
<strong>{filterUser?.username}</strong> in {selectedOrg?.org_name || dbName}? Records they already
|
||||
audited keep their name for history/efficiency stats.
|
||||
</DialogContentText>
|
||||
)}
|
||||
{confirmAction?.kind === 'moveAll' && (
|
||||
<DialogContentText>
|
||||
Move all {total.toLocaleString()} pending record(s) from <strong>{filterUser?.username}</strong> to{' '}
|
||||
<strong>{users.find((u) => u.user_id === confirmAction.toUserId)?.username}</strong>? Records already
|
||||
audited by {filterUser?.username} keep their name for history/efficiency stats.
|
||||
</DialogContentText>
|
||||
)}
|
||||
{confirmAction?.kind === 'unassignSelected' && (
|
||||
<DialogContentText>
|
||||
Remove the assignment from the {selectedIds.size} selected pending record(s)?
|
||||
</DialogContentText>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setBulkConfirmCount(null)}>Cancel</Button>
|
||||
<Button onClick={() => { setConfirmAction(null); setMoveTargetId(''); }}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => bulkConfirmCount !== null && handleBulkAllocate(bulkConfirmCount)}
|
||||
onClick={() => {
|
||||
if (!confirmAction) return;
|
||||
if (confirmAction.kind === 'bulk') handleBulkAllocate(confirmAction.count);
|
||||
else if (confirmAction.kind === 'unassignAll') handleReassign({ fromUserId: Number(assignedFilter), toUserId: null });
|
||||
else if (confirmAction.kind === 'moveAll') handleReassign({ fromUserId: Number(assignedFilter), toUserId: confirmAction.toUserId });
|
||||
else if (confirmAction.kind === 'unassignSelected') handleReassign({ toUserId: null, ids: Array.from(selectedIds) });
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
|
||||
@@ -418,56 +418,97 @@ export const AuditSession: React.FC = () => {
|
||||
notesVal: string,
|
||||
plantSub?: string | null,
|
||||
notAnAnomalySub?: string | null
|
||||
) => {
|
||||
if (!currentAnomaly || !user) return;
|
||||
): Promise<boolean> => {
|
||||
if (!currentAnomaly || !user) return false;
|
||||
const recordId = currentAnomaly.id;
|
||||
const toast = useToastStore.getState().showToast;
|
||||
// Prefer the backend's own message so the toast says WHY it failed.
|
||||
const backendMsg = (err: any) =>
|
||||
err?.response?.data?.message || err?.response?.data?.error || err?.message || 'Unknown error';
|
||||
|
||||
const isAnomalyBool = anomalyVal ?? true;
|
||||
const categories = isAnomalyBool ? ANOMALY_CATEGORIES : SAFE_CATEGORIES;
|
||||
const matchedCategory = categories.find(c => c.id === catVal);
|
||||
|
||||
let auditValueVal = matchedCategory ? matchedCategory.value : '';
|
||||
if (isAnomalyBool && catVal === 1 && plantSub) {
|
||||
auditValueVal = plantSub;
|
||||
} else if (!isAnomalyBool && catVal === 1 && notAnAnomalySub) {
|
||||
auditValueVal = notAnAnomalySub;
|
||||
}
|
||||
|
||||
// Body matches exactly what the API expects (dbName + isRectificationEnabled go in URL params via axiosClient)
|
||||
const auditPayload = {
|
||||
Audit_value: auditValueVal,
|
||||
Comments: notesVal || '',
|
||||
Audit_status: isAnomalyBool, // boolean true/false, not 1/0
|
||||
Frame_Test: currentAnomaly.Frame_Test || '',
|
||||
Frame_Master: currentAnomaly.Frame_Master || '',
|
||||
Chainage: currentAnomaly.Chainage || '',
|
||||
Pos: currentAnomaly.Pos || '',
|
||||
id: recordId,
|
||||
road: currentAnomaly.road || 'mcw',
|
||||
Algorithm: currentAnomaly.Algorithm || '',
|
||||
user_id: user.user_id || 0,
|
||||
};
|
||||
|
||||
// Call API 1: Save audit result (POST /anomaly)
|
||||
try {
|
||||
const isAnomalyBool = anomalyVal ?? true;
|
||||
const categories = isAnomalyBool ? ANOMALY_CATEGORIES : SAFE_CATEGORIES;
|
||||
const matchedCategory = categories.find(c => c.id === catVal);
|
||||
|
||||
let auditValueVal = matchedCategory ? matchedCategory.value : '';
|
||||
if (isAnomalyBool && catVal === 1 && plantSub) {
|
||||
auditValueVal = plantSub;
|
||||
} else if (!isAnomalyBool && catVal === 1 && notAnAnomalySub) {
|
||||
auditValueVal = notAnAnomalySub;
|
||||
}
|
||||
|
||||
// Body matches exactly what the API expects (dbName + isRectificationEnabled go in URL params via axiosClient)
|
||||
const auditPayload = {
|
||||
Audit_value: auditValueVal,
|
||||
Comments: notesVal || '',
|
||||
Audit_status: isAnomalyBool, // boolean true/false, not 1/0
|
||||
Frame_Test: currentAnomaly.Frame_Test || '',
|
||||
Frame_Master: currentAnomaly.Frame_Master || '',
|
||||
Chainage: currentAnomaly.Chainage || '',
|
||||
Pos: currentAnomaly.Pos || '',
|
||||
id: currentAnomaly.id,
|
||||
road: currentAnomaly.road || 'mcw',
|
||||
Algorithm: currentAnomaly.Algorithm || '',
|
||||
user_id: user.user_id || 0,
|
||||
};
|
||||
|
||||
// Call API 1: Save audit result
|
||||
await activityFeedsService.updateAnomaly([auditPayload], isRectActive);
|
||||
|
||||
// Call API 2: Notify dashboard — only for true anomaly, endpoint differs by mode
|
||||
if (isAnomalyBool) {
|
||||
if (isRectActive) {
|
||||
// Rectification mode: /auditor/anomaly/close, site_id as number
|
||||
await activityFeedsService.markAnomalyCompleted({
|
||||
anomaly_id: [currentAnomaly.id],
|
||||
site_id: currentAnomaly.site_id
|
||||
});
|
||||
} else {
|
||||
// Normal audit mode: /auditor/anomaly/submit, site_id as string
|
||||
await activityFeedsService.updateAnomalyInDashboard({
|
||||
site_id: String(currentAnomaly.site_id),
|
||||
anomaly_id: [currentAnomaly.id]
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to submit audit state:', err);
|
||||
console.error('Failed to save audit:', err);
|
||||
toast(`#${recordId}: saving audit failed — ${backendMsg(err)}`, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
// The backend soft-deletes (deleted = 1) for these exact Audit_values -
|
||||
// mirror of the list in anomalyModel.update. Note the UI's "low light
|
||||
// condition" does NOT match the backend's "low light", so from this
|
||||
// screen only "duplicate" soft-deletes.
|
||||
const SOFT_DELETE_AUDIT_VALUES = ['duplicate', 'comparison error', 'low light', 'far asset', 'no detection'];
|
||||
const softDeleted = SOFT_DELETE_AUDIT_VALUES.includes(auditValueVal);
|
||||
|
||||
if (!isAnomalyBool) {
|
||||
toast(
|
||||
softDeleted
|
||||
? `#${recordId} saved as "${auditValueVal}" — record soft-deleted`
|
||||
: `#${recordId} saved as false audit ("${auditValueVal}")`,
|
||||
softDeleted ? 'warning' : 'success'
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Call API 2: true anomaly -> create/update the anomaly record
|
||||
// (rectification: /auditor/anomaly/close; audits: /auditor/anomaly/submit)
|
||||
try {
|
||||
const res: any = isRectActive
|
||||
? await activityFeedsService.markAnomalyCompleted({
|
||||
anomaly_id: [recordId],
|
||||
site_id: currentAnomaly.site_id
|
||||
})
|
||||
: await activityFeedsService.updateAnomalyInDashboard({
|
||||
site_id: String(currentAnomaly.site_id),
|
||||
anomaly_id: [recordId]
|
||||
});
|
||||
|
||||
// Both endpoints report per-id outcomes - surface partial failures.
|
||||
if (res?.error_audit_ids?.length) {
|
||||
toast(`#${recordId}: backend error while processing the anomaly (details logged on the record's "errors" column)`, 'error');
|
||||
return false;
|
||||
}
|
||||
if (res?.skipped_audit_ids?.length) {
|
||||
toast(`#${recordId}: audit saved, but the backend skipped anomaly processing (record not found or not eligible)`, 'warning');
|
||||
return true;
|
||||
}
|
||||
toast(
|
||||
`#${recordId} audited as true anomaly ("${auditValueVal}") — anomaly record ${isRectActive ? 'closed' : 'created/updated'}`,
|
||||
'success'
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Anomaly processing failed:', err);
|
||||
toast(`#${recordId}: audit saved, but anomaly processing failed — ${backendMsg(err)}`, 'error');
|
||||
return false;
|
||||
}
|
||||
}, [currentAnomaly, user, isRectActive]);
|
||||
|
||||
@@ -485,14 +526,16 @@ export const AuditSession: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
setItemAuditStates(prev => {
|
||||
const next = [...prev];
|
||||
next[currentIndex] = 'audited';
|
||||
return next;
|
||||
});
|
||||
|
||||
// Call the API
|
||||
await saveAuditState(isAnomaly, selectedCategory, notes, plantSubCategory, notAnAnomalySubCategory);
|
||||
// Call the API - only mark the tile audited if the save really succeeded,
|
||||
// so a red toast + still-pending tile point at exactly the failed item.
|
||||
const ok = await saveAuditState(isAnomaly, selectedCategory, notes, plantSubCategory, notAnAnomalySubCategory);
|
||||
if (ok) {
|
||||
setItemAuditStates(prev => {
|
||||
const next = [...prev];
|
||||
next[currentIndex] = 'audited';
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
handleNext();
|
||||
}, [currentIndex, currentAnomaly, isAnomaly, selectedCategory, notes, plantSubCategory, notAnAnomalySubCategory, saveAuditState, handleNext]);
|
||||
@@ -505,14 +548,16 @@ export const AuditSession: React.FC = () => {
|
||||
notAnAnomalySub?: string | null
|
||||
) => {
|
||||
if (!currentAnomaly) return;
|
||||
setItemAuditStates(prev => {
|
||||
const next = [...prev];
|
||||
next[currentIndex] = 'audited';
|
||||
return next;
|
||||
});
|
||||
|
||||
// Call the API
|
||||
await saveAuditState(anomalyVal, catVal, notes, plantSub, notAnAnomalySub);
|
||||
// Call the API - only mark the tile audited if the save really succeeded.
|
||||
const ok = await saveAuditState(anomalyVal, catVal, notes, plantSub, notAnAnomalySub);
|
||||
if (ok) {
|
||||
setItemAuditStates(prev => {
|
||||
const next = [...prev];
|
||||
next[currentIndex] = 'audited';
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (currentIndex < anomalies.length - 1) {
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Typography, Button, Paper } from '@mui/material';
|
||||
import { Box, Typography, Button, Paper, LinearProgress, Chip } from '@mui/material';
|
||||
import { activityFeedsService } from '../../api/activityFeedsService';
|
||||
import { TopFilterBar } from '../../components/common/TopFilterBar';
|
||||
import { useFeedStore } from '../../store/feedStore';
|
||||
|
||||
type UserStats = Awaited<ReturnType<typeof activityFeedsService.getUserStats>>;
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const {
|
||||
selectedSite, auditStatus, filterFromDate, filterTillDate,
|
||||
@@ -26,8 +28,10 @@ export const Dashboard: React.FC = () => {
|
||||
// Build a key that uniquely identifies the current filter state
|
||||
const currentFilterKey = `${selectedSiteId}|${auditStatus}|${filterFromDate}|${filterTillDate}|${assetIdsKey}|${filterMinChainage}|${filterMaxChainage}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeDate}`;
|
||||
|
||||
// Initialise stats from cache immediately (no flicker on tab switch)
|
||||
const [stats, setStats] = useState(() =>
|
||||
// Session-start info (which page the audit session should open on) - still
|
||||
// derived from the filtered feed, same as before. The headline stats below
|
||||
// no longer come from this; they come from /anomaly/user-stats.
|
||||
const [sessionInfo, setSessionInfo] = useState(() =>
|
||||
dashboardCache.stats ?? {
|
||||
globalTotal: 0,
|
||||
batchTotal: 0,
|
||||
@@ -37,20 +41,30 @@ export const Dashboard: React.FC = () => {
|
||||
firstPendingIndex: -1,
|
||||
}
|
||||
);
|
||||
// Only show loading spinner if there's no cached result for these filters
|
||||
const [loading, setLoading] = useState(dashboardCache.stats === null);
|
||||
|
||||
// User-scoped stats: overall workload, today's activity, batches of 50.
|
||||
const [userStats, setUserStats] = useState<UserStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setStatsLoading(true);
|
||||
activityFeedsService.getUserStats(isRectActive)
|
||||
.then((s) => { if (!cancelled) setUserStats(s); })
|
||||
.catch((err) => console.error('Failed to load user stats', err))
|
||||
.finally(() => { if (!cancelled) setStatsLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [isRectActive]);
|
||||
|
||||
useEffect(() => {
|
||||
// CACHE HIT: filters unchanged since last fetch — skip API call entirely
|
||||
if (dashboardCache.stats !== null && currentFilterKey === `${dashboardCache.siteId}|${dashboardCache.auditStatus}|${dashboardCache.fromDate}|${dashboardCache.tillDate}|${dashboardCache.assetIds}|${dashboardCache.minChainage}|${dashboardCache.maxChainage}|${dashboardCache.searchAnomalyId}|${dashboardCache.filterPlaza}|${dashboardCache.isRectificationEnabled ?? false}|${dashboardCache.date ?? ''}`) {
|
||||
setStats(dashboardCache.stats);
|
||||
setLoading(false);
|
||||
setSessionInfo(dashboardCache.stats);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchStats = async () => {
|
||||
const fetchSessionInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res: any = await activityFeedsService.getHistory({
|
||||
date: activeDate,
|
||||
pageNo: 0,
|
||||
@@ -80,7 +94,7 @@ export const Dashboard: React.FC = () => {
|
||||
batchAnomaliesFound: anomalies.filter((a: any) => a.Audit_status === 1).length,
|
||||
firstPendingIndex: anomalies.findIndex((a: any) => a.IsAudited === 0)
|
||||
};
|
||||
setStats(newStats);
|
||||
setSessionInfo(newStats);
|
||||
// Persist to store cache so re-mounting the Dashboard skips this fetch
|
||||
setDashboardCache({
|
||||
siteId: selectedSiteId,
|
||||
@@ -98,15 +112,31 @@ export const Dashboard: React.FC = () => {
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load dashboard stats', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
console.error('Failed to load dashboard session info', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
fetchSessionInfo();
|
||||
}, [currentFilterKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const overall = userStats?.overall;
|
||||
const today = userStats?.today;
|
||||
const batches = userStats?.batches ?? [];
|
||||
// The active batch is the first one with anything left to do.
|
||||
const activeBatchIdx = batches.findIndex((b) => b.pending > 0);
|
||||
const scopeLabel = userStats?.scoped ? 'your records, all sites' : 'org-wide, all sites';
|
||||
|
||||
const statCard = (label: string, value: number | undefined, color: string, bg: string, border: string, caption: string) => (
|
||||
<Paper sx={{ flex: 1, p: 3, bgcolor: bg, border: `1px solid ${border}`, borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>{label}</Typography>
|
||||
<Typography variant="h4" sx={{ color, fontWeight: 'bold' }}>
|
||||
{statsLoading || value === undefined ? '...' : value.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.5, display: 'block' }}>
|
||||
{caption}
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', maxWidth: '100vw', display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -114,75 +144,71 @@ export const Dashboard: React.FC = () => {
|
||||
<Box sx={{ px: 2, pt: 2, pb: 4, width: '100%' }}>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
|
||||
{/* Stats Row */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||
{/* Active Batch */}
|
||||
<Paper sx={{
|
||||
flex: 1, p: 3, bgcolor: '#0f172a', border: '1px solid #1e293b', borderRadius: 2
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>Active Batch</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#3b82f6', fontWeight: 'bold' }}>
|
||||
{loading ? '...' : stats.batchTotal.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.5, display: 'block' }}>
|
||||
of {loading ? '...' : stats.globalTotal.toLocaleString()} total items on site
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Audited */}
|
||||
<Paper sx={{
|
||||
flex: 1, p: 3, bgcolor: '#052e16', border: '1px solid #14532d', borderRadius: 2
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>Audited</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#22c55e', fontWeight: 'bold' }}>
|
||||
{loading ? '...' : stats.batchAudited.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#475569', mt: 0.5, display: 'block' }}>
|
||||
in current batch
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Pending */}
|
||||
<Paper sx={{
|
||||
flex: 1, p: 3, bgcolor: '#422006', border: '1px solid #713f12', borderRadius: 2
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>Pending</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#eab308', fontWeight: 'bold' }}>
|
||||
{loading ? '...' : stats.batchPending.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#713f12', mt: 0.5, display: 'block' }}>
|
||||
in current batch
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Anomalies Found */}
|
||||
<Paper sx={{
|
||||
flex: 1, p: 3, bgcolor: '#450a0a', border: '1px solid #7f1d1d', borderRadius: 2
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 1 }}>Anomalies Found</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#ef4444', fontWeight: 'bold' }}>
|
||||
{loading ? '...' : stats.batchAnomaliesFound.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#7f1d1d', mt: 0.5, display: 'block' }}>
|
||||
in current batch
|
||||
</Typography>
|
||||
</Paper>
|
||||
{/* Overall stats — the logged-in user's whole workload */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
{statCard('Total Workload', overall?.total, '#3b82f6', '#0f172a', '#1e293b', `overall (${scopeLabel})`)}
|
||||
{statCard('Audited', overall?.audited, '#22c55e', '#052e16', '#14532d', `overall (${scopeLabel})`)}
|
||||
{statCard('Pending', overall?.pending, '#eab308', '#422006', '#713f12', `overall (${scopeLabel})`)}
|
||||
{statCard('Anomalies Found', overall?.anomaliesFound, '#ef4444', '#450a0a', '#7f1d1d', `overall (${scopeLabel})`)}
|
||||
</Box>
|
||||
|
||||
{/* Today's stats */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||
{statCard('Audited Today', today?.audited, '#22c55e', '#0f172a', '#1e293b', userStats?.scoped ? 'completed by you today' : 'completed today (org-wide)')}
|
||||
{statCard('Anomalies Found Today', today?.anomaliesFound, '#ef4444', '#0f172a', '#1e293b', userStats?.scoped ? 'true anomalies you confirmed today' : 'true anomalies confirmed today (org-wide)')}
|
||||
</Box>
|
||||
|
||||
{/* Batches of 50 */}
|
||||
<Paper sx={{ p: 3, bgcolor: '#0f172a', border: '1px solid #1e293b', borderRadius: 2, mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ color: '#f8fafc', fontWeight: 'bold', mb: 2 }}>
|
||||
Batches <Typography component="span" variant="caption" sx={{ color: '#64748b' }}>({userStats?.batchSize ?? 50} items each, in session order)</Typography>
|
||||
</Typography>
|
||||
{statsLoading ? (
|
||||
<Typography sx={{ color: '#64748b' }}>Loading…</Typography>
|
||||
) : batches.length === 0 ? (
|
||||
<Typography sx={{ color: '#64748b' }}>No items in your workload yet.</Typography>
|
||||
) : (
|
||||
batches.map((b, i) => {
|
||||
const pct = b.total > 0 ? Math.round((b.audited / b.total) * 100) : 0;
|
||||
const isActive = i === activeBatchIdx;
|
||||
return (
|
||||
<Box key={b.batch} sx={{ mb: 1.5, p: 1.5, borderRadius: 1.5, border: `1px solid ${isActive ? '#3b82f6' : '#1e293b'}`, bgcolor: isActive ? 'rgba(59,130,246,0.08)' : 'transparent' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
|
||||
<Typography variant="body2" sx={{ color: '#f8fafc', fontWeight: 'bold' }}>
|
||||
Batch {b.batch}
|
||||
</Typography>
|
||||
{isActive && <Chip size="small" label="Active" color="primary" />}
|
||||
{b.pending === 0 && <Chip size="small" label="Complete" sx={{ bgcolor: '#052e16', color: '#22c55e' }} />}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||
{b.audited}/{b.total} audited · {b.pending} pending · {b.anomaliesFound} anomalies
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={pct}
|
||||
sx={{
|
||||
height: 8, borderRadius: 4, bgcolor: '#1e293b',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: b.pending === 0 ? '#22c55e' : '#3b82f6', borderRadius: 4 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
|
||||
{/* Start Button */}
|
||||
{/* Start Button — unchanged behavior, driven by the filtered feed */}
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={() => navigate('/audit-session', {
|
||||
state: {
|
||||
isReviewMode: stats.batchPending === 0,
|
||||
startPage: stats.firstPendingIndex !== undefined && stats.firstPendingIndex !== -1 ? Math.floor(stats.firstPendingIndex / 50) : 0
|
||||
isReviewMode: sessionInfo.batchPending === 0,
|
||||
startPage: sessionInfo.firstPendingIndex !== undefined && sessionInfo.firstPendingIndex !== -1 ? Math.floor(sessionInfo.firstPendingIndex / 50) : 0
|
||||
}
|
||||
})}
|
||||
disabled={stats.batchTotal === 0}
|
||||
disabled={sessionInfo.batchTotal === 0}
|
||||
sx={{
|
||||
py: 2,
|
||||
bgcolor: '#3b82f6',
|
||||
@@ -194,11 +220,11 @@ export const Dashboard: React.FC = () => {
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
{stats.batchTotal === 0
|
||||
{sessionInfo.batchTotal === 0
|
||||
? 'No Items to Audit'
|
||||
: stats.batchPending === 0
|
||||
? `Start Session — Review ${Math.min(stats.batchTotal, 50)} Audited Items`
|
||||
: `Start Session — Audit Next ${Math.min(stats.batchPending, 50)} Items`}
|
||||
: sessionInfo.batchPending === 0
|
||||
? `Start Session — Review ${Math.min(sessionInfo.batchTotal, 50)} Audited Items`
|
||||
: `Start Session — Audit Next ${Math.min(sessionInfo.batchPending, 50)} Items`}
|
||||
</Button>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
Reference in New Issue
Block a user