492 lines
20 KiB
TypeScript
492 lines
20 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
|
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 {
|
|
FileText,
|
|
Search,
|
|
Clock,
|
|
CheckCircle,
|
|
XCircle,
|
|
User,
|
|
ArrowRight,
|
|
TrendingUp,
|
|
Edit,
|
|
Flame,
|
|
Target,
|
|
AlertCircle
|
|
} from 'lucide-react';
|
|
import { motion } from 'framer-motion';
|
|
import workflowApi from '@/services/workflowApi';
|
|
import { PageHeader } from '@/components/common/PageHeader';
|
|
import { StatsCard } from '@/components/dashboard/StatsCard';
|
|
import { Pagination } from '@/components/common/Pagination';
|
|
|
|
interface MyRequestsProps {
|
|
onViewRequest: (requestId: string, requestTitle?: string, status?: string) => void;
|
|
dynamicRequests?: any[];
|
|
}
|
|
|
|
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 'approved':
|
|
return {
|
|
color: 'bg-green-100 text-green-800 border-green-200',
|
|
icon: CheckCircle,
|
|
iconColor: 'text-green-600'
|
|
};
|
|
case 'rejected':
|
|
return {
|
|
color: 'bg-red-100 text-red-800 border-red-200',
|
|
icon: XCircle,
|
|
iconColor: 'text-red-600'
|
|
};
|
|
case 'pending':
|
|
return {
|
|
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
|
icon: Clock,
|
|
iconColor: 'text-yellow-600'
|
|
};
|
|
case 'closed':
|
|
return {
|
|
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
|
icon: CheckCircle,
|
|
iconColor: 'text-gray-600'
|
|
};
|
|
case 'draft':
|
|
return {
|
|
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
|
icon: Edit,
|
|
iconColor: 'text-gray-600'
|
|
};
|
|
default:
|
|
return {
|
|
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
|
icon: AlertCircle,
|
|
iconColor: 'text-gray-600'
|
|
};
|
|
}
|
|
};
|
|
|
|
|
|
export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsProps) {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState('all');
|
|
const [priorityFilter, setPriorityFilter] = useState('all');
|
|
const [apiRequests, setApiRequests] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [hasFetchedFromApi, setHasFetchedFromApi] = useState(false);
|
|
|
|
// Pagination states
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [totalRecords, setTotalRecords] = useState(0);
|
|
const [itemsPerPage] = useState(10);
|
|
|
|
const fetchMyRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string }) => {
|
|
try {
|
|
if (page === 1) {
|
|
setLoading(true);
|
|
setApiRequests([]);
|
|
}
|
|
|
|
const result = await workflowApi.listMyWorkflows({
|
|
page,
|
|
limit: itemsPerPage,
|
|
search: filters?.search,
|
|
status: filters?.status,
|
|
priority: filters?.priority
|
|
});
|
|
console.log('[MyRequests] API Response:', result); // Debug log
|
|
|
|
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
|
const items = Array.isArray((result as any)?.data)
|
|
? (result as any).data
|
|
: [];
|
|
|
|
console.log('[MyRequests] Parsed items:', items); // Debug log
|
|
|
|
setApiRequests(items);
|
|
setHasFetchedFromApi(true);
|
|
|
|
// Set pagination data
|
|
const pagination = (result as any)?.pagination;
|
|
if (pagination) {
|
|
setCurrentPage(pagination.page || 1);
|
|
setTotalPages(pagination.totalPages || 1);
|
|
setTotalRecords(pagination.total || 0);
|
|
}
|
|
} catch (error) {
|
|
console.error('[MyRequests] Error fetching requests:', error);
|
|
setApiRequests([]);
|
|
setHasFetchedFromApi(true);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [itemsPerPage]);
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
if (newPage >= 1 && newPage <= totalPages) {
|
|
setCurrentPage(newPage);
|
|
fetchMyRequests(newPage, {
|
|
search: searchTerm || undefined,
|
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
|
priority: priorityFilter !== 'all' ? priorityFilter : undefined
|
|
});
|
|
}
|
|
};
|
|
|
|
// Initial fetch on mount
|
|
useEffect(() => {
|
|
fetchMyRequests(1, {
|
|
search: searchTerm || undefined,
|
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
|
priority: priorityFilter !== 'all' ? priorityFilter : undefined
|
|
});
|
|
}, [fetchMyRequests]);
|
|
|
|
// Fetch when filters change (with debouncing for search)
|
|
useEffect(() => {
|
|
// Debounce search: wait 500ms after user stops typing
|
|
const timeoutId = setTimeout(() => {
|
|
if (hasFetchedFromApi) { // Only refetch if we've already loaded data once
|
|
setCurrentPage(1); // Reset to page 1 when filters change
|
|
fetchMyRequests(1, {
|
|
search: searchTerm || undefined,
|
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
|
priority: priorityFilter !== 'all' ? priorityFilter : undefined
|
|
});
|
|
}
|
|
}, searchTerm ? 500 : 0); // Debounce only for search, instant for dropdowns
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}, [searchTerm, statusFilter, priorityFilter, hasFetchedFromApi, fetchMyRequests]);
|
|
|
|
// Convert API/dynamic requests to the format expected by this component
|
|
// Once API has fetched (even if empty), always use API data, never fall back to props
|
|
const sourceRequests = hasFetchedFromApi ? apiRequests : dynamicRequests;
|
|
const convertedDynamicRequests = Array.isArray(sourceRequests) ? sourceRequests.map((req: any) => {
|
|
const createdAt = req.submittedAt || req.submitted_at || req.createdAt || req.created_at;
|
|
const priority = (req.priority || '').toString().toLowerCase();
|
|
|
|
return {
|
|
id: req.requestNumber || req.request_number || req.requestId || req.id || req.request_id,
|
|
requestId: req.requestId || req.id || req.request_id,
|
|
displayId: req.requestNumber || req.request_number || req.id,
|
|
title: req.title,
|
|
description: req.description,
|
|
status: (req.status || '').toString().toLowerCase().replace('_','-'),
|
|
priority: priority,
|
|
department: req.department,
|
|
submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined),
|
|
createdAt: createdAt,
|
|
currentApprover: req.currentApprover?.name || req.currentApprover?.email || '—',
|
|
approverLevel: req.currentLevel && req.totalLevels ? `${req.currentLevel} of ${req.totalLevels}` : (req.currentStep && req.totalSteps ? `${req.currentStep} of ${req.totalSteps}` : '—'),
|
|
templateType: req.templateType,
|
|
templateName: req.templateName
|
|
};
|
|
}) : [];
|
|
|
|
// Use only API/dynamic requests - backend already filtered
|
|
const allRequests = convertedDynamicRequests;
|
|
|
|
// No frontend filtering - backend handles all filtering
|
|
const filteredRequests = allRequests;
|
|
|
|
// Stats calculation - using total from pagination for total count
|
|
const stats = {
|
|
total: totalRecords || allRequests.length,
|
|
pending: allRequests.filter(r => r.status === 'pending').length,
|
|
approved: allRequests.filter(r => r.status === 'approved').length,
|
|
rejected: allRequests.filter(r => r.status === 'rejected').length,
|
|
draft: allRequests.filter(r => r.status === 'draft').length,
|
|
closed: allRequests.filter(r => r.status === 'closed').length
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="my-requests-page">
|
|
{/* Page Header */}
|
|
<PageHeader
|
|
icon={FileText}
|
|
title="My Requests"
|
|
description="Track and manage all your submitted requests"
|
|
badge={{
|
|
value: `${totalRecords || allRequests.length} total`,
|
|
label: 'requests',
|
|
loading
|
|
}}
|
|
testId="my-requests-header"
|
|
/>
|
|
|
|
{/* Stats Overview */}
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 sm:gap-4" data-testid="my-requests-stats">
|
|
<StatsCard
|
|
label="Total"
|
|
value={stats.total}
|
|
icon={FileText}
|
|
iconColor="text-blue-600"
|
|
gradient="bg-gradient-to-br from-blue-50 to-blue-100 border-blue-200"
|
|
textColor="text-blue-700"
|
|
valueColor="text-blue-900"
|
|
testId="stat-total"
|
|
/>
|
|
|
|
<StatsCard
|
|
label="Pending"
|
|
value={stats.pending}
|
|
icon={Clock}
|
|
iconColor="text-orange-600"
|
|
gradient="bg-gradient-to-br from-orange-50 to-orange-100 border-orange-200"
|
|
textColor="text-orange-700"
|
|
valueColor="text-orange-900"
|
|
testId="stat-pending"
|
|
/>
|
|
|
|
<StatsCard
|
|
label="Approved"
|
|
value={stats.approved}
|
|
icon={CheckCircle}
|
|
iconColor="text-green-600"
|
|
gradient="bg-gradient-to-br from-green-50 to-green-100 border-green-200"
|
|
textColor="text-green-700"
|
|
valueColor="text-green-900"
|
|
testId="stat-approved"
|
|
/>
|
|
|
|
<StatsCard
|
|
label="Rejected"
|
|
value={stats.rejected}
|
|
icon={XCircle}
|
|
iconColor="text-red-600"
|
|
gradient="bg-gradient-to-br from-red-50 to-red-100 border-red-200"
|
|
textColor="text-red-700"
|
|
valueColor="text-red-900"
|
|
testId="stat-rejected"
|
|
/>
|
|
|
|
<StatsCard
|
|
label="Draft"
|
|
value={stats.draft}
|
|
icon={Edit}
|
|
iconColor="text-gray-600"
|
|
gradient="bg-gradient-to-br from-gray-50 to-gray-100 border-gray-200"
|
|
textColor="text-gray-700"
|
|
valueColor="text-gray-900"
|
|
testId="stat-draft"
|
|
/>
|
|
</div>
|
|
|
|
{/* Filters and Search */}
|
|
<Card className="border-gray-200" data-testid="my-requests-filters">
|
|
<CardContent className="p-3 sm:p-4 md:p-6">
|
|
<div className="flex flex-col md:flex-row gap-3 sm:gap-4 items-start md:items-center">
|
|
<div className="flex-1 relative w-full">
|
|
<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 by title, description, or ID..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-9 text-sm sm:text-base bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
|
data-testid="search-input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2 sm:gap-3 w-full md:w-auto">
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger
|
|
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
|
data-testid="status-filter"
|
|
>
|
|
<SelectValue placeholder="Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Status</SelectItem>
|
|
<SelectItem value="draft">Draft</SelectItem>
|
|
<SelectItem value="pending">Pending</SelectItem>
|
|
<SelectItem value="approved">Approved</SelectItem>
|
|
<SelectItem value="rejected">Rejected</SelectItem>
|
|
<SelectItem value="closed">Closed</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
|
<SelectTrigger
|
|
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
|
data-testid="priority-filter"
|
|
>
|
|
<SelectValue placeholder="Priority" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Priority</SelectItem>
|
|
<SelectItem value="express">Express</SelectItem>
|
|
<SelectItem value="standard">Standard</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Requests List */}
|
|
<div className="space-y-4" data-testid="my-requests-list">
|
|
{loading ? (
|
|
<Card data-testid="loading-state">
|
|
<CardContent className="p-6 text-sm text-gray-600">Loading your requests…</CardContent>
|
|
</Card>
|
|
) : filteredRequests.length === 0 ? (
|
|
<Card data-testid="empty-state">
|
|
<CardContent className="p-12 text-center">
|
|
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No requests found</h3>
|
|
<p className="text-gray-600">
|
|
{searchTerm || statusFilter !== 'all' || priorityFilter !== 'all'
|
|
? 'Try adjusting your search or filters'
|
|
: 'You haven\'t created any requests yet'}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
filteredRequests.map((request, index) => (
|
|
<motion.div
|
|
key={request.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.1 }}
|
|
>
|
|
<Card
|
|
className="group hover:shadow-lg transition-all duration-300 cursor-pointer border border-gray-200 shadow-sm hover:shadow-md"
|
|
onClick={() => onViewRequest(request.id, request.title, request.status)}
|
|
data-testid={`request-card-${request.id}`}
|
|
>
|
|
<CardContent className="p-3 sm:p-6">
|
|
<div className="space-y-3 sm:space-y-4">
|
|
{/* Header with Title and Status Badges */}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<h4
|
|
className="text-base sm:text-lg font-semibold text-gray-900 mb-2 group-hover:text-blue-600 transition-colors line-clamp-2"
|
|
data-testid="request-title"
|
|
>
|
|
{request.title}
|
|
</h4>
|
|
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mb-2">
|
|
<Badge
|
|
variant="outline"
|
|
className={`${getStatusConfig(request.status).color} border font-medium text-xs shrink-0`}
|
|
data-testid="status-badge"
|
|
>
|
|
{(() => {
|
|
const IconComponent = getStatusConfig(request.status).icon;
|
|
return <IconComponent className="w-3 h-3 mr-1" />;
|
|
})()}
|
|
<span className="capitalize">{request.status}</span>
|
|
</Badge>
|
|
<Badge
|
|
variant="outline"
|
|
className={`${getPriorityConfig(request.priority).color} border font-medium text-xs capitalize shrink-0`}
|
|
data-testid="priority-badge"
|
|
>
|
|
{(() => {
|
|
const IconComponent = getPriorityConfig(request.priority).icon;
|
|
return <IconComponent className="w-3 h-3 mr-1" />;
|
|
})()}
|
|
{request.priority}
|
|
</Badge>
|
|
{(request as any).templateType && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="bg-purple-100 text-purple-700 text-xs shrink-0 hidden sm:inline-flex"
|
|
data-testid="template-badge"
|
|
>
|
|
<FileText className="w-3 h-3 mr-1" />
|
|
Template: {(request as any).templateName}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p
|
|
className="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3 line-clamp-2"
|
|
data-testid="request-description"
|
|
>
|
|
{request.description}
|
|
</p>
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4 text-xs sm:text-sm text-gray-500">
|
|
<span className="truncate" data-testid="request-id-display">
|
|
<span className="font-medium">ID:</span> {(request as any).displayId || request.id}
|
|
</span>
|
|
<span className="truncate" data-testid="submitted-date">
|
|
<span className="font-medium">Submitted:</span> {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<ArrowRight className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 group-hover:text-blue-600 transition-colors flex-shrink-0 mt-1" />
|
|
</div>
|
|
|
|
{/* Current Approver and Level Info */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 pt-3 border-t border-gray-100">
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<User className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
|
|
<span className="text-xs sm:text-sm truncate" data-testid="current-approver">
|
|
<span className="text-gray-500">Current Approver:</span> <span className="text-gray-900 font-medium">{request.currentApprover}</span>
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
|
|
<span className="text-xs sm:text-sm" data-testid="approval-level">
|
|
<span className="text-gray-500">Approval Level:</span> <span className="text-gray-900 font-medium">{request.approverLevel}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
|
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
|
<span data-testid="submitted-timestamp">
|
|
Submitted: {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
totalRecords={totalRecords}
|
|
itemsPerPage={itemsPerPage}
|
|
onPageChange={handlePageChange}
|
|
loading={loading}
|
|
itemLabel="requests"
|
|
testIdPrefix="my-requests-pagination"
|
|
/>
|
|
</div>
|
|
);
|
|
} |