Re_Figma_Code/src/pages/Requests/UserAllRequests.tsx

701 lines
29 KiB
TypeScript

/**
* User All Requests Page - For Regular Users
*
* OPTIMIZED: Uses backend pagination (10 records per page) and backend stats API
* Shows requests where the user is EITHER:
* - The initiator (created by the user), OR
* - A participant (approver/spectator)
*/
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Pagination } from '@/components/common/Pagination';
import dashboardService from '@/services/dashboard.service';
import type { DateRange } 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 { exportRequestsToCSV } from './utils/csvExports';
// Services
import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService';
// Types
import type { RequestsProps, BackendStats } from './types/requests.types';
// Filter UI components
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';
export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Filters hook
const filters = useRequestsFilters();
// State
const [apiRequests, setApiRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [backendStats, setBackendStats] = useState<BackendStats | null>(null); // Stats from backend API
const [departments, setDepartments] = useState<string[]>([]);
const [loadingDepartments, setLoadingDepartments] = useState(false);
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
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 using dashboard API
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
// Stats reflect all filters EXCEPT status - total stays stable when only status changes
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 {
// Use dashboard stats API with viewAsUser=true for user-level stats
const stats = await dashboardService.getRequestStats(
statsDateRange,
statsStartDate ? statsStartDate.toISOString() : undefined,
statsEndDate ? statsEndDate.toISOString() : undefined,
undefined, // status - stats should show all statuses
filtersWithoutStatus?.priority,
filtersWithoutStatus?.department,
filtersWithoutStatus?.initiator,
filtersWithoutStatus?.approver,
filtersWithoutStatus?.approverType,
filtersWithoutStatus?.search,
filtersWithoutStatus?.slaCompliance,
true // viewAsUser: always true for user-level
);
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
}
}, []);
// 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]);
// Fetch requests - OPTIMIZED: Only fetches 10 records per page
const fetchRequests = useCallback(async (page: number = 1) => {
try {
if (page === 1) {
setLoading(true);
setApiRequests([]);
}
const filterOptions = filtersRef.current.getFilters();
const result = await fetchUserParticipantRequestsData({
page,
itemsPerPage,
filters: filterOptions
});
setApiRequests(result.data); // Paginated data (10 records)
// Update pagination
setCurrentPage(result.pagination.page);
setTotalPages(result.pagination.totalPages);
setTotalRecords(result.pagination.total);
} catch (error) {
setApiRequests([]);
} finally {
setLoading(false);
}
}, [itemsPerPage]);
// Export to CSV
const handleExportToCSV = useCallback(async () => {
try {
setExporting(true);
const allData = await fetchAllRequestsForExport(filters.getFilters());
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);
}
}, [filters]);
// Initial fetch
useEffect(() => {
fetchDepartments();
fetchUsers();
}, [fetchDepartments, fetchUsers]);
// Fetch backend stats when filters change (except status filter)
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
useEffect(() => {
const timeoutId = setTimeout(() => {
const filtersWithoutStatus = {
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : 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
};
const statsDateRange = filters.dateRange || 'month';
fetchBackendStatsRef.current(
statsDateRange,
filters.customStartDate,
filters.customEndDate,
filtersWithoutStatus
);
}, filters.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filters.searchTerm,
filters.priorityFilter,
filters.slaComplianceFilter,
filters.departmentFilter,
filters.initiatorFilter,
filters.approverFilter,
filters.approverFilterType,
filters.dateRange,
filters.customStartDate,
filters.customEndDate
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes
]);
// Fetch requests on mount and when filters change (for list display)
useEffect(() => {
const timeoutId = setTimeout(() => {
setCurrentPage(1);
fetchRequests(1);
}, filters.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filters.searchTerm,
filters.statusFilter,
filters.priorityFilter,
filters.slaComplianceFilter,
filters.departmentFilter,
filters.initiatorFilter,
filters.approverFilter,
filters.approverFilterType,
filters.dateRange,
filters.customStartDate,
filters.customEndDate
// fetchRequests excluded to prevent infinite loops
]);
// Page change handler
const handlePageChange = useCallback((newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
setCurrentPage(newPage);
fetchRequests(newPage);
}
}, [totalPages, fetchRequests]);
// Transform requests
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
// Calculate stats - Use backend stats API (OPTIMIZED)
const stats = useMemo(() => {
// Use backend stats if available
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 current page (less accurate, but works during initial load)
const pending = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'pending' || status === 'in-progress';
}).length;
const paused = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'paused';
}).length;
const approved = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'approved';
}).length;
const rejected = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'rejected';
}).length;
const closed = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'closed';
}).length;
return {
total: totalRecords > 0 ? totalRecords : convertedRequests.length,
pending,
paused,
approved,
rejected,
draft: 0,
closed
};
}, [backendStats, totalRecords, convertedRequests]);
return (
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="user-all-requests-page">
{/* Header */}
<RequestsHeader
isOrgLevel={false}
isAdmin={false}
loading={loading}
exporting={exporting}
onExport={handleExportToCSV}
/>
{/* Stats */}
<RequestsStats
stats={stats}
onStatusFilter={(status) => {
filters.setStatusFilter(status);
}}
/>
{/* Filters */}
<Card className="border-gray-200 shadow-md" data-testid="requests-filters">
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-muted-foreground" />
<h3 className="font-semibold text-gray-900">Advanced Filters</h3>
{filters.hasActiveFilters && (
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
Active
</Badge>
)}
</div>
{filters.hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={filters.clearFilters} className="gap-2">
<RefreshCw className="w-4 h-4" />
Clear All
</Button>
)}
</div>
<Separator />
{/* Primary Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
<div className="relative md:col-span-3 lg:col-span-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search requests..."
value={filters.searchTerm}
onChange={(e) => filters.setSearchTerm(e.target.value)}
className="pl-10 h-10"
data-testid="search-input"
/>
</div>
<Select value={filters.statusFilter} onValueChange={filters.setStatusFilter}>
<SelectTrigger className="h-10" data-testid="status-filter">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Select value={filters.priorityFilter} onValueChange={filters.setPriorityFilter}>
<SelectTrigger className="h-10" data-testid="priority-filter">
<SelectValue placeholder="All Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priority</SelectItem>
<SelectItem value="express">Express</SelectItem>
<SelectItem value="standard">Standard</SelectItem>
</SelectContent>
</Select>
<Select
value={filters.departmentFilter}
onValueChange={filters.setDepartmentFilter}
disabled={loadingDepartments || departments.length === 0}
>
<SelectTrigger className="h-10" data-testid="department-filter">
<SelectValue placeholder={loadingDepartments ? "Loading..." : "All Departments"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Departments</SelectItem>
{departments.map((dept) => (
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.slaComplianceFilter} onValueChange={filters.setSlaComplianceFilter}>
<SelectTrigger className="h-10" data-testid="sla-compliance-filter">
<SelectValue placeholder="All SLA Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All SLA Status</SelectItem>
<SelectItem value="compliant">Compliant</SelectItem>
<SelectItem value="on-track">On Track</SelectItem>
<SelectItem value="approaching">Approaching</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="breached">Breached</SelectItem>
</SelectContent>
</Select>
</div>
{/* User Filters - Initiator and Approver */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
{/* Initiator Filter */}
<div className="flex flex-col">
<Label className="text-sm font-medium text-gray-700 mb-2">Initiator</Label>
<div className="relative">
{initiatorSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={initiatorSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search initiator..."
value={initiatorSearch.searchQuery}
onChange={(e) => 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 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{initiatorSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => initiatorSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
{/* Approver Filter */}
<div className="flex flex-col">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium text-gray-700">Approver</Label>
{filters.approverFilter !== 'all' && (
<Select
value={filters.approverFilterType}
onValueChange={(value: 'current' | 'any') => filters.setApproverFilterType(value)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current">Current Only</SelectItem>
<SelectItem value="any">Any Approver</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="relative">
{approverSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={approverSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search approver..."
value={approverSearch.searchQuery}
onChange={(e) => 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 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{approverSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => approverSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Date Range Filter */}
<div className="flex items-center gap-3 flex-wrap">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={filters.dateRange} onValueChange={filters.handleDateRangeChange}>
<SelectTrigger className="w-[160px] h-10">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{filters.dateRange === 'custom' && (
<Popover open={filters.showCustomDatePicker} onOpenChange={filters.setShowCustomDatePicker}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{filters.customStartDate && filters.customEndDate
? `${format(filters.customStartDate, 'MMM d, yyyy')} - ${format(filters.customEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start">
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<Input
id="start-date"
type="date"
value={filters.customStartDate ? format(filters.customStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
filters.setCustomStartDate(date);
if (filters.customEndDate && date > filters.customEndDate) {
filters.setCustomEndDate(date);
}
} else {
filters.setCustomStartDate(undefined);
}
}}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<Input
id="end-date"
type="date"
value={filters.customEndDate ? format(filters.customEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
filters.setCustomEndDate(date);
if (filters.customStartDate && date < filters.customStartDate) {
filters.setCustomStartDate(date);
}
} else {
filters.setCustomEndDate(undefined);
}
}}
min={filters.customStartDate ? format(filters.customStartDate, 'yyyy-MM-dd') : undefined}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={filters.handleApplyCustomDate}
disabled={!filters.customStartDate || !filters.customEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
filters.setShowCustomDatePicker(false);
filters.setCustomStartDate(undefined);
filters.setCustomEndDate(undefined);
filters.setDateRange('month');
}}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
</CardContent>
</Card>
{/* Requests List */}
<RequestsList
requests={convertedRequests}
loading={loading}
hasActiveFilters={filters.hasActiveFilters}
onViewRequest={onViewRequest}
/>
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalRecords={totalRecords}
itemsPerPage={itemsPerPage}
onPageChange={handlePageChange}
loading={loading}
itemLabel="requests"
testIdPrefix="requests-pagination"
/>
</div>
);
}