sorting for records in the ui

This commit is contained in:
2026-07-13 21:43:10 +05:30
parent c7cea78f74
commit 3cb086d67b
6 changed files with 134 additions and 15 deletions

View File

@@ -84,9 +84,11 @@ export const activityFeedsService = {
// 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) => {
// sortBy/sortDir keep the batch numbering aligned with the feed/session
// order when a custom sort is active (backend mirrors the same whitelist).
getUserStats: async (isRectificationEnabled: boolean, sortBy?: string, sortDir?: string) => {
const response = await axiosClient.get('/api/audit/anomaly/user-stats', {
params: { isRectificationEnabled }
params: { isRectificationEnabled, sortBy: sortBy || '', sortDir: sortDir || '' }
});
return response as unknown as {
scoped: boolean;

View File

@@ -1,7 +1,8 @@
import React, { useState, useMemo, useCallback, memo } from 'react';
import {
Box, TextField, Checkbox, FormControlLabel, Button, Typography, Dialog,
DialogContent, DialogActions, CircularProgress, Divider, InputAdornment, Chip
DialogContent, DialogActions, CircularProgress, Divider, InputAdornment, Chip,
Select, MenuItem
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import { useFeedStore } from '../../store/feedStore';
@@ -70,6 +71,7 @@ export const FeedFilters: React.FC = () => {
filterFromDate, filterTillDate,
filterMinChainage, filterMaxChainage,
setFilterMinChainage, setFilterMaxChainage,
sortBy, sortDir, setSortBy, setSortDir,
} = useFeedStore();
// ── Use a Set (stored in state) for O(1) has() lookups ────────────────────
@@ -77,6 +79,8 @@ export const FeedFilters: React.FC = () => {
const [draftSet, setDraftSet] = useState<Set<number>>(() => new Set(selectedAssetIds));
const [draftFromDate, setDraftFromDate] = useState('');
const [draftTillDate, setDraftTillDate] = useState('');
const [draftSortBy, setDraftSortBy] = useState<string>(() => sortBy);
const [draftSortDir, setDraftSortDir] = useState<string>(() => sortDir);
const [draftMinChainage, setDraftMinChainage] = useState('');
const [draftMaxChainage, setDraftMaxChainage] = useState('');
const [assetSearch, setAssetSearch] = useState('');
@@ -146,6 +150,8 @@ export const FeedFilters: React.FC = () => {
setDraftTillDate('');
setDraftMinChainage('');
setDraftMaxChainage('');
setDraftSortBy('');
setDraftSortDir('asc');
setAssetSearch('');
}, []);
@@ -162,6 +168,8 @@ export const FeedFilters: React.FC = () => {
setFilterMinChainage(draftMinChainage); // persist chainage globally
setFilterMaxChainage(draftMaxChainage); // persist chainage globally
setSelectedAssetIds(draftAssetArr);
setSortBy(draftSortBy as any);
setSortDir(draftSortDir as any);
if (selectedTabIndex >= 0) {
const activeTab = tabs[selectedTabIndex];
@@ -172,7 +180,9 @@ export const FeedFilters: React.FC = () => {
const assetIdsKey = draftAssetArr.join(',');
// Build the correct filter key that will be calculated in GlobalFeed
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${draftFromDate}|${draftTillDate}|${draftMinChainage}|${draftMaxChainage}|${assetIdsKey}`;
// Must mirror GlobalFeed's filterKey format exactly or the cache
// check there misses and double-fetches.
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${draftFromDate}|${draftTillDate}|${draftMinChainage}|${draftMaxChainage}|${assetIdsKey}|${draftSortBy}|${draftSortDir}`;
const historyRes: any = await activityFeedsService.getHistory({
date: activeTab.date,
@@ -189,6 +199,8 @@ export const FeedFilters: React.FC = () => {
fromChainage: draftMinChainage,
toChainage: draftMaxChainage,
plaza: filterPlaza,
sortBy: draftSortBy,
sortDir: draftSortDir,
});
if (historyRes?.anomalies) {
updateTab(selectedTabIndex, {
@@ -246,6 +258,32 @@ export const FeedFilters: React.FC = () => {
)}
</Box>
{/* Sort - drives feed order, session page order and dashboard batch
numbering (one source of truth end to end) */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#94a3b8', whiteSpace: 'nowrap' }}>Sort by</Typography>
<Select
size="small"
value={draftSortBy}
onChange={(e) => setDraftSortBy(String(e.target.value))}
displayEmpty
sx={{ minWidth: 140, color: '#f8fafc', bgcolor: 'rgba(255,255,255,0.07)', borderRadius: 1, '& fieldset': { borderColor: '#334155' }, '& .MuiSvgIcon-root': { color: '#94a3b8' } }}
MenuProps={{ slotProps: { paper: { sx: { bgcolor: '#0f172a', border: '1px solid #1e293b' } } } }}
>
{[['', 'Default'], ['id', 'ID'], ['date', 'Date'], ['chainage', 'Chainage'], ['latlong', 'Lat/Long']].map(([v, l]) => (
<MenuItem key={v} value={v} sx={{ color: '#f8fafc', bgcolor: '#0f172a', '&:hover': { bgcolor: '#1e293b' } }}>{l}</MenuItem>
))}
</Select>
<Button
size="small"
disabled={!draftSortBy}
onClick={() => setDraftSortDir(d => (d === 'asc' ? 'desc' : 'asc'))}
sx={{ color: '#94a3b8', border: '1px solid #334155', textTransform: 'none', borderRadius: 1, minWidth: 72, '&.Mui-disabled': { color: '#475569', borderColor: '#1e293b' } }}
>
{draftSortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
</Button>
</Box>
{/* Date & Chainage */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 3, alignItems: 'center' }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>

View File

@@ -16,7 +16,8 @@ export const GlobalFeed: React.FC = () => {
filterFromDate, filterTillDate,
filterMinChainage, filterMaxChainage,
selectedAssetIds,
setIsFeedLoading
setIsFeedLoading,
sortBy, sortDir
} = useFeedStore();
useEffect(() => {
@@ -60,7 +61,7 @@ export const GlobalFeed: React.FC = () => {
const assetIdsKey = selectedAssetIds.join(',');
// Build a key for the current filter state on the active tab
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${filterFromDate}|${filterTillDate}|${filterMinChainage}|${filterMaxChainage}|${assetIdsKey}`;
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${filterFromDate}|${filterTillDate}|${filterMinChainage}|${filterMaxChainage}|${assetIdsKey}|${sortBy}|${sortDir}`;
// CACHE HIT: This exact tab and filter combo was already fetched
if (filterKey === activeTab.filters?.lastFetchedFilterKey && activeTab.anomalies && activeTab.anomalies.length > 0) {
@@ -83,6 +84,8 @@ export const GlobalFeed: React.FC = () => {
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
isRectificationEnabled: isRectActive,
sortByDateAsc: false,
sortBy,
sortDir,
anomalyIds: searchAnomalyId,
plaza: filterPlaza,
assetsIds: assetIdsKey,
@@ -116,7 +119,7 @@ export const GlobalFeed: React.FC = () => {
fetchFilteredHistory();
}, [
selectedSite?.site_id, auditStatus, searchAnomalyId, filterPlaza, isRectificationEnabled, selectedTabIndex, tabs.length,
filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, selectedAssetIds
filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, selectedAssetIds, sortBy, sortDir
]);
const handlePageChange = async (_event: unknown, newPage: number) => {
@@ -137,6 +140,8 @@ export const GlobalFeed: React.FC = () => {
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
isRectificationEnabled: isRectActive,
sortByDateAsc: false,
sortBy,
sortDir,
anomalyIds: searchAnomalyId,
plaza: filterPlaza,
assetsIds: assetIdsKey,

View File

@@ -199,7 +199,7 @@ export const AuditSession: React.FC = () => {
selectedSite, filterFromDate, filterTillDate,
filterMinChainage, filterMaxChainage, selectedAssetIds,
searchAnomalyId, auditStatus, isRectificationEnabled, filterPlaza,
setDashboardCache
setDashboardCache, sortBy, sortDir, setSortBy, setSortDir
} = useFeedStore();
// Call this before every navigate('/dashboard') so the dashboard re-fetches fresh stats
@@ -233,6 +233,8 @@ export const AuditSession: React.FC = () => {
pageNo: targetPage * 50,
pageSize: 50,
sortByDateAsc: false, // must match dashboard sort so firstPendingIndex page alignment is correct
sortBy, // user sort: same value the dashboard fetch uses, so page alignment holds
sortDir,
isTrueFalse: isRectActive ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'),
auditStatus: auditStatus,
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
@@ -267,7 +269,7 @@ export const AuditSession: React.FC = () => {
} finally {
setLoading(false);
}
}, [selectedSite, selectedAssetIds, auditStatus, isRectificationEnabled, filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, searchAnomalyId, activeDate, isRectActive, location.state, filterPlaza, sessionPageNo, navigate]);
}, [selectedSite, selectedAssetIds, auditStatus, isRectificationEnabled, filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, searchAnomalyId, activeDate, isRectActive, location.state, filterPlaza, sessionPageNo, navigate, sortBy, sortDir]);
const handleLoadNextFeedPage = useCallback(async () => {
try {
@@ -282,6 +284,8 @@ export const AuditSession: React.FC = () => {
date: activeDate,
pageNo: nextPage * feedPageSize,
pageSize: feedPageSize,
sortBy,
sortDir,
isTrueFalse: isRectActive ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'),
auditStatus: auditStatus,
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
@@ -323,7 +327,7 @@ export const AuditSession: React.FC = () => {
} finally {
setLoading(false);
}
}, [selectedSite, selectedAssetIds, auditStatus, isRectificationEnabled, filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, searchAnomalyId, activeDate, isRectActive, location.state, activeTab, selectedTabIndex, updateTab, navigate]);
}, [selectedSite, selectedAssetIds, auditStatus, isRectificationEnabled, filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, searchAnomalyId, activeDate, isRectActive, location.state, activeTab, selectedTabIndex, updateTab, navigate, sortBy, sortDir]);
useEffect(() => {
if (hasInitializedSession.current) return;
@@ -893,6 +897,26 @@ export const AuditSession: React.FC = () => {
goToIndex(firstSkipped !== -1 ? firstSkipped : anomalies.length - 1);
};
// Changing sort mid-session reloads page 0 in the new order. Dashboard
// sessions only - feed sessions inherit the order of the feed tab they
// were opened from (the feed page has its own selector). Staged bulk work
// can't survive a reorder, so it's discarded after a confirm.
const handleSessionSortChange = (by: string, dir: string) => {
if (stagedCount > 0 && !window.confirm(`Changing sort reloads the session and discards ${stagedCount} staged audit(s). Continue?`)) return;
setSortBy(by as any);
setSortDir(dir as any);
};
const skipSortReload = useRef(true);
useEffect(() => {
if (skipSortReload.current) { skipSortReload.current = false; return; }
if (isFeedSession) return;
setShowBatchEndPanel(false);
setSessionPageNo(0);
loadSessionData(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortBy, sortDir]);
// Category 1 nests a sub-category (Plant / Not an Anomaly) - a verdict
// without it is incomplete, both for immediate save and for staging.
const validateSubCategory = (): boolean => {
@@ -1238,6 +1262,31 @@ export const AuditSession: React.FC = () => {
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{!isFeedSession && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Select
size="small"
value={sortBy}
onChange={(e) => handleSessionSortChange(String(e.target.value), sortDir)}
displayEmpty
sx={{ fontSize: '0.75rem', color: '#94a3b8', height: 30, '& fieldset': { borderColor: '#334155' }, '& .MuiSvgIcon-root': { color: '#94a3b8' } }}
MenuProps={{ sx: { zIndex: 10001 }, slotProps: { paper: { sx: { bgcolor: '#0f172a', border: '1px solid #1e293b' } } } }}
>
{[['', 'Sort: Default'], ['id', 'Sort: ID'], ['date', 'Sort: Date'], ['chainage', 'Sort: Chainage'], ['latlong', 'Sort: Lat/Long']].map(([v, l]) => (
<MenuItem key={v} value={v} sx={{ fontSize: '0.8rem', color: '#f8fafc', bgcolor: '#0f172a', '&:hover': { bgcolor: '#1e293b' } }}>{l}</MenuItem>
))}
</Select>
<Button
size="small"
disabled={!sortBy}
onClick={() => handleSessionSortChange(sortBy, sortDir === 'asc' ? 'desc' : 'asc')}
title="Toggle sort direction"
sx={{ minWidth: 52, color: '#94a3b8', border: '1px solid #334155', textTransform: 'none', borderRadius: 2, '&.Mui-disabled': { color: '#475569', borderColor: '#1e293b' } }}
>
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
</Button>
</Box>
)}
<Typography sx={{ fontWeight: 'bold', fontSize: '0.9rem' }}>{user?.username || user?.email}</Typography>
<Button size="small" onClick={() => setIsShortcutsDialogOpen(true)} sx={{ color: '#94a3b8', border: '1px solid #334155', textTransform: 'none', borderRadius: 2 }}>? Shortcuts</Button>
</Box>

View File

@@ -13,7 +13,7 @@ export const Dashboard: React.FC = () => {
filterMinChainage, filterMaxChainage,
selectedAssetIds, dashboardCache, setDashboardCache,
searchAnomalyId, filterPlaza, isRectificationEnabled,
tabs, selectedTabIndex
tabs, selectedTabIndex, sortBy, sortDir
} = useFeedStore();
const navigate = useNavigate();
@@ -26,7 +26,7 @@ export const Dashboard: React.FC = () => {
const isRectActive = isRectificationEnabled || activeTab.isRectificationTab;
// Build a key that uniquely identifies the current filter state
const currentFilterKey = `${selectedSiteId}|${auditStatus}|${filterFromDate}|${filterTillDate}|${assetIdsKey}|${filterMinChainage}|${filterMaxChainage}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeDate}`;
const currentFilterKey = `${selectedSiteId}|${auditStatus}|${filterFromDate}|${filterTillDate}|${assetIdsKey}|${filterMinChainage}|${filterMaxChainage}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeDate}|${sortBy}|${sortDir}`;
// Session-start info (which page the audit session should open on) - still
// derived from the filtered feed, same as before. The headline stats below
@@ -49,16 +49,18 @@ export const Dashboard: React.FC = () => {
useEffect(() => {
let cancelled = false;
setStatsLoading(true);
activityFeedsService.getUserStats(isRectActive)
activityFeedsService.getUserStats(isRectActive, sortBy, sortDir)
.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]);
// Batch numbering follows the chosen sort - refetch when it changes so
// "batch N" keeps matching the N-th session page.
}, [isRectActive, sortBy, sortDir]);
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 ?? ''}`) {
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 ?? ''}|${dashboardCache.sortBy ?? ''}|${dashboardCache.sortDir ?? 'asc'}`) {
setSessionInfo(dashboardCache.stats);
return;
}
@@ -70,6 +72,8 @@ export const Dashboard: React.FC = () => {
pageNo: 0,
pageSize: 200,
sortByDateAsc: false,
sortBy,
sortDir,
isRectificationEnabled: isRectActive,
auditStatus: auditStatus,
isTrueFalse: isRectActive ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'),
@@ -108,6 +112,8 @@ export const Dashboard: React.FC = () => {
filterPlaza,
isRectificationEnabled: isRectActive,
date: activeDate,
sortBy,
sortDir,
stats: newStats,
});
}

View File

@@ -1,6 +1,13 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Feed/session sort. '' = backend default order (id DESC on the pending
// feed). One choice drives the whole chain: feed table, session pages,
// dashboard batch numbering and the "continue auditing" start page - they
// must all use the same order or batch N stops matching session page N.
export type FeedSortBy = '' | 'id' | 'date' | 'chainage' | 'latlong';
export type FeedSortDir = 'asc' | 'desc';
export interface FeedTab {
date: string;
isRectificationTab: boolean;
@@ -26,6 +33,8 @@ interface DashboardCache {
filterPlaza: string;
isRectificationEnabled?: boolean;
date?: string;
sortBy?: string;
sortDir?: string;
stats: {
globalTotal: number;
batchTotal: number;
@@ -59,6 +68,8 @@ interface FeedState {
searchAnomalyId: string;
isRectificationEnabled: boolean;
isFeedLoading: boolean;
sortBy: FeedSortBy;
sortDir: FeedSortDir;
// Actions
setTabs: (tabs: FeedTab[]) => void;
@@ -84,6 +95,8 @@ interface FeedState {
setSearchAnomalyId: (id: string) => void;
setIsRectificationEnabled: (val: boolean) => void;
setIsFeedLoading: (val: boolean) => void;
setSortBy: (val: FeedSortBy) => void;
setSortDir: (val: FeedSortDir) => void;
resetStore: () => void;
}
@@ -123,6 +136,8 @@ const initialFeedState = {
searchAnomalyId: '',
isRectificationEnabled: false,
isFeedLoading: false,
sortBy: '' as FeedSortBy,
sortDir: 'asc' as FeedSortDir,
};
export const useFeedStore = create<FeedState>()(
@@ -152,6 +167,8 @@ export const useFeedStore = create<FeedState>()(
setSearchAnomalyId: (searchAnomalyId) => set({ searchAnomalyId }),
setIsRectificationEnabled: (isRectificationEnabled) => set({ isRectificationEnabled }),
setIsFeedLoading: (isFeedLoading) => set({ isFeedLoading }),
setSortBy: (sortBy) => set({ sortBy }),
setSortDir: (sortDir) => set({ sortDir }),
addTab: (tab) => set((state) => ({ tabs: [...state.tabs, tab], selectedTabIndex: state.tabs.length })),
closeTab: (index) => set((state) => {
const newTabs = [...state.tabs];
@@ -180,6 +197,8 @@ export const useFeedStore = create<FeedState>()(
auditStatus: state.auditStatus,
searchAnomalyId: state.searchAnomalyId,
isRectificationEnabled: state.isRectificationEnabled,
sortBy: state.sortBy,
sortDir: state.sortDir,
}),
}
)