feat: implement administrative audit allocation and management dashboard pages with supporting API services

This commit is contained in:
2026-07-12 18:19:50 +05:30
parent b667fe1082
commit e5c2b047d7
8 changed files with 551 additions and 248 deletions

View File

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