858 lines
36 KiB
TypeScript
858 lines
36 KiB
TypeScript
/**
|
|
* 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';
|
|
|
|
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<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [exporting, setExporting] = useState(false);
|
|
const [backendStats, setBackendStats] = useState<BackendStats | null>(null);
|
|
const [departments, setDepartments] = useState<string[]>([]);
|
|
const [loadingDepartments, setLoadingDepartments] = useState(false);
|
|
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
|
|
|
|
// 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]);
|
|
|
|
// 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 (
|
|
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="requests-page">
|
|
{/* Header */}
|
|
<RequestsHeader
|
|
isOrgLevel={isOrgLevel}
|
|
isAdmin={isAdmin}
|
|
loading={loading}
|
|
exporting={exporting}
|
|
onExport={handleExportToCSV}
|
|
/>
|
|
|
|
{/* Stats */}
|
|
<RequestsStats
|
|
stats={stats}
|
|
onStatusFilter={(status) => {
|
|
filters.setStatusFilter(status);
|
|
}}
|
|
/>
|
|
|
|
{/* Filters - TODO: Extract to separate component */}
|
|
<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-6 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.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}>
|
|
<SelectTrigger className="h-10" data-testid="template-type-filter">
|
|
<SelectValue placeholder="All Templates" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Templates</SelectItem>
|
|
<SelectItem value="CUSTOM">Custom</SelectItem>
|
|
<SelectItem value="DEALER CLAIM">Dealer Claim</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="all">All Time</SelectItem>
|
|
<SelectItem value="today">Today</SelectItem>
|
|
<SelectItem value="week">This Week</SelectItem>
|
|
<SelectItem value="month">This Month</SelectItem>
|
|
<SelectItem value="last7days">Last 7 Days</SelectItem>
|
|
<SelectItem value="last30days">Last 30 Days</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={filters.currentPage || 1}
|
|
totalPages={totalPages}
|
|
totalRecords={totalRecords}
|
|
itemsPerPage={itemsPerPage}
|
|
onPageChange={handlePageChange}
|
|
loading={loading}
|
|
itemLabel="requests"
|
|
testIdPrefix="requests-pagination"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|