From e5c2b047d757a61a8de5b8d76cec80f4f214ba11 Mon Sep 17 00:00:00 2001 From: kaushik Date: Sun, 12 Jul 2026 18:19:50 +0530 Subject: [PATCH] feat: implement administrative audit allocation and management dashboard pages with supporting API services --- src/api/accountService.ts | 17 ++ src/api/activityFeedsService.ts | 16 ++ src/api/adminService.ts | 26 ++- src/pages/account/DbSelectionDialog.tsx | 63 ++---- src/pages/account/Login.tsx | 59 ++++-- src/pages/admin/Allocation.tsx | 251 +++++++++++++++++++---- src/pages/audit-session/AuditSession.tsx | 169 +++++++++------ src/pages/dashboard/Dashboard.tsx | 198 ++++++++++-------- 8 files changed, 551 insertions(+), 248 deletions(-) diff --git a/src/api/accountService.ts b/src/api/accountService.ts index 0c8e3d0..3ea191d 100644 --- a/src/api/accountService.ts +++ b/src/api/accountService.ts @@ -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 => { + const response = await axiosClient.get('/api/audit/account/my-organizations', { + headers: { 'x-access-token': accessToken }, + }); + return response as unknown as Organization[]; } }; diff --git a/src/api/activityFeedsService.ts b/src/api/activityFeedsService.ts index 5fe0b94..f433b2c 100644 --- a/src/api/activityFeedsService.ts +++ b/src/api/activityFeedsService.ts @@ -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; diff --git a/src/api/adminService.ts b/src/api/adminService.ts index f324555..a5a8476 100644 --- a/src/api/adminService.ts +++ b/src/api/adminService.ts @@ -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 => { 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: { diff --git a/src/pages/account/DbSelectionDialog.tsx b/src/pages/account/DbSelectionDialog.tsx index fb6f9b5..6cd4a3e 100644 --- a/src/pages/account/DbSelectionDialog.tsx +++ b/src/pages/account/DbSelectionDialog.tsx @@ -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 = ({ open, onClose }) => { - const [organizations, setOrganizations] = useState([]); - 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 = ({ open, organizations, onClose }) => { + const sorted = [...organizations].sort((a, b) => + a.org_name.toLowerCase() > b.org_name.toLowerCase() ? 1 : -1 + ); return ( onClose()} maxWidth="xs" fullWidth> Select the organization - {loading ? ( - - - - ) : ( - - {organizations.map((org, index) => ( - - onClose(org)}> - - - - ))} - - )} + + {sorted.map((org, index) => ( + + onClose(org)}> + + + + ))} + ); diff --git a/src/pages/account/Login.tsx b/src/pages/account/Login.tsx index d5a6cbf..7c39b58 100644 --- a/src/pages/account/Login.tsx +++ b/src/pages/account/Login.tsx @@ -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([]); const [tempLoginResult, setTempLoginResult] = useState> | null>(null); const { login, isAuthenticated } = useAuthStore(); @@ -24,6 +26,23 @@ export const Login: React.FC = () => { } }, [isAuthenticated, navigate]); + const commitLogin = ( + result: NonNullable, + 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 = () => { - + ); }; diff --git a/src/pages/admin/Allocation.tsx b/src/pages/admin/Allocation.tsx index af3c428..231ff9a 100644 --- a/src/pages/admin/Allocation.tsx +++ b/src/pages/admin/Allocation.tsx @@ -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([]); @@ -66,7 +80,11 @@ export const Allocation: React.FC = () => { const [sites, setSites] = useState([]); const [users, setUsers] = useState([]); - const [selectedUserId, setSelectedUserId] = 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); @@ -74,7 +92,7 @@ export const Allocation: React.FC = () => { const [selectedIds, setSelectedIds] = useState>(new Set()); const [loading, setLoading] = useState(false); const [assigning, setAssigning] = useState(false); - const [bulkConfirmCount, setBulkConfirmCount] = useState(null); + const [confirmAction, setConfirmAction] = useState(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 ( @@ -274,12 +327,72 @@ export const Allocation: React.FC = () => { {sites.map((s) => {s.site_name})} + + Assigned to + + - - Allocate to - { + 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) => ( + {u.username} + ))} + + + + } + > + {total.toLocaleString()} pending record(s) assigned to {filterUser.username} + {' '}(matching current filters) + + )} + + + Allocate to (select one or more) + @@ -287,26 +400,53 @@ export const Allocation: React.FC = () => { {selectedIds.size} selected on this page + - Quick bulk assign (matches current filters): + + 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 } }} + /> + @@ -360,20 +500,57 @@ export const Allocation: React.FC = () => { )} - setBulkConfirmCount(null)}> - Confirm bulk allocation + { 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'} + - - 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. - + {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)? + + )} - + diff --git a/src/pages/audit-session/AuditSession.tsx b/src/pages/audit-session/AuditSession.tsx index 48a34e8..a64431e 100644 --- a/src/pages/audit-session/AuditSession.tsx +++ b/src/pages/audit-session/AuditSession.tsx @@ -418,56 +418,97 @@ export const AuditSession: React.FC = () => { notesVal: string, plantSub?: string | null, notAnAnomalySub?: string | null - ) => { - if (!currentAnomaly || !user) return; + ): Promise => { + 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); diff --git a/src/pages/dashboard/Dashboard.tsx b/src/pages/dashboard/Dashboard.tsx index 4d96199..327a583 100644 --- a/src/pages/dashboard/Dashboard.tsx +++ b/src/pages/dashboard/Dashboard.tsx @@ -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>; + 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(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) => ( + + {label} + + {statsLoading || value === undefined ? '...' : value.toLocaleString()} + + + {caption} + + + ); return ( @@ -114,78 +144,74 @@ export const Dashboard: React.FC = () => { - {/* Stats Row */} - - {/* Active Batch */} - - Active Batch - - {loading ? '...' : stats.batchTotal.toLocaleString()} - - - of {loading ? '...' : stats.globalTotal.toLocaleString()} total items on site - - - - {/* Audited */} - - Audited - - {loading ? '...' : stats.batchAudited.toLocaleString()} - - - in current batch - - - - {/* Pending */} - - Pending - - {loading ? '...' : stats.batchPending.toLocaleString()} - - - in current batch - - - - {/* Anomalies Found */} - - Anomalies Found - - {loading ? '...' : stats.batchAnomaliesFound.toLocaleString()} - - - in current batch - - + {/* Overall stats — the logged-in user's whole workload */} + + {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})`)} + {/* Today's stats */} + + {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)')} + + {/* Batches of 50 */} + + + Batches ({userStats?.batchSize ?? 50} items each, in session order) + + {statsLoading ? ( + Loading… + ) : batches.length === 0 ? ( + No items in your workload yet. + ) : ( + batches.map((b, i) => { + const pct = b.total > 0 ? Math.round((b.audited / b.total) * 100) : 0; + const isActive = i === activeBatchIdx; + return ( + + + + Batch {b.batch} + + {isActive && } + {b.pending === 0 && } + + + {b.audited}/{b.total} audited · {b.pending} pending · {b.anomaliesFound} anomalies + + + + + ); + }) + )} + - - {/* Start Button */} - {/* Footer */}