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, ListItemText, Alert, } 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]; // 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([]); const [dbName, setDbName] = useState(''); const [type, setType] = useState<'audits' | 'rectification'>('audits'); const [sort, setSort] = useState<'oldest' | 'newest'>('oldest'); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); const [assetId, setAssetId] = useState(''); const [siteId, setSiteId] = useState(''); const [sites, setSites] = useState([]); const [users, setUsers] = useState([]); const [selectedUserIds, setSelectedUserIds] = useState([]); // '' = all, 'null' = unassigned, otherwise a user_id string const [assignedFilter, setAssignedFilter] = useState(''); const [customCount, setCustomCount] = useState(''); const [moveTargetId, setMoveTargetId] = useState(''); const [records, setRecords] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(0); const [selectedIds, setSelectedIds] = useState>(new Set()); const [loading, setLoading] = useState(false); const [assigning, setAssigning] = useState(false); const [confirmAction, setConfirmAction] = useState(null); useEffect(() => { (async () => { try { const [orgsRes, usersRes] = await Promise.all([ accountService.getOrganizations(), adminService.getUsers(), ]); const orgs = (orgsRes as any) || []; setOrganizations(orgs); setUsers(usersRes.filter((u) => !u.disabled)); if (orgs.length > 0) setDbName(orgs[0].db_name); } catch (err) { useToastStore.getState().showToast('Failed to load organizations/users.', 'error'); } })(); }, []); // Sites are org-scoped, and the org being managed here may differ from the // admin's own logged-in org, so this refetches whenever the selection changes // rather than reusing the global feedStore (which is scoped to the admin's own org). useEffect(() => { const org = organizations.find((o) => o.db_name === dbName); if (!org) return; (async () => { try { const sitesRes = await activityFeedsService.getOrganizationSites(org.org_id); setSites(parseSites(sitesRes)); } catch (err) { useToastStore.getState().showToast('Failed to load sites for this organization.', 'error'); } })(); setSiteId(''); }, [dbName, organizations]); // dateTo is a bare date (e.g. "2026-07-09"); video_date is a full timestamp, // so comparing "<= 2026-07-09" would exclude same-day records after // midnight - extend it to end-of-day. Also normalize the order: both // fields are independent and unset by default, so it's easy to end up with // From after To while filling them in, which would silently match zero // rows instead of erroring. const [effectiveDateFrom, effectiveDateTo] = (() => { if (!dateFrom || !dateTo) return [dateFrom || undefined, dateTo ? `${dateTo}T23:59:59` : undefined]; return dateFrom <= dateTo ? [dateFrom, `${dateTo}T23:59:59`] : [dateTo, `${dateFrom}T23:59:59`]; })(); const load = useCallback(async () => { if (!dbName) return; setLoading(true); try { const result = await adminService.getAllocationRecords({ dbName, type, sort, limit: PAGE_SIZE, offset: page * PAGE_SIZE, dateFrom: effectiveDateFrom, dateTo: effectiveDateTo, assetId: assetId || undefined, siteId: siteId || undefined, assignedUserId: assignedFilter === '' ? undefined : assignedFilter === 'null' ? 'null' : Number(assignedFilter), }); 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, assignedFilter]); useEffect(() => { load(); }, [load]); useEffect(() => { setPage(0); setSelectedIds(new Set()); }, [dbName, type, sort, dateFrom, dateTo, assetId, siteId, assignedFilter]); 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 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_ids: selectedUserIds, }); useToastStore.getState().showToast( `Allocated ${result.assigned} record(s) — ${perUserToast(result.perUser)}`, 'success'); setSelectedIds(new Set()); load(); } catch (err: any) { useToastStore.getState().showToast(err?.response?.data?.message || 'Failed to allocate records.', 'error'); } finally { setAssigning(false); } }; const handleBulkAllocate = async (count: number) => { if (selectedUserIds.length === 0) return; setConfirmAction(null); setAssigning(true); try { const result = await adminService.assignAllocation({ 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) — ${perUserToast(result.perUser)}`, 'success'); setSelectedIds(new Set()); setPage(0); load(); } catch (err: any) { useToastStore.getState().showToast(err?.response?.data?.message || 'Failed to bulk-allocate records.', 'error'); } finally { setAssigning(false); } }; 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 ( Organization value && setType(value)} size="small" > Audits Rectification value && setSort(value)} size="small" > Oldest first Newest first setDateFrom(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} /> setDateTo(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} /> Asset Site Assigned to {/* Absent-user actions: visible when viewing one user's queue */} {filterUser && ( Move all pending to } > {total.toLocaleString()} pending record(s) assigned to {filterUser.username} {' '}(matching current filters) )} Allocate to (select one or more) {selectedIds.size} selected on this page Bulk assign unassigned records (total, split equally · matches current filters): {BULK_PRESETS.map((count) => ( ))} setCustomCount(e.target.value)} sx={{ width: 130 }} slotProps={{ htmlInput: { min: 1, max: 10000 } }} /> {loading ? ( ) : ( 0 && selectedIds.size === records.length} indeterminate={selectedIds.size > 0 && selectedIds.size < records.length} onChange={toggleAll} /> Anomaly ID Asset Plaza Chainage Video Date Assigned To {records.map((r) => ( toggleRow(r.id)} /> {r.id} {r.Asset || '—'} {r.Plaza || '—'} {r.Chainage || '—'} {r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'} {r.assigned_username || '—'} ))}
setPage(newPage)} rowsPerPage={PAGE_SIZE} rowsPerPageOptions={[PAGE_SIZE]} />
)} { setConfirmAction(null); setMoveTargetId(''); }}> {confirmAction?.kind === 'bulk' && 'Confirm bulk allocation'} {confirmAction?.kind === 'unassignAll' && 'Unassign all pending'} {confirmAction?.kind === 'moveAll' && 'Move all pending'} {confirmAction?.kind === 'unassignSelected' && 'Unassign selected'} {confirmAction?.kind === 'bulk' && ( Split the first {confirmAction.count.toLocaleString()} {sort} unassigned {type} record(s) in{' '} {selectedOrg?.org_name || dbName} (matching the current date/asset/site filters) equally: {splitPreview(confirmAction.count)} If fewer records are available, users earlier in the list are filled first. )} {confirmAction?.kind === 'unassignAll' && ( Remove the assignment from all {total.toLocaleString()} pending record(s) currently allocated to{' '} {filterUser?.username} in {selectedOrg?.org_name || dbName}? Records they already audited keep their name for history/efficiency stats. )} {confirmAction?.kind === 'moveAll' && ( Move all {total.toLocaleString()} pending record(s) from {filterUser?.username} to{' '} {users.find((u) => u.user_id === confirmAction.toUserId)?.username}? Records already audited by {filterUser?.username} keep their name for history/efficiency stats. )} {confirmAction?.kind === 'unassignSelected' && ( Remove the assignment from the {selectedIds.size} selected pending record(s)? )} ); };