/** * Requests Page - Refactored Version * * This is a refactored version that uses modular components, hooks, and utilities. * The main file orchestrates all the modules while keeping the complex filter UI inline. */ import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useAuth, hasManagementAccess } from '@/contexts/AuthContext'; import { useAppSelector } from '@/redux/hooks'; import { Pagination } from '@/components/common/Pagination'; import type { DateRange } from '@/services/dashboard.service'; import dashboardService from '@/services/dashboard.service'; import userApi from '@/services/userApi'; // Components import { RequestsHeader } from './components/RequestsHeader'; import { RequestsStats } from './components/RequestsStats'; import { RequestsList } from './components/RequestsList'; // Hooks import { useRequestsFilters } from './hooks/useRequestsFilters'; import { useUserSearch } from './hooks/useUserSearch'; // Utils import { transformRequests } from './utils/requestTransformers'; import { calculateStatsFromFilteredData } from './utils/requestCalculations'; import { exportRequestsToCSV } from './utils/csvExports'; // Services import { fetchRequestsData, fetchAllRequestsForExport } from './services/requestsService'; import workflowApi from '@/services/workflowApi'; // Types import type { RequestsProps, BackendStats } from './types/requests.types'; // Filter UI components (to be extracted later) import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; import { X, Search, Filter, RefreshCw, Calendar as CalendarIcon } from 'lucide-react'; import { format } from 'date-fns'; import { CustomDatePicker } from '@/components/ui/date-picker'; export function Requests({ onViewRequest }: RequestsProps) { const { user } = useAuth(); // Get viewAsUser from Redux store (synced with Dashboard toggle) const viewAsUser = useAppSelector((state) => state.dashboard.viewAsUser); // Determine if viewing at organization level: // - If user is admin/management AND not in "Personal" mode (viewAsUser=false) → show all requests // - If user is admin/management AND in "Personal" mode (viewAsUser=true) → show only their requests // - If user is not admin/management → always show only their requests const isAdmin = useMemo(() => hasManagementAccess(user), [user]); const isOrgLevel = useMemo(() => isAdmin && !viewAsUser, [isAdmin, viewAsUser]); // Filters hook const filters = useRequestsFilters(); // State const [apiRequests, setApiRequests] = useState([]); const [loading, setLoading] = useState(false); const [exporting, setExporting] = useState(false); const [backendStats, setBackendStats] = useState(null); const [departments, setDepartments] = useState([]); const [loadingDepartments, setLoadingDepartments] = useState(false); const [allUsers, setAllUsers] = useState>([]); // Pagination (currentPage now in Redux) const [totalPages, setTotalPages] = useState(1); const [totalRecords, setTotalRecords] = useState(0); const [itemsPerPage] = useState(10); // User search hooks const initiatorSearch = useUserSearch({ allUsers, filterValue: filters.initiatorFilter, onFilterChange: filters.setInitiatorFilter }); const approverSearch = useUserSearch({ allUsers, filterValue: filters.approverFilter, onFilterChange: filters.setApproverFilter }); // Fetch backend stats // Stats should reflect filters (priority, department, initiator, approver, search, date range) but NOT status // Status filter should not affect stats - stats should always show all status counts // For user-level (Personal mode), stats will only include requests where user is involved const fetchBackendStats = useCallback(async ( statsDateRange?: DateRange, statsStartDate?: Date, statsEndDate?: Date, filtersWithoutStatus?: { priority?: string; department?: string; initiator?: string; approver?: string; approverType?: 'current' | 'any'; search?: string; slaCompliance?: string; } ) => { try { // For dynamic SLA statuses (on_track, approaching, critical), we need to fetch and enrich // because these are calculated dynamically, not stored in DB const slaCompliance = filtersWithoutStatus?.slaCompliance; const isDynamicSlaStatus = slaCompliance && slaCompliance !== 'all' && slaCompliance !== 'breached' && slaCompliance !== 'compliant'; if (isDynamicSlaStatus) { // Fetch a larger sample of requests, enrich them, filter by SLA, then calculate stats const backendFilters: any = {}; if (filtersWithoutStatus?.search) backendFilters.search = filtersWithoutStatus.search; if (filtersWithoutStatus?.priority && filtersWithoutStatus.priority !== 'all') { backendFilters.priority = filtersWithoutStatus.priority; } if (filtersWithoutStatus?.department && filtersWithoutStatus.department !== 'all') { backendFilters.department = filtersWithoutStatus.department; } if (filtersWithoutStatus?.initiator && filtersWithoutStatus.initiator !== 'all') { backendFilters.initiator = filtersWithoutStatus.initiator; } if (filtersWithoutStatus?.approver && filtersWithoutStatus.approver !== 'all') { backendFilters.approver = filtersWithoutStatus.approver; backendFilters.approverType = filtersWithoutStatus.approverType || 'current'; } backendFilters.slaCompliance = slaCompliance; // Include SLA filter - backend will enrich and filter if (statsDateRange) backendFilters.dateRange = statsDateRange; if (statsStartDate) backendFilters.startDate = statsStartDate.toISOString(); if (statsEndDate) backendFilters.endDate = statsEndDate.toISOString(); // Fetch up to 1000 requests (backend will enrich and filter by SLA) // Use appropriate API based on org/personal mode const result = isOrgLevel ? await workflowApi.listWorkflows({ page: 1, limit: 1000, ...backendFilters }) : await workflowApi.listParticipantRequests({ page: 1, limit: 1000, ...backendFilters }); const filteredData = Array.isArray(result?.data) ? result.data : []; // Calculate stats from filtered data const total = filteredData.length; const pending = filteredData.filter((r: any) => { const status = (r.status || '').toString().toUpperCase(); return status === 'PENDING' || status === 'IN_PROGRESS'; }).length; const approved = filteredData.filter((r: any) => { const status = (r.status || '').toString().toUpperCase(); return status === 'APPROVED'; }).length; const rejected = filteredData.filter((r: any) => { const status = (r.status || '').toString().toUpperCase(); return status === 'REJECTED'; }).length; const closed = filteredData.filter((r: any) => { const status = (r.status || '').toString().toUpperCase(); return status === 'CLOSED'; }).length; setBackendStats({ total, pending, paused: 0, // Paused not calculated in dynamic SLA mode approved, rejected, draft: 0, // Drafts are excluded closed }); } else { // For breached/compliant or no SLA filter, use dashboard stats API // Note: status is undefined here because All Requests stats should show all statuses // Pass viewAsUser=true when in Personal mode (not org-level) const stats = await dashboardService.getRequestStats( statsDateRange, statsStartDate ? statsStartDate.toISOString() : undefined, statsEndDate ? statsEndDate.toISOString() : undefined, undefined, // status - All Requests stats show all statuses, not filtered by status filtersWithoutStatus?.priority, undefined, // templateType filtersWithoutStatus?.department, filtersWithoutStatus?.initiator, filtersWithoutStatus?.approver, filtersWithoutStatus?.approverType, filtersWithoutStatus?.search, filtersWithoutStatus?.slaCompliance, !isOrgLevel // viewAsUser: true when in Personal mode ); setBackendStats({ total: stats.totalRequests || 0, pending: stats.openRequests || 0, paused: stats.pausedRequests || 0, approved: stats.approvedRequests || 0, rejected: stats.rejectedRequests || 0, draft: stats.draftRequests || 0, closed: stats.closedRequests || 0 }); } } catch (error) { console.error('Failed to fetch backend stats:', error); // Keep previous stats on error } finally { // Stats loading removed - no longer needed } }, [isOrgLevel]); // Fetch departments const fetchDepartments = useCallback(async () => { try { setLoadingDepartments(true); const depts = await dashboardService.getDepartments(); setDepartments(depts); } catch (error) { // Leave departments empty on error } finally { setLoadingDepartments(false); } }, []); // Fetch users const fetchUsers = useCallback(async () => { try { const usersData = await userApi.getAllUsers(); const usersList = usersData.map((user: any) => ({ userId: user.userId, email: user.email, displayName: user.displayName || user.email })); setAllUsers(usersList); } catch (error) { console.error('Failed to fetch users:', error); } }, []); // Use refs to store stable callbacks to prevent infinite loops const filtersRef = useRef(filters); const fetchBackendStatsRef = useRef(fetchBackendStats); // Update refs on each render useEffect(() => { filtersRef.current = filters; fetchBackendStatsRef.current = fetchBackendStats; }, [filters, fetchBackendStats]); // Parse URL search params useEffect(() => { const params = new URLSearchParams(window.location.search); // Approver Filter (from Dashboard TAT Breach Report link) const approver = params.get('approver'); const approverType = params.get('approverType'); const slaCompliance = params.get('slaCompliance'); const dateRange = params.get('dateRange'); const startDate = params.get('startDate'); const endDate = params.get('endDate'); if (approver) { filters.setApproverFilter(approver); } if (approverType === 'current' || approverType === 'any') { filters.setApproverFilterType(approverType); } if (slaCompliance) { filters.setSlaComplianceFilter(slaCompliance); } if (dateRange) { filters.setDateRange(dateRange as any); } if (startDate) { filters.setCustomStartDate(new Date(startDate)); } if (endDate) { filters.setCustomEndDate(new Date(endDate)); } }, []); // Run only once on mount // Fetch requests const fetchRequests = useCallback(async (page: number = 1) => { try { if (page === 1) { setLoading(true); setApiRequests([]); } const filterOptions = filtersRef.current.getFilters(); const result = await fetchRequestsData({ page, itemsPerPage, filters: filterOptions, isOrgLevel }); setApiRequests(result.data); // Paginated data WITH status filter (for list display) // Note: Stats come from backend stats API (always unfiltered), not from allData // Update pagination filters.setCurrentPage(result.pagination.page); setTotalPages(result.pagination.totalPages); setTotalRecords(result.pagination.total); // Stats are fetched separately via useEffect when filters change } catch (error) { setApiRequests([]); } finally { setLoading(false); } }, [itemsPerPage, isOrgLevel]); // Export to CSV const handleExportToCSV = useCallback(async () => { try { setExporting(true); const allData = await fetchAllRequestsForExport(isOrgLevel); await exportRequestsToCSV(allData, filters.getFilters()); } catch (error: any) { console.error('Failed to export requests:', error); alert('Failed to export requests. Please try again.'); } finally { setExporting(false); } }, [isOrgLevel, filters]); // Initial fetch useEffect(() => { fetchDepartments(); fetchUsers(); }, [fetchDepartments, fetchUsers]); // Fetch backend stats when filters change (excluding status) // Stats should reflect priority, department, initiator, approver, search, and date range filters // But NOT status filter - stats should always show all status counts // Total changes when other filters are applied, but stays stable when only status changes // Stats are fetched for both org-level AND user-level (Personal mode) views useEffect(() => { const timeoutId = setTimeout(() => { const filtersWithoutStatus = { priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined, department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined, initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined, approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined, approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined, search: filters.searchTerm || undefined, slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined }; // All Requests (admin/normal user) should always have a date range // Default to 'all' if no date range is selected const statsDateRange = filters.dateRange || 'all'; fetchBackendStatsRef.current( statsDateRange, filters.customStartDate, filters.customEndDate, filtersWithoutStatus ); }, filters.searchTerm ? 500 : 0); return () => clearTimeout(timeoutId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ isOrgLevel, filters.dateRange, filters.customStartDate, filters.customEndDate, filters.priorityFilter, filters.templateTypeFilter, filters.departmentFilter, filters.initiatorFilter, filters.approverFilter, filters.approverFilterType, filters.searchTerm, filters.slaComplianceFilter // Note: statusFilter is NOT in dependencies - stats don't change when only status changes ]); // Track previous filter values to detect changes const prevFiltersRef = useRef({ searchTerm: filters.searchTerm, statusFilter: filters.statusFilter, priorityFilter: filters.priorityFilter, templateTypeFilter: filters.templateTypeFilter, slaComplianceFilter: filters.slaComplianceFilter, departmentFilter: filters.departmentFilter, initiatorFilter: filters.initiatorFilter, approverFilter: filters.approverFilter, approverFilterType: filters.approverFilterType, dateRange: filters.dateRange, customStartDate: filters.customStartDate, customEndDate: filters.customEndDate, isOrgLevel, }); const hasInitialFetchRun = useRef(false); // Initial fetch on mount - use stored page from Redux useEffect(() => { const storedPage = filters.currentPage || 1; fetchRequests(storedPage); hasInitialFetchRun.current = true; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Only on mount // Fetch when filters change or isOrgLevel changes useEffect(() => { if (!hasInitialFetchRun.current) return; const prev = prevFiltersRef.current; const hasChanged = prev.searchTerm !== filters.searchTerm || prev.statusFilter !== filters.statusFilter || prev.priorityFilter !== filters.priorityFilter || prev.templateTypeFilter !== filters.templateTypeFilter || prev.slaComplianceFilter !== filters.slaComplianceFilter || prev.departmentFilter !== filters.departmentFilter || prev.initiatorFilter !== filters.initiatorFilter || prev.approverFilter !== filters.approverFilter || prev.approverFilterType !== filters.approverFilterType || prev.dateRange !== filters.dateRange || prev.customStartDate !== filters.customStartDate || prev.customEndDate !== filters.customEndDate || prev.isOrgLevel !== isOrgLevel; if (!hasChanged) return; const timeoutId = setTimeout(() => { filters.setCurrentPage(1); fetchRequests(1); prevFiltersRef.current = { searchTerm: filters.searchTerm, statusFilter: filters.statusFilter, priorityFilter: filters.priorityFilter, templateTypeFilter: filters.templateTypeFilter, slaComplianceFilter: filters.slaComplianceFilter, departmentFilter: filters.departmentFilter, initiatorFilter: filters.initiatorFilter, approverFilter: filters.approverFilter, approverFilterType: filters.approverFilterType, dateRange: filters.dateRange, customStartDate: filters.customStartDate, customEndDate: filters.customEndDate, isOrgLevel, }; }, filters.searchTerm !== prev.searchTerm ? 500 : 0); return () => clearTimeout(timeoutId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ isOrgLevel, filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.slaComplianceFilter, filters.departmentFilter, filters.initiatorFilter, filters.approverFilter, filters.approverFilterType, filters.dateRange, filters.customStartDate, filters.customEndDate ]); // Page change handler const handlePageChange = useCallback((newPage: number) => { if (newPage >= 1 && newPage <= totalPages) { filters.setCurrentPage(newPage); fetchRequests(newPage); } }, [totalPages, fetchRequests, filters]); // Transform requests const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]); // Calculate stats - Use backend stats API for both org-level and user-level views // Stats should always show total counts regardless of any filters applied const stats = useMemo(() => { // Use backend stats if available (for both org-level and user-level) if (backendStats) { return { total: backendStats.total || 0, pending: backendStats.pending || 0, paused: backendStats.paused || 0, approved: backendStats.approved || 0, rejected: backendStats.rejected || 0, draft: backendStats.draft || 0, closed: backendStats.closed || 0 }; } // Fallback: Calculate from paginated data (less accurate, but better than nothing) return calculateStatsFromFilteredData( [], // Empty - we'll use backendStats or fallback isOrgLevel, backendStats, false, // No filters for stats - always show overall totalRecords, convertedRequests ); }, [isOrgLevel, backendStats, totalRecords, convertedRequests]); // Removed totalRequests - no longer displayed in header (shown in stat cards instead) return (
{/* Header */} {/* Stats */} { filters.setStatusFilter(status); }} /> {/* Filters - TODO: Extract to separate component */}

