647 lines
28 KiB
TypeScript
647 lines
28 KiB
TypeScript
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
|
import { useOpenRequestsFilters } from './hooks/useOpenRequestsFilters';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
import { Calendar, Clock, FileText, AlertCircle, ArrowRight, RefreshCw, CheckCircle, XCircle, Lock, Flame, Target } from 'lucide-react';
|
|
import workflowApi from '@/services/workflowApi';
|
|
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
|
import { getUserFilterType } from '@/utils/userFilterUtils';
|
|
import { getRequestsFilters } from '@/flows';
|
|
import { TokenManager } from '@/utils/tokenManager';
|
|
interface Request {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
status: 'pending' | 'approved' | 'rejected' | 'closed' | 'paused';
|
|
priority: 'express' | 'standard';
|
|
initiator: { name: string; avatar: string };
|
|
currentApprover?: {
|
|
name: string;
|
|
avatar: string;
|
|
sla?: any; // Backend-calculated SLA data
|
|
};
|
|
createdAt: string;
|
|
approvalStep?: string;
|
|
department?: string;
|
|
currentLevelSLA?: any; // Backend-provided SLA for current level
|
|
isPaused?: boolean; // Pause status
|
|
pauseInfo?: any; // Pause details
|
|
templateType?: string; // Template type for badge display
|
|
}
|
|
|
|
interface OpenRequestsProps {
|
|
onViewRequest?: (requestId: string, requestTitle?: string) => void;
|
|
}
|
|
|
|
// Utility functions
|
|
const getPriorityConfig = (priority: string) => {
|
|
switch (priority) {
|
|
case 'express':
|
|
return {
|
|
color: 'bg-red-100 text-red-800 border-red-200',
|
|
icon: Flame,
|
|
iconColor: 'text-red-600'
|
|
};
|
|
case 'standard':
|
|
return {
|
|
color: 'bg-blue-100 text-blue-800 border-blue-200',
|
|
icon: Target,
|
|
iconColor: 'text-blue-600'
|
|
};
|
|
default:
|
|
return {
|
|
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
|
icon: Target,
|
|
iconColor: 'text-gray-600'
|
|
};
|
|
}
|
|
};
|
|
|
|
const getStatusConfig = (status: string) => {
|
|
switch (status) {
|
|
case 'pending':
|
|
return {
|
|
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
|
icon: Clock,
|
|
iconColor: 'text-yellow-600',
|
|
label: 'Pending'
|
|
};
|
|
case 'approved':
|
|
return {
|
|
color: 'bg-green-100 text-green-800 border-green-200',
|
|
icon: AlertCircle,
|
|
iconColor: 'text-green-600',
|
|
label: 'Needs Closure'
|
|
};
|
|
case 'rejected':
|
|
return {
|
|
color: 'bg-red-100 text-red-800 border-red-200',
|
|
icon: XCircle,
|
|
iconColor: 'text-red-600',
|
|
label: 'Rejected'
|
|
};
|
|
case 'closed':
|
|
return {
|
|
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
|
icon: CheckCircle,
|
|
iconColor: 'text-gray-600',
|
|
label: 'Closed'
|
|
};
|
|
default:
|
|
return {
|
|
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
|
icon: AlertCircle,
|
|
iconColor: 'text-gray-600',
|
|
label: status
|
|
};
|
|
}
|
|
};
|
|
|
|
// getSLAUrgency removed - now using SLATracker component for real-time SLA display
|
|
|
|
export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|
const [items, setItems] = useState<Request[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
// Pagination states (currentPage now in Redux)
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [totalRecords, setTotalRecords] = useState(0);
|
|
const [itemsPerPage] = useState(10);
|
|
const fetchRequestsRef = useRef<any>(null);
|
|
|
|
// Use Redux for filters with callback (persists during navigation)
|
|
const filters = useOpenRequestsFilters();
|
|
|
|
// Get user filter type and corresponding filter component (plug-and-play pattern)
|
|
const userFilterType = useMemo(() => {
|
|
try {
|
|
const userData = TokenManager.getUserData();
|
|
return getUserFilterType(userData);
|
|
} catch (error) {
|
|
console.error('[OpenRequests] Error getting user filter type:', error);
|
|
return 'STANDARD' as const;
|
|
}
|
|
}, []);
|
|
|
|
// Get the appropriate filter component based on user type
|
|
const RequestsFiltersComponent = useMemo(() => {
|
|
return getRequestsFilters(userFilterType);
|
|
}, [userFilterType]);
|
|
|
|
// Determine once - use this throughout instead of checking repeatedly
|
|
const isDealer = userFilterType === 'DEALER';
|
|
|
|
// Helper to build filter params for API - excludes dealer-restricted filters
|
|
// Since we know user type initially, this helper uses that knowledge
|
|
// Note: This doesn't need useCallback since we'll use it inline in effects to avoid dependency issues
|
|
const getFilterParams = (includeStatus?: boolean) => {
|
|
return {
|
|
search: filters.searchTerm || undefined,
|
|
// Only include status, priority, and templateType filters if user is not a dealer
|
|
status: includeStatus && !isDealer && filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
|
priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
|
templateType: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
|
sortBy: filters.sortBy,
|
|
sortOrder: filters.sortOrder
|
|
};
|
|
};
|
|
|
|
// Fetch open requests for the current user only (user-scoped, not organization-wide)
|
|
// Note: This endpoint returns only requests where the user is:
|
|
// - An approver (with pending/in-progress status)
|
|
// - A spectator
|
|
// - An initiator (for approved requests awaiting closure)
|
|
// This applies to ALL users including regular users, MANAGEMENT, and ADMIN roles
|
|
// For organization-wide view, users should use the "All Requests" screen (/requests)
|
|
const fetchRequests = useCallback(async (page: number = 1, filterParams?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => {
|
|
try {
|
|
if (page === 1) {
|
|
setLoading(true);
|
|
setItems([]);
|
|
}
|
|
|
|
// Always use user-scoped endpoint (not organization-wide)
|
|
// Backend filters by userId regardless of user role (ADMIN/MANAGEMENT/regular user)
|
|
// For organization-wide requests, use the "All Requests" screen (/requests)
|
|
const result = await workflowApi.listOpenForMe({
|
|
page,
|
|
limit: itemsPerPage,
|
|
search: filterParams?.search,
|
|
status: filterParams?.status,
|
|
priority: filterParams?.priority,
|
|
templateType: filterParams?.templateType,
|
|
sortBy: filterParams?.sortBy,
|
|
sortOrder: filterParams?.sortOrder
|
|
});
|
|
|
|
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
|
const data = Array.isArray((result as any)?.data)
|
|
? (result as any).data
|
|
: [];
|
|
|
|
// Set pagination data
|
|
const pagination = (result as any)?.pagination;
|
|
if (pagination) {
|
|
filters.setCurrentPage(pagination.page || 1);
|
|
setTotalPages(pagination.totalPages || 1);
|
|
setTotalRecords(pagination.total || 0);
|
|
}
|
|
|
|
const mapped: Request[] = data.map((r: any) => {
|
|
const createdAt = r.submittedAt || r.submitted_at || r.createdAt || r.created_at;
|
|
|
|
return {
|
|
id: r.requestNumber || r.request_number || r.requestId,
|
|
requestId: r.requestId,
|
|
displayId: r.requestNumber || r.request_number || r.requestId,
|
|
title: r.title,
|
|
description: r.description,
|
|
status: (r.status || '').toString().toLowerCase().replace('_', '-'),
|
|
priority: (r.priority || '').toString().toLowerCase(),
|
|
initiator: {
|
|
name: (r.initiator?.displayName || r.initiator?.email || '—'),
|
|
avatar: ((r.initiator?.displayName || r.initiator?.email || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase())
|
|
},
|
|
currentApprover: r.currentApprover ? {
|
|
name: (r.currentApprover.name || r.currentApprover.email || '—'),
|
|
avatar: ((r.currentApprover.name || r.currentApprover.email || 'CA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase()),
|
|
sla: r.currentApprover.sla // ← Backend-calculated SLA
|
|
} : undefined,
|
|
createdAt: createdAt || '—',
|
|
approvalStep: r.currentLevel ? `Step ${r.currentLevel} of ${r.totalLevels || '?'}` : undefined,
|
|
department: r.department,
|
|
currentLevelSLA: r.currentLevelSLA, // ← Backend-calculated current level SLA
|
|
templateType: r.templateType || r.template_type, // ← Template type for badge display
|
|
};
|
|
});
|
|
setItems(mapped);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
}, [itemsPerPage, filters]);
|
|
|
|
fetchRequestsRef.current = fetchRequests;
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
setRefreshing(true);
|
|
fetchRequests(filters.currentPage, getFilterParams(true));
|
|
}, [filters.currentPage, fetchRequests]);
|
|
|
|
const handlePageChange = useCallback((newPage: number) => {
|
|
if (newPage >= 1 && newPage <= totalPages) {
|
|
filters.setCurrentPage(newPage);
|
|
fetchRequests(newPage, getFilterParams(true));
|
|
}
|
|
}, [totalPages, filters, fetchRequests]);
|
|
|
|
const getPageNumbers = () => {
|
|
const pages = [];
|
|
const maxPagesToShow = 5;
|
|
let startPage = Math.max(1, filters.currentPage - Math.floor(maxPagesToShow / 2));
|
|
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
|
|
|
|
if (endPage - startPage < maxPagesToShow - 1) {
|
|
startPage = Math.max(1, endPage - maxPagesToShow + 1);
|
|
}
|
|
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
pages.push(i);
|
|
}
|
|
|
|
return pages;
|
|
};
|
|
|
|
// Track if this is initial mount
|
|
const hasInitialFetchRun = useRef(false);
|
|
|
|
// Initial fetch on mount - use stored page from Redux
|
|
useEffect(() => {
|
|
if (!hasInitialFetchRun.current) {
|
|
hasInitialFetchRun.current = true;
|
|
const storedPage = filters.currentPage || 1;
|
|
fetchRequests(storedPage, getFilterParams(true));
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []); // Only on mount
|
|
|
|
// Track filter changes and refetch
|
|
useEffect(() => {
|
|
// Skip until initial fetch has completed
|
|
if (!hasInitialFetchRun.current) return;
|
|
|
|
// Debounce search
|
|
const timeoutId = setTimeout(() => {
|
|
filters.setCurrentPage(1); // Reset to page 1 when filters change
|
|
fetchRequests(1, getFilterParams(true));
|
|
}, filters.searchTerm ? 500 : 0);
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder, isDealer]);
|
|
|
|
// Backend handles both filtering and sorting - use items directly
|
|
// No client-side sorting needed anymore
|
|
const filteredAndSortedRequests = items;
|
|
|
|
|
|
return (
|
|
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto">
|
|
{/* Enhanced Header */}
|
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 sm:gap-4 md:gap-6">
|
|
<div className="space-y-1 sm:space-y-2">
|
|
<div className="flex items-center gap-2 sm:gap-3">
|
|
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl flex items-center justify-center shadow-lg">
|
|
<FileText className="w-5 h-5 sm:w-6 sm:h-6 text-white" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900">My Open Requests</h1>
|
|
<p className="text-sm sm:text-base text-gray-600">Manage and track your active approval requests</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 sm:gap-3">
|
|
<Badge variant="secondary" className="text-xs sm:text-sm md:text-base px-2 sm:px-3 md:px-4 py-1 sm:py-1.5 md:py-2 bg-slate-100 text-slate-800 font-semibold">
|
|
{loading ? 'Loading…' : `${totalRecords || items.length} open`}
|
|
<span className="hidden sm:inline ml-1">requests</span>
|
|
</Badge>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-1 sm:gap-2 h-8 sm:h-9"
|
|
onClick={handleRefresh}
|
|
disabled={refreshing}
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 sm:w-4 sm:h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
|
<span className="hidden sm:inline">{refreshing ? 'Refreshing...' : 'Refresh'}</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Enhanced Filters Section - Plug-and-play pattern */}
|
|
<RequestsFiltersComponent
|
|
searchTerm={filters.searchTerm}
|
|
statusFilter={filters.statusFilter}
|
|
priorityFilter={filters.priorityFilter}
|
|
templateTypeFilter={filters.templateTypeFilter}
|
|
sortBy={filters.sortBy}
|
|
sortOrder={filters.sortOrder}
|
|
onSearchChange={filters.setSearchTerm}
|
|
onStatusFilterChange={filters.setStatusFilter}
|
|
onPriorityFilterChange={filters.setPriorityFilter}
|
|
onTemplateTypeFilterChange={filters.setTemplateTypeFilter}
|
|
onSortByChange={filters.setSortBy}
|
|
onSortOrderChange={filters.setSortOrder}
|
|
onClearFilters={filters.clearFilters}
|
|
activeFiltersCount={filters.activeFiltersCount}
|
|
/>
|
|
|
|
{/* Requests List */}
|
|
<div className="space-y-3">
|
|
{filteredAndSortedRequests.map((request) => {
|
|
const priorityConfig = getPriorityConfig(request.priority);
|
|
const statusConfig = getStatusConfig(request.status);
|
|
|
|
return (
|
|
<Card
|
|
key={request.id}
|
|
className="group hover:shadow-lg transition-all duration-200 cursor-pointer border border-gray-200 hover:border-blue-400 hover:scale-[1.002]"
|
|
onClick={() => onViewRequest?.(request.id, request.title)}
|
|
>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start gap-4">
|
|
{/* Left: Priority Icon */}
|
|
<div className="flex-shrink-0 pt-1">
|
|
<div className={`p-2.5 rounded-lg ${priorityConfig.color} border shadow-sm`}>
|
|
<priorityConfig.icon className={`w-5 h-5 ${priorityConfig.iconColor}`} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Center: Main Content */}
|
|
<div className="flex-1 min-w-0 space-y-2.5">
|
|
{/* Header Row */}
|
|
<div className="flex items-center gap-2.5 flex-wrap">
|
|
<h3 className="text-base font-bold text-gray-900 group-hover:text-blue-600 transition-colors">
|
|
{(request as any).displayId || request.id}
|
|
</h3>
|
|
<Badge
|
|
variant="outline"
|
|
className={`${statusConfig.color} text-xs px-2.5 py-0.5 font-semibold shrink-0`}
|
|
>
|
|
<statusConfig.icon className="w-3.5 h-3.5 mr-1" />
|
|
{(statusConfig as any).label || request.status}
|
|
</Badge>
|
|
{request.department && (
|
|
<Badge variant="secondary" className="bg-blue-50 text-blue-700 text-xs px-2.5 py-0.5 hidden sm:inline-flex">
|
|
{request.department}
|
|
</Badge>
|
|
)}
|
|
<Badge
|
|
variant="outline"
|
|
className={`${priorityConfig.color} text-xs px-2.5 py-0.5 capitalize hidden md:inline-flex`}
|
|
>
|
|
{request.priority}
|
|
</Badge>
|
|
{/* Template Type Badge */}
|
|
{(() => {
|
|
const templateType = (request as any)?.templateType || (request as any)?.template_type || '';
|
|
const templateTypeUpper = templateType?.toUpperCase() || '';
|
|
|
|
// Direct mapping from templateType
|
|
let templateLabel = 'Custom';
|
|
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
|
|
|
|
if (templateTypeUpper === 'DEALER CLAIM') {
|
|
templateLabel = 'Dealer Claim';
|
|
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
|
|
} else if (templateTypeUpper === 'TEMPLATE') {
|
|
templateLabel = 'Template';
|
|
}
|
|
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className={`${templateColor} text-xs px-2.5 py-0.5 shrink-0 hidden md:inline-flex`}
|
|
data-testid="template-type-badge"
|
|
>
|
|
{templateLabel}
|
|
</Badge>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<h4 className="text-sm font-semibold text-gray-800 line-clamp-1 leading-relaxed">
|
|
{request.title}
|
|
</h4>
|
|
|
|
{/* SLA Display - Compact Version */}
|
|
{request.currentLevelSLA && (() => {
|
|
// Check pause status from isPaused field, pauseInfo, OR status field
|
|
const isPaused = Boolean(
|
|
request.isPaused ||
|
|
request.pauseInfo?.isPaused ||
|
|
request.status === 'paused'
|
|
);
|
|
// Use percentage-based colors to match approver SLA tracker
|
|
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
|
|
// Grey: When paused (frozen state)
|
|
const percentUsed = request.currentLevelSLA.percentageUsed || 0;
|
|
|
|
const getSLAColors = () => {
|
|
// If paused, always use grey colors (frozen state)
|
|
if (isPaused) {
|
|
return {
|
|
bg: 'bg-gray-100 border border-gray-300',
|
|
progress: 'bg-gray-500',
|
|
text: 'text-gray-600',
|
|
icon: 'text-gray-600'
|
|
};
|
|
}
|
|
|
|
if (percentUsed >= 100) {
|
|
return {
|
|
bg: 'bg-red-50 border border-red-200',
|
|
progress: 'bg-red-600',
|
|
text: 'text-red-600',
|
|
icon: 'text-blue-600'
|
|
};
|
|
} else if (percentUsed >= 75) {
|
|
return {
|
|
bg: 'bg-orange-50 border border-orange-200',
|
|
progress: 'bg-orange-500',
|
|
text: 'text-orange-600',
|
|
icon: 'text-blue-600'
|
|
};
|
|
} else if (percentUsed >= 50) {
|
|
return {
|
|
bg: 'bg-amber-50 border border-amber-200',
|
|
progress: 'bg-amber-500',
|
|
text: 'text-amber-600',
|
|
icon: 'text-blue-600'
|
|
};
|
|
} else {
|
|
return {
|
|
bg: 'bg-green-50 border border-green-200',
|
|
progress: 'bg-green-600',
|
|
text: 'text-gray-700',
|
|
icon: 'text-blue-600'
|
|
};
|
|
}
|
|
};
|
|
|
|
const colors = getSLAColors();
|
|
|
|
return (
|
|
<div className={`p-2 rounded-md ${colors.bg}`}>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
{isPaused ? (
|
|
<Lock className={`w-3.5 h-3.5 ${colors.icon}`} />
|
|
) : (
|
|
<Clock className={`w-3.5 h-3.5 ${colors.icon}`} />
|
|
)}
|
|
<span className="text-xs font-medium text-gray-900">
|
|
TAT: {percentUsed}% {isPaused && '(paused)'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="text-gray-600">{request.currentLevelSLA.elapsedText}</span>
|
|
<span className={`font-semibold ${colors.text}`}>
|
|
{request.currentLevelSLA.remainingText} left
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Progress
|
|
value={percentUsed}
|
|
className="h-1.5"
|
|
indicatorClassName={colors.progress}
|
|
/>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Metadata Row */}
|
|
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-600">
|
|
<div className="flex items-center gap-1.5">
|
|
<Avatar className="h-6 w-6 ring-2 ring-white shadow-sm">
|
|
<AvatarFallback className="bg-gradient-to-br from-slate-700 to-slate-900 text-white text-[10px] font-bold">
|
|
{request.initiator.avatar}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="font-medium text-gray-900">{request.initiator.name}</span>
|
|
</div>
|
|
|
|
{request.currentApprover && (
|
|
<div className="flex items-center gap-1.5">
|
|
<Avatar className="h-6 w-6 ring-2 ring-yellow-200 shadow-sm">
|
|
<AvatarFallback className="bg-yellow-500 text-white text-[10px] font-bold">
|
|
{request.currentApprover.avatar}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="font-medium text-gray-900">{request.currentApprover.name}</span>
|
|
</div>
|
|
)}
|
|
|
|
{request.approvalStep && (
|
|
<div className="flex items-center gap-1.5">
|
|
<AlertCircle className="w-3.5 h-3.5 text-blue-500" />
|
|
<span className="font-medium">{request.approvalStep}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
<Calendar className="w-3.5 h-3.5" />
|
|
<span>Created: {request.createdAt !== '—' ? formatDateDDMMYYYY(request.createdAt) : '—'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Arrow */}
|
|
<div className="flex-shrink-0 flex items-center pt-2">
|
|
<ArrowRight className="w-5 h-5 text-gray-400 group-hover:text-blue-600 group-hover:translate-x-1 transition-all" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Empty State */}
|
|
{filteredAndSortedRequests.length === 0 && (
|
|
<Card className="shadow-lg border-0">
|
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
|
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
|
<FileText className="h-8 w-8 text-gray-400" />
|
|
</div>
|
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">No requests found</h3>
|
|
<p className="text-gray-600 text-center max-w-md">
|
|
{filters.searchTerm || filters.activeFiltersCount > 0
|
|
? 'Try adjusting your filters or search terms to see more results.'
|
|
: 'No open requests available at the moment.'
|
|
}
|
|
</p>
|
|
{filters.activeFiltersCount > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
className="mt-4"
|
|
onClick={filters.clearFilters}
|
|
>
|
|
Clear all filters
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Pagination Controls */}
|
|
{totalPages > 1 && !loading && (
|
|
<Card className="shadow-md">
|
|
<CardContent className="p-4">
|
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
|
|
<div className="text-xs sm:text-sm text-muted-foreground">
|
|
Showing {((filters.currentPage - 1) * itemsPerPage) + 1} to {Math.min(filters.currentPage * itemsPerPage, totalRecords)} of {totalRecords} open requests
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(filters.currentPage - 1)}
|
|
disabled={filters.currentPage === 1}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
<ArrowRight className="h-4 w-4 rotate-180" />
|
|
</Button>
|
|
|
|
{filters.currentPage > 3 && totalPages > 5 && (
|
|
<>
|
|
<Button variant="outline" size="sm" onClick={() => handlePageChange(1)} className="h-8 w-8 p-0">1</Button>
|
|
<span className="text-muted-foreground">...</span>
|
|
</>
|
|
)}
|
|
|
|
{getPageNumbers().map((pageNum) => (
|
|
<Button
|
|
key={pageNum}
|
|
variant={pageNum === filters.currentPage ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => handlePageChange(pageNum)}
|
|
className={`h-8 w-8 p-0 ${pageNum === filters.currentPage ? 'bg-re-green text-white hover:bg-re-green/90' : ''}`}
|
|
>
|
|
{pageNum}
|
|
</Button>
|
|
))}
|
|
|
|
{filters.currentPage < totalPages - 2 && totalPages > 5 && (
|
|
<>
|
|
<span className="text-muted-foreground">...</span>
|
|
<Button variant="outline" size="sm" onClick={() => handlePageChange(totalPages)} className="h-8 w-8 p-0">{totalPages}</Button>
|
|
</>
|
|
)}
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(filters.currentPage + 1)}
|
|
disabled={filters.currentPage === totalPages}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|