Re_Figma_Code/src/pages/OpenRequests/OpenRequests.tsx

684 lines
29 KiB
TypeScript

import { useEffect, useState, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Progress } from '@/components/ui/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, X, CheckCircle, XCircle } from 'lucide-react';
import workflowApi from '@/services/workflowApi';
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
interface Request {
id: string;
title: string;
description: string;
status: 'pending' | 'approved' | 'rejected' | 'closed';
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
}
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 [searchParams] = useSearchParams();
// Initialize filters from URL params
const [searchTerm, setSearchTerm] = useState(searchParams.get('search') || '');
const [priorityFilter, setPriorityFilter] = useState(searchParams.get('priority') || 'all');
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || 'all');
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority' | 'sla'>(
(searchParams.get('sortBy') as 'created' | 'due' | 'priority' | 'sla') || 'created'
);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(
(searchParams.get('sortOrder') as 'asc' | 'desc') || 'desc'
);
const [items, setItems] = useState<Request[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
// Pagination states
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalRecords, setTotalRecords] = useState(0);
const [itemsPerPage] = useState(10);
// 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, filters?: { search?: string; status?: string; priority?: 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: filters?.search,
status: filters?.status,
priority: filters?.priority,
sortBy: filters?.sortBy,
sortOrder: filters?.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) {
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
};
});
setItems(mapped);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [itemsPerPage]);
const handleRefresh = () => {
setRefreshing(true);
fetchRequests(currentPage, {
search: searchTerm || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
sortBy,
sortOrder
});
};
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
setCurrentPage(newPage);
fetchRequests(newPage, {
search: searchTerm || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
sortBy,
sortOrder
});
}
};
const getPageNumbers = () => {
const pages = [];
const maxPagesToShow = 5;
let startPage = Math.max(1, 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 the initial mount
const isInitialMount = useRef(true);
// Initial fetch on mount with URL params
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
fetchRequests(1, {
search: searchTerm || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
sortBy,
sortOrder
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only run on mount to use URL params
// Fetch when filters or sorting change (with debouncing for search)
useEffect(() => {
// Skip initial mount to avoid double fetch
if (isInitialMount.current) return;
// Debounce search: wait 500ms after user stops typing
const timeoutId = setTimeout(() => {
setCurrentPage(1); // Reset to page 1 when filters change
fetchRequests(1, {
search: searchTerm || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
sortBy,
sortOrder
});
}, searchTerm ? 500 : 0); // Debounce only for search, instant for dropdowns
return () => clearTimeout(timeoutId);
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, fetchRequests]);
// Backend handles both filtering and sorting - use items directly
// No client-side sorting needed anymore
const filteredAndSortedRequests = items;
const clearFilters = () => {
setSearchTerm('');
setPriorityFilter('all');
setStatusFilter('all');
};
const activeFiltersCount = [
searchTerm,
priorityFilter !== 'all' ? priorityFilter : null,
statusFilter !== 'all' ? statusFilter : null
].filter(Boolean).length;
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 */}
<Card className="shadow-lg border-0">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
>
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
{/* Primary filters */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
<Input
placeholder="Search requests, IDs..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200 transition-colors"
/>
</div>
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="All Priorities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="express">
<div className="flex items-center gap-2">
<Flame className="w-4 h-4 text-orange-600" />
<span>Express</span>
</div>
</SelectItem>
<SelectItem value="standard">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-blue-600" />
<span>Standard</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending">Pending (In Approval)</SelectItem>
<SelectItem value="approved">Approved (Needs Closure)</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value: any) => setSortBy(value)}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
<SelectItem value="sla">SLA Progress</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
>
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 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>
</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 && (() => {
// Use percentage-based colors to match approver SLA tracker
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
const percentUsed = request.currentLevelSLA.percentageUsed || 0;
const getSLAColors = () => {
if (percentUsed >= 100) {
return {
bg: 'bg-red-50 border border-red-200',
progress: 'bg-red-600',
text: 'text-red-600'
};
} else if (percentUsed >= 75) {
return {
bg: 'bg-orange-50 border border-orange-200',
progress: 'bg-orange-500',
text: 'text-orange-600'
};
} else if (percentUsed >= 50) {
return {
bg: 'bg-amber-50 border border-amber-200',
progress: 'bg-amber-500',
text: 'text-amber-600'
};
} else {
return {
bg: 'bg-green-50 border border-green-200',
progress: 'bg-green-600',
text: 'text-gray-700'
};
}
};
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">
<Clock className="w-3.5 h-3.5 text-gray-600" />
<span className="text-xs font-medium text-gray-900">TAT: {percentUsed}%</span>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-600">{request.currentLevelSLA.elapsedText}</span>
<span className={`font-semibold ${
percentUsed >= 100 ? 'text-red-600' :
percentUsed >= 75 ? 'text-orange-600' :
percentUsed >= 50 ? 'text-amber-600' :
'text-gray-700'
}`}>
{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">
{searchTerm || activeFiltersCount > 0
? 'Try adjusting your filters or search terms to see more results.'
: 'No open requests available at the moment.'
}
</p>
{activeFiltersCount > 0 && (
<Button
variant="outline"
className="mt-4"
onClick={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 {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, totalRecords)} of {totalRecords} open requests
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="h-8 w-8 p-0"
>
<ArrowRight className="h-4 w-4 rotate-180" />
</Button>
{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 === currentPage ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(pageNum)}
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-re-green text-white hover:bg-re-green/90' : ''}`}
>
{pageNum}
</Button>
))}
{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(currentPage + 1)}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
>
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}