Advanced Filters

{filters.hasActiveFilters && ( Active )}
{filters.hasActiveFilters && ( )}
{/* Primary Filters */}
filters.setSearchTerm(e.target.value)} className="pl-10 h-10" data-testid="search-input" />
{/* User Filters - Initiator and Approver */}
{/* Initiator Filter */}
{initiatorSearch.selectedUser ? (
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
) : ( <> initiatorSearch.handleSearch(e.target.value)} onFocus={() => { if (initiatorSearch.searchResults.length > 0) { initiatorSearch.setShowResults(true); } }} onBlur={() => setTimeout(() => initiatorSearch.setShowResults(false), 200)} className="h-10" data-testid="initiator-search-input" /> {initiatorSearch.showResults && initiatorSearch.searchResults.length > 0 && (
{initiatorSearch.searchResults.map((user) => ( ))}
)} )}
{/* Approver Filter */}
{filters.approverFilter !== 'all' && ( )}
{approverSearch.selectedUser ? (
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
) : ( <> approverSearch.handleSearch(e.target.value)} onFocus={() => { if (approverSearch.searchResults.length > 0) { approverSearch.setShowResults(true); } }} onBlur={() => setTimeout(() => approverSearch.setShowResults(false), 200)} className="h-10" data-testid="approver-search-input" /> {approverSearch.showResults && approverSearch.searchResults.length > 0 && (
{approverSearch.searchResults.map((user) => ( ))}
)} )}
{/* Date Range Filter */}
{filters.dateRange === 'custom' && (
{ const date = dateStr ? new Date(dateStr) : undefined; if (date) { filters.setCustomStartDate(date); if (filters.customEndDate && date > filters.customEndDate) { filters.setCustomEndDate(date); } } else { filters.setCustomStartDate(undefined); } }} maxDate={new Date()} placeholderText="dd/mm/yyyy" className="w-full" />
{ const date = dateStr ? new Date(dateStr) : undefined; if (date) { filters.setCustomEndDate(date); if (filters.customStartDate && date < filters.customStartDate) { filters.setCustomStartDate(date); } } else { filters.setCustomEndDate(undefined); } }} minDate={filters.customStartDate || undefined} maxDate={new Date()} placeholderText="dd/mm/yyyy" className="w-full" />
)}
{/* Requests List */} {/* Pagination */}
); }