code rafactor done for few screns

This commit is contained in:
laxmanhalaki 2025-11-19 20:20:14 +05:30
parent 12d1094f45
commit 00f0b786f6
131 changed files with 15551 additions and 11874 deletions

View File

@ -27,41 +27,41 @@ export function KPICard({
}: KPICardProps) {
return (
<Card
className="hover:shadow-lg transition-all duration-200 shadow-md cursor-pointer"
className="hover:shadow-lg transition-all duration-200 shadow-md cursor-pointer h-full flex flex-col"
onClick={onClick}
data-testid={testId}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle
className="text-sm font-medium text-muted-foreground"
data-testid={`${testId}-title`}
>
{title}
</CardTitle>
<div className={`p-2 sm:p-3 rounded-lg ${iconBgColor}`} data-testid={`${testId}-icon-wrapper`}>
<div className={`p-1.5 sm:p-2 rounded-lg ${iconBgColor}`} data-testid={`${testId}-icon-wrapper`}>
<Icon
className={`h-4 w-4 sm:h-5 sm:w-5 ${iconColor}`}
className={`h-4 w-4 sm:h-4 sm:w-4 ${iconColor}`}
data-testid={`${testId}-icon`}
/>
</div>
</CardHeader>
<CardContent>
<CardContent className="flex flex-col flex-1 py-3">
<div
className="text-2xl sm:text-3xl font-bold text-gray-900 mb-3"
className="text-xl sm:text-2xl font-bold text-gray-900 mb-2"
data-testid={`${testId}-value`}
>
{value}
</div>
{subtitle && (
<div
className="text-xs text-muted-foreground mb-3"
className="text-xs text-muted-foreground mb-2"
data-testid={`${testId}-subtitle`}
>
{subtitle}
</div>
)}
{children && (
<div data-testid={`${testId}-children`}>
<div className="flex-1 flex flex-col" data-testid={`${testId}-children`}>
{children}
</div>
)}

View File

@ -26,7 +26,7 @@ export function StatCard({
onClick={onClick}
>
<p
className="text-xs text-gray-600 mb-1"
className="text-xs text-gray-600 mb-1 leading-tight"
data-testid={`${testId}-label`}
>
{label}

View File

@ -0,0 +1,359 @@
import { motion } from 'framer-motion';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Users, Settings, Shield, User, CheckCircle, AlertCircle, Info, Clock, Minus, Plus } from 'lucide-react';
import { FormData } from '@/hooks/useCreateRequestForm';
import { useMultiUserSearch } from '@/hooks/useUserSearch';
import { useAuth } from '@/contexts/AuthContext';
import { ensureUserExists } from '@/services/userApi';
interface ApprovalWorkflowStepProps {
formData: FormData;
updateFormData: (field: keyof FormData, value: any) => void;
onValidationError: (error: { type: string; email: string; message: string }) => void;
}
/**
* Component: ApprovalWorkflowStep
*
* Purpose: Step 3 - Approval workflow configuration
*
* Features:
* - Configure number of approvers
* - Define approval hierarchy with TAT
* - User search with @ prefix
* - Test IDs for testing
*
* Note: This is a simplified version. Full implementation includes complex approver management.
*/
export function ApprovalWorkflowStep({
formData,
updateFormData,
onValidationError
}: ApprovalWorkflowStepProps) {
const { user } = useAuth();
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
const handleApproverEmailChange = (index: number, value: string) => {
const newApprovers = [...formData.approvers];
const previousEmail = newApprovers[index]?.email;
const emailChanged = previousEmail !== value;
newApprovers[index] = {
...newApprovers[index],
email: value,
level: index + 1,
userId: emailChanged ? undefined : newApprovers[index]?.userId,
name: emailChanged ? undefined : newApprovers[index]?.name,
department: emailChanged ? undefined : newApprovers[index]?.department,
avatar: emailChanged ? undefined : newApprovers[index]?.avatar
};
updateFormData('approvers', newApprovers);
if (!value || !value.startsWith('@') || value.length < 2) {
clearSearchForIndex(index);
return;
}
searchUsersForIndex(index, value, 10);
};
const handleUserSelect = async (index: number, selectedUser: any) => {
try {
const dbUser = await ensureUserExists({
userId: selectedUser.userId,
email: selectedUser.email,
displayName: selectedUser.displayName,
firstName: selectedUser.firstName,
lastName: selectedUser.lastName,
department: selectedUser.department,
phone: selectedUser.phone,
mobilePhone: selectedUser.mobilePhone,
designation: selectedUser.designation,
jobTitle: selectedUser.jobTitle,
manager: selectedUser.manager,
employeeId: selectedUser.employeeId,
employeeNumber: selectedUser.employeeNumber,
secondEmail: selectedUser.secondEmail,
location: selectedUser.location
});
const updated = [...formData.approvers];
updated[index] = {
...updated[index],
email: selectedUser.email,
name: selectedUser.displayName || [selectedUser.firstName, selectedUser.lastName].filter(Boolean).join(' '),
userId: dbUser.userId,
level: index + 1,
};
updateFormData('approvers', updated);
clearSearchForIndex(index);
} catch (err) {
console.error('Failed to ensure user exists:', err);
onValidationError({
type: 'error',
email: selectedUser.email,
message: 'Failed to validate user. Please try again.'
});
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
data-testid="approval-workflow-step"
>
<div className="text-center mb-8" data-testid="approval-workflow-header">
<div className="w-16 h-16 bg-gradient-to-br from-orange-500 to-red-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Users className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2" data-testid="approval-workflow-title">
Approval Workflow
</h2>
<p className="text-gray-600" data-testid="approval-workflow-description">
Define the approval hierarchy and assign approvers by email ID.
</p>
</div>
<div className="max-w-4xl mx-auto space-y-8" data-testid="approval-workflow-content">
{/* Number of Approvers */}
<Card data-testid="approval-workflow-config-card">
<CardHeader>
<CardTitle className="flex items-center gap-2" data-testid="approval-workflow-config-title">
<Settings className="w-5 h-5" />
Approval Configuration
</CardTitle>
<CardDescription>
Configure how many approvers you need and define the approval sequence.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div data-testid="approval-workflow-count-field">
<Label className="text-base font-semibold mb-4 block">Number of Approvers *</Label>
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const currentCount = formData.approverCount || 1;
const newCount = Math.max(1, currentCount - 1);
updateFormData('approverCount', newCount);
if (formData.approvers.length > newCount) {
updateFormData('approvers', formData.approvers.slice(0, newCount));
}
}}
disabled={(formData.approverCount || 1) <= 1}
data-testid="approval-workflow-decrease-count"
>
<Minus className="w-4 h-4" />
</Button>
<span className="text-2xl font-semibold w-12 text-center" data-testid="approval-workflow-count-display">
{formData.approverCount || 1}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const currentCount = formData.approverCount || 1;
const newCount = Math.min(10, currentCount + 1);
updateFormData('approverCount', newCount);
}}
disabled={(formData.approverCount || 1) >= 10}
data-testid="approval-workflow-increase-count"
>
<Plus className="w-4 h-4" />
</Button>
</div>
<p className="text-sm text-gray-600 mt-2">
Maximum 10 approvers allowed. Each approver will review sequentially.
</p>
</div>
</CardContent>
</Card>
{/* Approval Hierarchy */}
<Card data-testid="approval-workflow-hierarchy-card">
<CardHeader>
<CardTitle className="flex items-center gap-2" data-testid="approval-workflow-hierarchy-title">
<Shield className="w-5 h-5" />
Approval Hierarchy *
</CardTitle>
<CardDescription>
Define the approval sequence. Each approver will review the request in order from Level 1 to Level {formData.approverCount || 1}.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Initiator Card */}
<div className="p-4 rounded-lg border-2 border-blue-200 bg-blue-50" data-testid="approval-workflow-initiator-card">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-blue-900">Request Initiator</span>
<Badge variant="secondary" className="text-xs">YOU</Badge>
</div>
<p className="text-sm text-blue-700">Creates and submits the request</p>
</div>
</div>
</div>
{/* Dynamic Approver Cards */}
{Array.from({ length: formData.approverCount || 1 }, (_, index) => {
const level = index + 1;
const isLast = level === (formData.approverCount || 1);
if (!formData.approvers[index]) {
const newApprovers = [...formData.approvers];
newApprovers[index] = { email: '', name: '', level: level, tat: '' as any };
updateFormData('approvers', newApprovers);
}
return (
<div key={level} className="space-y-3" data-testid={`approval-workflow-approver-level-${level}`}>
<div className="flex justify-center">
<div className="w-px h-6 bg-gray-300"></div>
</div>
<div className={`p-4 rounded-lg border-2 transition-all ${
formData.approvers[index]?.email
? 'border-green-200 bg-green-50'
: 'border-gray-200 bg-gray-50'
}`}>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
formData.approvers[index]?.email
? 'bg-green-600'
: 'bg-gray-400'
}`}>
<span className="text-white font-semibold">{level}</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-3">
<span className="font-semibold text-gray-900">
Approver Level {level}
</span>
{isLast && (
<Badge variant="destructive" className="text-xs">FINAL APPROVER</Badge>
)}
</div>
<div className="space-y-4">
<div data-testid={`approval-workflow-approver-${level}-email-field`}>
<div className="flex items-center justify-between mb-1">
<Label htmlFor={`approver-${level}`} className="text-sm font-medium">
Email Address *
</Label>
{formData.approvers[index]?.email && formData.approvers[index]?.userId && (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
<CheckCircle className="w-3 h-3 mr-1" />
Verified
</Badge>
)}
</div>
<div className="relative">
<Input
id={`approver-${level}`}
type="email"
placeholder="approver@royalenfield.com"
value={formData.approvers[index]?.email || ''}
onChange={(e) => handleApproverEmailChange(index, e.target.value)}
className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full"
data-testid={`approval-workflow-approver-${level}-email-input`}
/>
{/* Search suggestions dropdown */}
{(userSearchLoading[index] || (userSearchResults[index]?.length || 0) > 0) && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
{userSearchLoading[index] ? (
<div className="p-2 text-xs text-gray-500">Searching...</div>
) : (
<ul className="max-h-56 overflow-auto divide-y">
{userSearchResults[index]?.map((u) => (
<li
key={u.userId}
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
onClick={() => handleUserSelect(index, u)}
data-testid={`approval-workflow-approver-${level}-search-result-${u.userId}`}
>
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
<div className="text-xs text-gray-600">{u.email}</div>
</li>
))}
</ul>
)}
</div>
)}
</div>
</div>
<div data-testid={`approval-workflow-approver-${level}-tat-field`}>
<Label htmlFor={`tat-${level}`} className="text-sm font-medium">
TAT (Turn Around Time) *
</Label>
<div className="flex items-center gap-2 mt-1">
<Input
id={`tat-${level}`}
type="number"
placeholder={formData.approvers[index]?.tatType === 'days' ? '7' : '24'}
min="1"
max={formData.approvers[index]?.tatType === 'days' ? '30' : '720'}
value={formData.approvers[index]?.tat || ''}
onChange={(e) => {
const newApprovers = [...formData.approvers];
newApprovers[index] = {
...newApprovers[index],
tat: parseInt(e.target.value) || '',
level: level,
tatType: formData.approvers[index]?.tatType || 'hours'
};
updateFormData('approvers', newApprovers);
}}
className="h-10 border-2 border-gray-300 focus:border-blue-500 flex-1"
data-testid={`approval-workflow-approver-${level}-tat-input`}
/>
<Select
value={formData.approvers[index]?.tatType || 'hours'}
onValueChange={(value) => {
const newApprovers = [...formData.approvers];
newApprovers[index] = {
...newApprovers[index],
tatType: value as 'hours' | 'days',
level: level,
tat: ''
};
updateFormData('approvers', newApprovers);
}}
data-testid={`approval-workflow-approver-${level}-tat-type-select`}
>
<SelectTrigger className="w-20 h-10 border-2 border-gray-300">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
})}
</CardContent>
</Card>
</div>
</motion.div>
);
}

View File

@ -0,0 +1,225 @@
import { motion } from 'framer-motion';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Badge } from '@/components/ui/badge';
import { FileText, Zap, Clock } from 'lucide-react';
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
interface BasicInformationStepProps {
formData: FormData;
selectedTemplate: RequestTemplate | null;
updateFormData: (field: keyof FormData, value: any) => void;
}
/**
* Component: BasicInformationStep
*
* Purpose: Step 2 - Basic information form (title, description, priority)
*
* Features:
* - Request title and description inputs
* - Priority selection (Express/Standard)
* - Template-specific additional fields
* - Test IDs for testing
*/
export function BasicInformationStep({
formData,
selectedTemplate,
updateFormData
}: BasicInformationStepProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
data-testid="basic-information-step"
>
<div className="text-center mb-8" data-testid="basic-information-header">
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<FileText className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2" data-testid="basic-information-title">
Basic Information
</h2>
<p className="text-gray-600" data-testid="basic-information-description">
Provide the essential details for your {selectedTemplate?.name || 'request'}.
</p>
</div>
<div className="max-w-2xl mx-auto space-y-6" data-testid="basic-information-form">
<div data-testid="basic-information-title-field">
<Label htmlFor="title" className="text-base font-semibold">Request Title *</Label>
<p className="text-sm text-gray-600 mb-3">
Be specific and descriptive. This will be visible to all participants.
</p>
<Input
id="title"
placeholder="e.g., Approval on new office location"
value={formData.title}
onChange={(e) => updateFormData('title', e.target.value)}
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
data-testid="basic-information-title-input"
/>
</div>
<div data-testid="basic-information-description-field">
<Label htmlFor="description" className="text-base font-semibold">Detailed Description *</Label>
<p className="text-sm text-gray-600 mb-3">
Explain what you need approval for, why it's needed, and any relevant background information.
</p>
<Textarea
id="description"
placeholder="Provide comprehensive details about your request including scope, objectives, expected outcomes, and any supporting context that will help approvers make an informed decision."
className="min-h-[120px] text-base border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm resize-none"
value={formData.description}
onChange={(e) => updateFormData('description', e.target.value)}
data-testid="basic-information-description-textarea"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6" data-testid="basic-information-priority-section">
<div data-testid="basic-information-priority-field">
<Label className="text-base font-semibold">Priority Level *</Label>
<p className="text-sm text-gray-600 mb-2">
select priority for your request
</p>
<RadioGroup
value={formData.priority || ''}
onValueChange={(value) => updateFormData('priority', value)}
data-testid="basic-information-priority-radio-group"
>
<div
className={`flex items-center space-x-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
formData.priority === 'express'
? 'border-red-500 bg-red-100'
: 'border-red-200 bg-red-50 hover:bg-red-100'
}`}
onClick={() => updateFormData('priority', 'express')}
data-testid="basic-information-priority-express-option"
>
<RadioGroupItem value="express" id="express" />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Zap className="w-4 h-4 text-red-600" />
<Label htmlFor="express" className="font-medium text-red-900 cursor-pointer">Express</Label>
<Badge variant="destructive" className="text-xs">URGENT</Badge>
</div>
<p className="text-xs text-red-700">
Includes calendar days in TAT - faster processing timeline
</p>
</div>
</div>
<div
className={`flex items-center space-x-3 p-3 rounded-lg border cursor-pointer transition-all ${
formData.priority === 'standard'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:bg-gray-50'
}`}
onClick={() => updateFormData('priority', 'standard')}
data-testid="basic-information-priority-standard-option"
>
<RadioGroupItem value="standard" id="standard" />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-blue-600" />
<Label htmlFor="standard" className="font-medium text-blue-900 cursor-pointer">Standard</Label>
<Badge variant="secondary" className="text-xs">DEFAULT</Badge>
</div>
<p className="text-xs text-gray-600">
Includes working days in TAT - regular processing timeline
</p>
</div>
</div>
</RadioGroup>
</div>
</div>
{/* Template-specific fields */}
{(selectedTemplate?.fields.amount || selectedTemplate?.fields.vendor || selectedTemplate?.fields.timeline || selectedTemplate?.fields.impact) && (
<div className="border-t pt-6" data-testid="basic-information-additional-details">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Additional Details</h3>
<div className="space-y-6">
{selectedTemplate?.fields.amount && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4" data-testid="basic-information-amount-field">
<div className="md:col-span-2">
<Label htmlFor="amount" className="text-base font-semibold">Budget Amount</Label>
<Input
id="amount"
placeholder="Enter amount"
value={formData.amount}
onChange={(e) => updateFormData('amount', e.target.value)}
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
data-testid="basic-information-amount-input"
/>
</div>
<div>
<Label className="text-base font-semibold">Currency</Label>
<Select
value={formData.currency}
onValueChange={(value) => updateFormData('currency', value)}
data-testid="basic-information-currency-select"
>
<SelectTrigger className="h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD ($)</SelectItem>
<SelectItem value="EUR">EUR ()</SelectItem>
<SelectItem value="GBP">GBP (£)</SelectItem>
<SelectItem value="INR">INR ()</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{selectedTemplate?.fields.vendor && (
<div data-testid="basic-information-vendor-field">
<Label htmlFor="vendor" className="text-base font-semibold">Vendor/Supplier</Label>
<Input
id="vendor"
placeholder="Enter vendor or supplier name"
value={formData.vendor}
onChange={(e) => updateFormData('vendor', e.target.value)}
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
data-testid="basic-information-vendor-input"
/>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div data-testid="basic-information-cost-center-field">
<Label htmlFor="costCenter" className="text-base font-semibold">Cost Center</Label>
<Input
id="costCenter"
placeholder="e.g., Marketing, IT, Operations"
value={formData.costCenter}
onChange={(e) => updateFormData('costCenter', e.target.value)}
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
data-testid="basic-information-cost-center-input"
/>
</div>
<div data-testid="basic-information-project-field">
<Label htmlFor="project" className="text-base font-semibold">Related Project</Label>
<Input
id="project"
placeholder="Associated project name or code"
value={formData.project}
onChange={(e) => updateFormData('project', e.target.value)}
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
data-testid="basic-information-project-input"
/>
</div>
</div>
</div>
</div>
)}
</div>
</motion.div>
);
}

View File

@ -0,0 +1,307 @@
import { motion } from 'framer-motion';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Upload, FileText, Eye, X, Plus } from 'lucide-react';
interface DocumentPolicy {
maxFileSizeMB: number;
allowedFileTypes: string[];
}
interface DocumentsStepProps {
documentPolicy: DocumentPolicy;
isEditing: boolean;
documents: File[];
existingDocuments: any[];
documentsToDelete: string[];
onDocumentsChange: (documents: File[]) => void;
onExistingDocumentsChange: (documents: any[]) => void;
onDocumentsToDeleteChange: (ids: string[]) => void;
onPreviewDocument: (doc: any, isExisting: boolean) => void;
onDocumentErrors?: (errors: Array<{ fileName: string; reason: string }>) => void;
fileInputRef: React.RefObject<HTMLInputElement>;
}
/**
* Component: DocumentsStep
*
* Purpose: Step 5 - Document upload and management
*
* Features:
* - File upload with validation
* - Preview existing documents
* - Delete documents
* - Test IDs for testing
*/
export function DocumentsStep({
documentPolicy,
isEditing,
documents,
existingDocuments,
documentsToDelete,
onDocumentsChange,
onExistingDocumentsChange,
onDocumentsToDeleteChange,
onPreviewDocument,
onDocumentErrors,
fileInputRef
}: DocumentsStepProps) {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
// Validate files
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
const validationErrors: Array<{ fileName: string; reason: string }> = [];
const validFiles: File[] = [];
files.forEach(file => {
// Check file size
if (file.size > maxSizeBytes) {
validationErrors.push({
fileName: file.name,
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
});
return;
}
// Check file extension
const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
validationErrors.push({
fileName: file.name,
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
});
return;
}
validFiles.push(file);
});
// Update parent with valid files
if (validFiles.length > 0) {
onDocumentsChange([...documents, ...validFiles]);
}
// Show errors if any
if (validationErrors.length > 0 && onDocumentErrors) {
onDocumentErrors(validationErrors);
}
// Reset file input
if (event.target) {
event.target.value = '';
}
};
const handleRemove = (index: number) => {
const newDocs = documents.filter((_, i) => i !== index);
onDocumentsChange(newDocs);
};
const handleDeleteExisting = (docId: string) => {
onDocumentsToDeleteChange([...documentsToDelete, docId]);
};
const canPreview = (doc: any, isExisting: boolean = false): boolean => {
if (isExisting) {
const type = (doc.fileType || doc.file_type || '').toLowerCase();
const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
} else {
const type = (doc.type || '').toLowerCase();
const name = (doc.name || '').toLowerCase();
return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
data-testid="documents-step"
>
<div className="text-center mb-8" data-testid="documents-header">
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Upload className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2" data-testid="documents-title">
Documents & Attachments
</h2>
<p className="text-gray-600" data-testid="documents-description">
Upload supporting documents, files, and any additional materials for your request.
</p>
</div>
<div className="max-w-2xl mx-auto space-y-6" data-testid="documents-content">
<Card data-testid="documents-upload-card">
<CardHeader>
<CardTitle className="flex items-center gap-2" data-testid="documents-upload-title">
<FileText className="w-5 h-5" />
File Upload
</CardTitle>
<CardDescription>
Attach supporting documents. Max {documentPolicy.maxFileSizeMB}MB per file. Allowed types: {documentPolicy.allowedFileTypes.join(', ')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors" data-testid="documents-upload-area">
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
<p className="text-gray-600 mb-4">
Drag and drop files here, or click to browse
</p>
<input
type="file"
multiple
accept={documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',')}
onChange={handleFileChange}
className="hidden"
id="file-upload"
ref={fileInputRef}
data-testid="documents-file-input"
/>
<Button
variant="outline"
size="lg"
type="button"
onClick={() => fileInputRef.current?.click()}
data-testid="documents-browse-button"
>
<Plus className="w-4 h-4 mr-2" />
Browse Files
</Button>
<p className="text-xs text-gray-500 mt-2">
Supported formats: {documentPolicy.allowedFileTypes.map(ext => ext.toUpperCase()).join(', ')} (Max {documentPolicy.maxFileSizeMB}MB per file)
</p>
</div>
</CardContent>
</Card>
{/* Existing Documents */}
{isEditing && existingDocuments.length > 0 && (
<Card data-testid="documents-existing-card">
<CardHeader>
<CardTitle className="flex items-center justify-between" data-testid="documents-existing-title">
<span>Existing Documents</span>
<Badge variant="secondary" data-testid="documents-existing-count">
{existingDocuments.filter(d => !documentsToDelete.includes(d.documentId || d.document_id || '')).length} file{existingDocuments.filter(d => !documentsToDelete.includes(d.documentId || d.document_id || '')).length !== 1 ? 's' : ''}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3" data-testid="documents-existing-list">
{existingDocuments.map((doc: any) => {
const docId = doc.documentId || doc.document_id || '';
const isDeleted = documentsToDelete.includes(docId);
if (isDeleted) return null;
return (
<div key={docId} className="flex items-center justify-between p-4 rounded-lg border bg-gray-50" data-testid={`documents-existing-${docId}`}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<FileText className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-gray-900">{doc.originalFileName || doc.fileName || 'Document'}</p>
<div className="flex items-center gap-3 text-sm text-gray-600">
<span>{doc.fileSize ? (Number(doc.fileSize) / (1024 * 1024)).toFixed(2) + ' MB' : 'Unknown size'}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{canPreview(doc, true) && (
<Button
variant="ghost"
size="sm"
onClick={() => onPreviewDocument(doc, true)}
data-testid={`documents-existing-${docId}-preview`}
>
<Eye className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteExisting(docId)}
data-testid={`documents-existing-${docId}-delete`}
>
<X className="h-4 w-4 text-red-600" />
</Button>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* New Documents */}
{documents.length > 0 && (
<Card data-testid="documents-new-card">
<CardHeader>
<CardTitle className="flex items-center justify-between" data-testid="documents-new-title">
<span>New Files to Upload</span>
<Badge variant="secondary" data-testid="documents-new-count">
{documents.length} file{documents.length !== 1 ? 's' : ''}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3" data-testid="documents-new-list">
{documents.map((file, index) => (
<div key={index} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border" data-testid={`documents-new-${index}`}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<FileText className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-gray-900">{file.name}</p>
<div className="flex items-center gap-3 text-sm text-gray-600">
<span>{(file.size / (1024 * 1024)).toFixed(2)} MB</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{canPreview(file, false) && (
<Button
variant="ghost"
size="sm"
onClick={() => onPreviewDocument(file, false)}
data-testid={`documents-new-${index}-preview`}
>
<Eye className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(index)}
data-testid={`documents-new-${index}-remove`}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</motion.div>
);
}

View File

@ -0,0 +1,227 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Eye, Info, X } from 'lucide-react';
import { FormData } from '@/hooks/useCreateRequestForm';
import { useUserSearch } from '@/hooks/useUserSearch';
import { ensureUserExists } from '@/services/userApi';
interface ParticipantsStepProps {
formData: FormData;
updateFormData: (field: keyof FormData, value: any) => void;
onValidationError: (error: { type: string; email: string; message: string }) => void;
initiatorEmail: string;
}
/**
* Component: ParticipantsStep
*
* Purpose: Step 4 - Participants & access settings
*
* Features:
* - Add spectators with @ search
* - Manage spectator list
* - Test IDs for testing
*/
export function ParticipantsStep({
formData,
updateFormData,
onValidationError,
initiatorEmail
}: ParticipantsStepProps) {
const [emailInput, setEmailInput] = useState('');
const { searchResults, searchLoading, searchUsersDebounced, clearSearch, ensureUser } = useUserSearch();
const handleEmailInputChange = (value: string) => {
setEmailInput(value);
if (!value || !value.startsWith('@') || value.length < 2) {
clearSearch();
return;
}
searchUsersDebounced(value, 10);
};
const handleAddSpectator = async (user?: any) => {
if (user) {
// Add from search results
if (user.email.toLowerCase() === initiatorEmail.toLowerCase()) {
onValidationError({
type: 'self-assign',
email: user.email,
message: 'You cannot add yourself as a spectator.'
});
return;
}
try {
const dbUser = await ensureUser(user);
const spectator = {
id: dbUser.userId,
userId: dbUser.userId,
name: dbUser.displayName || user.email.split('@')[0],
email: dbUser.email,
avatar: (dbUser.displayName || dbUser.email).substring(0, 2).toUpperCase(),
role: 'Spectator',
department: dbUser.department || '',
level: 1,
canClose: false
};
const updatedSpectators = [...formData.spectators, spectator];
updateFormData('spectators', updatedSpectators);
setEmailInput('');
clearSearch();
} catch (err) {
onValidationError({
type: 'error',
email: user.email,
message: 'Failed to validate user. Please try again.'
});
}
} else if (emailInput && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput)) {
// Add by email directly (will be validated)
if (emailInput.toLowerCase() === initiatorEmail.toLowerCase()) {
onValidationError({
type: 'self-assign',
email: emailInput,
message: 'You cannot add yourself as a spectator.'
});
return;
}
// This would trigger validation in parent component
}
};
const removeSpectator = (spectatorId: string) => {
const updatedSpectators = formData.spectators.filter((s: any) => s.id !== spectatorId);
updateFormData('spectators', updatedSpectators);
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
data-testid="participants-step"
>
<div className="text-center mb-8" data-testid="participants-header">
<div className="w-16 h-16 bg-gradient-to-br from-teal-500 to-green-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Eye className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2" data-testid="participants-title">
Participants & Access
</h2>
<p className="text-gray-600" data-testid="participants-description">
Configure additional participants and visibility settings for your request.
</p>
</div>
<div className="max-w-3xl mx-auto space-y-8" data-testid="participants-content">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Spectators */}
<Card data-testid="participants-spectators-card">
<CardHeader>
<CardTitle className="flex items-center justify-between text-base" data-testid="participants-spectators-title">
<div className="flex items-center gap-2">
<Eye className="w-4 h-4" />
Spectators
</div>
<Badge variant="outline" className="text-xs" data-testid="participants-spectators-count">
{formData.spectators.length}
</Badge>
</CardTitle>
<CardDescription>
Users who can view and comment but cannot approve
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2" data-testid="participants-spectators-add-section">
<div className="flex items-center gap-2">
<div className="relative w-full">
<Input
placeholder="Use @ sign to add a user"
value={emailInput}
onChange={(e) => handleEmailInputChange(e.target.value)}
onKeyPress={async (e) => {
if (e.key === 'Enter' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput)) {
e.preventDefault();
await handleAddSpectator();
}
}}
className="text-sm w-full"
data-testid="participants-spectators-email-input"
/>
{(searchLoading || searchResults.length > 0) && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
{searchLoading ? (
<div className="p-2 text-xs text-gray-500">Searching...</div>
) : (
<ul className="max-h-56 overflow-auto divide-y">
{searchResults.map(u => (
<li
key={u.userId}
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
onClick={() => handleAddSpectator(u)}
data-testid={`participants-spectators-search-result-${u.userId}`}
>
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
<div className="text-xs text-gray-600">{u.email}</div>
</li>
))}
</ul>
)}
</div>
)}
</div>
<Button
size="sm"
onClick={() => handleAddSpectator()}
disabled={!emailInput || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailInput)}
data-testid="participants-spectators-add-button"
>
Add
</Button>
</div>
<p className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded p-2 flex items-center gap-1">
<Info className="w-3 h-3 flex-shrink-0" />
<span>
Use <span className="font-mono bg-blue-100 px-1 rounded">@</span> sign to search users, or type email directly (will be validated against organization directory)
</span>
</p>
</div>
<div className="space-y-2 max-h-40 overflow-y-auto" data-testid="participants-spectators-list">
{formData.spectators.map((spectator) => (
<div key={spectator.id} className="flex items-center justify-between p-2 bg-teal-50 rounded-lg" data-testid={`participants-spectator-${spectator.id}`}>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarFallback className="bg-teal-600 text-white text-xs">
{spectator.avatar}
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{spectator.name}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeSpectator(spectator.id)}
data-testid={`participants-spectator-${spectator.id}-remove`}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</motion.div>
);
}

View File

@ -0,0 +1,283 @@
import { motion } from 'framer-motion';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { CheckCircle, Rocket, FileText, Users, Eye, Upload, Flame, Target, TrendingUp, DollarSign } from 'lucide-react';
import { format } from 'date-fns';
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
interface ReviewSubmitStepProps {
formData: FormData;
selectedTemplate: RequestTemplate | null;
}
const getPriorityIcon = (priority: string) => {
switch (priority) {
case 'high': return <Flame className="w-4 h-4 text-red-600" />;
case 'medium': return <Target className="w-4 h-4 text-orange-600" />;
case 'low': return <TrendingUp className="w-4 h-4 text-green-600" />;
default: return <Target className="w-4 h-4 text-gray-600" />;
}
};
/**
* Component: ReviewSubmitStep
*
* Purpose: Step 6 - Review and submit request
*
* Features:
* - Displays all request information for review
* - Shows approval workflow summary
* - Lists participants and documents
* - Test IDs for testing
*/
export function ReviewSubmitStep({
formData,
selectedTemplate
}: ReviewSubmitStepProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
data-testid="review-submit-step"
>
<div className="text-center mb-8" data-testid="review-submit-header">
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-teal-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2" data-testid="review-submit-title">
Review & Submit
</h2>
<p className="text-gray-600" data-testid="review-submit-description">
Please review all details before submitting your request for approval.
</p>
</div>
<div className="max-w-5xl mx-auto space-y-8" data-testid="review-submit-content">
{/* Request Overview */}
<Card className="border-2 border-green-200 bg-green-50/50" data-testid="review-submit-overview-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-900" data-testid="review-submit-overview-title">
<Rocket className="w-5 h-5" />
Request Overview
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" data-testid="review-submit-overview-grid">
<div data-testid="review-submit-overview-type">
<Label className="text-green-900 font-semibold">Request Type</Label>
<p className="text-green-800 mt-1">{selectedTemplate?.name}</p>
<Badge variant="outline" className="mt-2 text-xs border-green-300 text-green-700">
{selectedTemplate?.category}
</Badge>
</div>
<div data-testid="review-submit-overview-priority">
<Label className="text-green-900 font-semibold">Priority</Label>
<div className="flex items-center gap-2 mt-1">
{getPriorityIcon(formData.priority)}
<span className="text-green-800 capitalize">{formData.priority}</span>
</div>
</div>
<div data-testid="review-submit-overview-workflow">
<Label className="text-green-900 font-semibold">Workflow Type</Label>
<p className="text-green-800 mt-1 capitalize">{formData.workflowType}</p>
<p className="text-sm text-green-700">{formData.approverCount || 1} Level{(formData.approverCount || 1) > 1 ? 's' : ''}</p>
</div>
</div>
<div data-testid="review-submit-overview-title">
<Label className="text-green-900 font-semibold">Request Title</Label>
<p className="text-green-800 font-medium mt-1 text-lg">{formData.title}</p>
</div>
</CardContent>
</Card>
{/* Basic Information */}
<Card data-testid="review-submit-basic-info-card">
<CardHeader>
<CardTitle className="flex items-center gap-2" data-testid="review-submit-basic-info-title">
<FileText className="w-5 h-5" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div data-testid="review-submit-basic-info-description">
<Label className="font-semibold">Description</Label>
<p className="text-sm text-gray-700 mt-1 p-3 bg-gray-50 rounded-lg border">
{formData.description}
</p>
</div>
{formData.amount && (
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200" data-testid="review-submit-basic-info-financial">
<div className="flex items-center gap-2 mb-3">
<DollarSign className="w-4 h-4 text-blue-600" />
<Label className="font-semibold text-blue-900">Financial Details</Label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<span className="text-sm text-blue-700">Amount</span>
<p className="font-semibold text-blue-900">{formData.amount} {formData.currency}</p>
</div>
{formData.costCenter && (
<div>
<span className="text-sm text-blue-700">Cost Center</span>
<p className="font-medium text-blue-900">{formData.costCenter}</p>
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
{/* Approval Workflow */}
<Card className="border-2 border-orange-200 bg-orange-50/50" data-testid="review-submit-approval-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-orange-900" data-testid="review-submit-approval-title">
<Users className="w-5 h-5" />
Approval Workflow
</CardTitle>
<CardDescription className="text-orange-700">
Sequential approval hierarchy with TAT (Turn Around Time) for each level
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4" data-testid="review-submit-approval-levels">
{Array.from({ length: formData.approverCount || 1 }, (_, index) => {
const level = index + 1;
const isLast = level === (formData.approverCount || 1);
const approver = formData.approvers[index];
return (
<div key={level} className="p-4 bg-white rounded-lg border border-orange-200" data-testid={`review-submit-approval-level-${level}`}>
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
approver?.email ? 'bg-green-600' : 'bg-gray-400'
}`}>
<span className="text-white font-semibold">{level}</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-semibold text-gray-900">
Approver Level {level}
</span>
{isLast && (
<Badge variant="destructive" className="text-xs">FINAL APPROVER</Badge>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<span className="text-sm text-gray-600">Email Address</span>
<p className="text-sm font-medium text-gray-900">
{approver?.email || 'Not assigned'}
</p>
</div>
<div>
<span className="text-sm text-gray-600">TAT (Turn Around Time)</span>
<p className="text-sm font-medium text-gray-900">
{approver?.tat ?
`${approver.tat} ${approver.tatType === 'days' ? 'day' : 'hour'}${approver.tat !== 1 ? 's' : ''}`
: 'Not set'
}
</p>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Participants & Access */}
<Card data-testid="review-submit-participants-card">
<CardHeader>
<CardTitle className="flex items-center gap-2" data-testid="review-submit-participants-title">
<Eye className="w-5 h-5" />
Participants & Access Control
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{formData.spectators.length > 0 && (
<div data-testid="review-submit-participants-spectators">
<Label className="font-semibold text-sm">Spectators ({formData.spectators.length})</Label>
<div className="flex flex-wrap gap-2 mt-2">
{formData.spectators.map((spectator) => (
<Badge key={spectator.id} variant="outline" className="text-xs" data-testid={`review-submit-spectator-${spectator.id}`}>
{spectator.name} ({spectator.email})
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Documents */}
{formData.documents.length > 0 && (
<Card data-testid="review-submit-documents-card">
<CardHeader>
<CardTitle className="flex items-center gap-2" data-testid="review-submit-documents-title">
<Upload className="w-5 h-5" />
Documents & Attachments
</CardTitle>
<CardDescription>
{formData.documents.length} document{formData.documents.length !== 1 ? 's' : ''} attached to this request
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3" data-testid="review-submit-documents-list">
{formData.documents.map((doc, index) => (
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border" data-testid={`review-submit-document-${index}`}>
<FileText className="w-5 h-5 text-gray-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{doc.name}</p>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-1">
<span>{(doc.size / (1024 * 1024)).toFixed(2)} MB</span>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Final Confirmation */}
<Card className="border-2 border-blue-200 bg-blue-50/50" data-testid="review-submit-confirmation-card">
<CardContent className="pt-6">
<div className="flex items-start gap-4">
<CheckCircle className="w-6 h-6 text-blue-600 mt-1 flex-shrink-0" />
<div className="flex-1">
<h3 className="font-semibold text-blue-900 mb-2" data-testid="review-submit-confirmation-title">
Ready to Submit Request
</h3>
<p className="text-sm text-blue-700 mb-4" data-testid="review-submit-confirmation-message">
Once submitted, your request will enter the approval workflow and notifications will be sent to all relevant participants.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm" data-testid="review-submit-confirmation-summary">
<div>
<span className="text-blue-700">Request Type:</span>
<p className="font-medium text-blue-900">{selectedTemplate?.name}</p>
</div>
<div>
<span className="text-blue-700">Approval Levels:</span>
<p className="font-medium text-blue-900">{formData.approverCount || 1}</p>
</div>
<div>
<span className="text-blue-700">Documents:</span>
<p className="font-medium text-blue-900">{formData.documents.length} attached</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</motion.div>
);
}

View File

@ -0,0 +1,205 @@
import { motion, AnimatePresence } from 'framer-motion';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Check, Clock, Users, Info, Flame, Target, TrendingUp } from 'lucide-react';
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
interface TemplateSelectionStepProps {
templates: RequestTemplate[];
selectedTemplate: RequestTemplate | null;
onSelectTemplate: (template: RequestTemplate) => void;
}
const getPriorityIcon = (priority: string) => {
switch (priority) {
case 'high': return <Flame className="w-4 h-4 text-red-600" />;
case 'medium': return <Target className="w-4 h-4 text-orange-600" />;
case 'low': return <TrendingUp className="w-4 h-4 text-green-600" />;
default: return <Target className="w-4 h-4 text-gray-600" />;
}
};
/**
* Component: TemplateSelectionStep
*
* Purpose: Step 1 - Template selection for request creation
*
* Features:
* - Displays available templates
* - Shows template details when selected
* - Test IDs for testing
*/
export function TemplateSelectionStep({
templates,
selectedTemplate,
onSelectTemplate
}: TemplateSelectionStepProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="min-h-full flex flex-col items-center justify-center py-8"
data-testid="template-selection-step"
>
{/* Header Section */}
<div className="text-center mb-12 max-w-3xl" data-testid="template-selection-header">
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4" data-testid="template-selection-title">
Choose Your Request Type
</h1>
<p className="text-lg text-gray-600" data-testid="template-selection-description">
Start with a pre-built template for faster approvals, or create a custom request tailored to your needs.
</p>
</div>
{/* Template Cards Grid */}
<div
className="w-full max-w-6xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"
data-testid="template-selection-grid"
>
{templates.map((template) => (
<motion.div
key={template.id}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
data-testid={`template-card-${template.id}`}
>
<Card
className={`cursor-pointer h-full transition-all duration-300 border-2 ${
selectedTemplate?.id === template.id
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg'
}`}
onClick={() => onSelectTemplate(template)}
data-testid={`template-card-${template.id}-clickable`}
>
<CardHeader className="space-y-4 pb-4">
<div className="flex items-start justify-between">
<div
className={`w-14 h-14 rounded-xl flex items-center justify-center ${
selectedTemplate?.id === template.id
? 'bg-blue-100'
: 'bg-gray-100'
}`}
data-testid={`template-card-${template.id}-icon`}
>
<template.icon
className={`w-7 h-7 ${
selectedTemplate?.id === template.id
? 'text-blue-600'
: 'text-gray-600'
}`}
/>
</div>
{selectedTemplate?.id === template.id && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 15 }}
data-testid={`template-card-${template.id}-selected-indicator`}
>
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
<Check className="w-5 h-5 text-white" />
</div>
</motion.div>
)}
</div>
<div className="text-left">
<CardTitle className="text-xl mb-2" data-testid={`template-card-${template.id}-name`}>
{template.name}
</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs" data-testid={`template-card-${template.id}-category`}>
{template.category}
</Badge>
{getPriorityIcon(template.priority)}
</div>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-4">
<p
className="text-sm text-gray-600 leading-relaxed line-clamp-2"
data-testid={`template-card-${template.id}-description`}
>
{template.description}
</p>
<Separator />
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-estimated-time`}>
<Clock className="w-3.5 h-3.5" />
<span>{template.estimatedTime}</span>
</div>
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-approvers-count`}>
<Users className="w-3.5 h-3.5" />
<span>{template.commonApprovers.length} approvers</span>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
{/* Template Details Card */}
<AnimatePresence>
{selectedTemplate && (
<motion.div
initial={{ opacity: 0, y: 20, height: 0 }}
animate={{ opacity: 1, y: 0, height: 'auto' }}
exit={{ opacity: 0, y: -20, height: 0 }}
transition={{ duration: 0.3 }}
className="w-full max-w-6xl"
data-testid="template-details-card"
>
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-blue-900" data-testid="template-details-title">
<Info className="w-5 h-5" />
{selectedTemplate.name} - Template Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-sla">
<Label className="text-blue-900 font-semibold">Suggested SLA</Label>
<p className="text-blue-700 mt-1">{selectedTemplate.suggestedSLA} days</p>
</div>
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-priority">
<Label className="text-blue-900 font-semibold">Priority Level</Label>
<div className="flex items-center gap-1 mt-1">
{getPriorityIcon(selectedTemplate.priority)}
<span className="text-blue-700 capitalize">{selectedTemplate.priority}</span>
</div>
</div>
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-duration">
<Label className="text-blue-900 font-semibold">Estimated Duration</Label>
<p className="text-blue-700 mt-1">{selectedTemplate.estimatedTime}</p>
</div>
</div>
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-approvers">
<Label className="text-blue-900 font-semibold">Common Approvers</Label>
<div className="flex flex-wrap gap-2 mt-2">
{selectedTemplate.commonApprovers.map((approver, index) => (
<Badge
key={`${selectedTemplate.id}-approver-${index}-${approver}`}
variant="outline"
className="border-blue-300 text-blue-700 bg-white"
data-testid={`template-details-approver-${index}`}
>
{approver}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}

View File

@ -0,0 +1,117 @@
import { Button } from '@/components/ui/button';
import { ArrowLeft, ArrowRight, Rocket, Loader2 } from 'lucide-react';
interface WizardFooterProps {
currentStep: number;
totalSteps: number;
isStepValid: boolean;
onPrev: () => void;
onNext: () => void;
onSubmit: () => void;
onSaveDraft: () => void;
submitting: boolean;
savingDraft: boolean;
loadingDraft: boolean;
isEditing: boolean;
}
/**
* Component: WizardFooter
*
* Purpose: Navigation footer for wizard with Previous/Next/Submit buttons
*
* Features:
* - Fixed on mobile for better keyboard handling
* - Shows loading states
* - Test IDs for testing
*/
export function WizardFooter({
currentStep,
totalSteps,
isStepValid,
onPrev,
onNext,
onSubmit,
onSaveDraft,
submitting,
savingDraft,
loadingDraft,
isEditing
}: WizardFooterProps) {
return (
<div
className="fixed sm:relative bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-3 sm:px-6 py-3 sm:py-4 flex-shrink-0 shadow-lg sm:shadow-none z-50"
data-testid="wizard-footer"
>
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center gap-2 sm:gap-4 max-w-6xl mx-auto">
{/* Previous Button */}
<Button
variant="outline"
onClick={onPrev}
disabled={currentStep === 1}
size="sm"
className="sm:size-lg order-2 sm:order-1"
data-testid="wizard-footer-prev-button"
>
<ArrowLeft className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
<span className="text-xs sm:text-sm">Previous</span>
</Button>
{/* Action Buttons */}
<div className="flex gap-2 sm:gap-3 order-1 sm:order-2" data-testid="wizard-footer-actions">
<Button
variant="outline"
onClick={onSaveDraft}
size="sm"
className="sm:size-lg flex-1 sm:flex-none text-xs sm:text-sm"
disabled={loadingDraft || submitting || savingDraft}
data-testid="wizard-footer-save-draft-button"
>
{savingDraft ? (
<>
<Loader2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2 animate-spin" />
{isEditing ? 'Updating...' : 'Saving...'}
</>
) : (
isEditing ? 'Update Draft' : 'Save Draft'
)}
</Button>
{currentStep === totalSteps ? (
<Button
onClick={onSubmit}
disabled={!isStepValid || loadingDraft || submitting || savingDraft}
size="sm"
className="sm:size-lg bg-green-600 hover:bg-green-700 flex-1 sm:flex-none sm:px-8 text-xs sm:text-sm"
data-testid="wizard-footer-submit-button"
>
{submitting ? (
<>
<Loader2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2 animate-spin" />
Submitting...
</>
) : (
<>
<Rocket className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
Submit
</>
)}
</Button>
) : (
<Button
onClick={onNext}
disabled={!isStepValid}
size="sm"
className="sm:size-lg flex-1 sm:flex-none sm:px-8 text-xs sm:text-sm"
data-testid="wizard-footer-next-button"
>
<span className="hidden sm:inline">Next Step</span>
<span className="sm:hidden">Next</span>
<ArrowRight className="h-3 w-3 sm:h-4 sm:w-4 ml-1 sm:ml-2" />
</Button>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,119 @@
import { Check } from 'lucide-react';
interface WizardStepperProps {
currentStep: number;
totalSteps: number;
stepNames: string[];
}
/**
* Component: WizardStepper
*
* Purpose: Displays progress indicator for multi-step wizard
*
* Features:
* - Shows current step and progress percentage
* - Mobile-optimized display
* - Test IDs for testing
*/
export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStepperProps) {
const progressPercentage = Math.round((currentStep / totalSteps) * 100);
return (
<div
className="bg-white border-b border-gray-200 px-3 sm:px-6 py-2 sm:py-3 flex-shrink-0"
data-testid="wizard-stepper"
>
<div className="max-w-6xl mx-auto">
{/* Mobile: Current step indicator only */}
<div className="block sm:hidden" data-testid="wizard-stepper-mobile">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-full bg-green-600 text-white flex items-center justify-center text-xs font-semibold"
data-testid="wizard-stepper-mobile-current-step"
>
{currentStep}
</div>
<div>
<p className="text-xs font-semibold text-gray-900" data-testid="wizard-stepper-mobile-step-name">
{stepNames[currentStep - 1]}
</p>
<p className="text-xs text-gray-600" data-testid="wizard-stepper-mobile-step-info">
Step {currentStep} of {totalSteps}
</p>
</div>
</div>
<div className="text-right">
<p className="text-xs font-medium text-green-600" data-testid="wizard-stepper-mobile-progress">
{progressPercentage}%
</p>
</div>
</div>
{/* Progress bar */}
<div
className="w-full bg-gray-200 h-1.5 rounded-full overflow-hidden"
data-testid="wizard-stepper-mobile-progress-bar"
>
<div
className="bg-green-600 h-full transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
data-testid="wizard-stepper-mobile-progress-fill"
/>
</div>
</div>
{/* Desktop: Full step indicator */}
<div className="hidden sm:block" data-testid="wizard-stepper-desktop">
<div className="flex items-center justify-between mb-2" data-testid="wizard-stepper-desktop-steps">
{stepNames.map((_, index) => (
<div key={index} className="flex items-center" data-testid={`wizard-stepper-desktop-step-${index + 1}`}>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
index + 1 < currentStep
? 'bg-green-600 text-white'
: index + 1 === currentStep
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-600'
}`}
data-testid={`wizard-stepper-desktop-step-${index + 1}-indicator`}
>
{index + 1 < currentStep ? (
<Check className="w-4 h-4" />
) : (
index + 1
)}
</div>
{index < stepNames.length - 1 && (
<div
className={`w-8 md:w-12 lg:w-16 h-1 mx-1 md:mx-2 ${
index + 1 < currentStep ? 'bg-green-600' : 'bg-gray-200'
}`}
data-testid={`wizard-stepper-desktop-step-${index + 1}-connector`}
/>
)}
</div>
))}
</div>
<div
className="hidden lg:flex justify-between text-xs text-gray-600 mt-2"
data-testid="wizard-stepper-desktop-labels"
>
{stepNames.map((step, index) => (
<span
key={index}
className={`${
index + 1 === currentStep ? 'font-semibold text-blue-600' : ''
}`}
data-testid={`wizard-stepper-desktop-label-${index + 1}`}
>
{step}
</span>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
export { WizardStepper } from './WizardStepper';
export { WizardFooter } from './WizardFooter';
export { TemplateSelectionStep } from './TemplateSelectionStep';
export { BasicInformationStep } from './BasicInformationStep';
export { ApprovalWorkflowStep } from './ApprovalWorkflowStep';
export { ParticipantsStep } from './ParticipantsStep';
export { DocumentsStep } from './DocumentsStep';
export { ReviewSubmitStep } from './ReviewSubmitStep';

View File

@ -0,0 +1,315 @@
import { useState, useEffect } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { getWorkflowDetails } from '@/services/workflowApi';
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
export interface RequestTemplate {
id: string;
name: string;
description: string;
category: string;
icon: React.ComponentType<any>;
estimatedTime: string;
commonApprovers: string[];
suggestedSLA: number;
priority: 'high' | 'medium' | 'low';
fields: {
amount?: boolean;
vendor?: boolean;
timeline?: boolean;
impact?: boolean;
};
}
export interface FormData {
template: string;
title: string;
description: string;
category: string;
priority: string;
urgency: string;
businessImpact: string;
amount: string;
currency: string;
vendor: string;
timeline: string;
slaTemplate: string;
slaHours: number;
customSlaHours: number;
slaEndDate: Date | undefined;
expectedCompletionDate: Date | undefined;
breachEscalation: boolean;
reminderSchedule: '25' | '50' | '75';
workflowType: 'sequential' | 'parallel';
requiresAllApprovals: boolean;
escalationEnabled: boolean;
reminderEnabled: boolean;
minimumLevel: number;
maxLevel: number;
approvers: any[];
approverCount: number;
spectators: any[];
ccList: any[];
invitedUsers: any[];
allowComments: boolean;
allowDocumentUpload: boolean;
documents: File[];
tags: string[];
relatedRequests: string[];
costCenter: string;
project: string;
}
const initialFormData: FormData = {
template: '',
title: '',
description: '',
category: '',
priority: '',
urgency: '',
businessImpact: '',
amount: '',
currency: 'USD',
vendor: '',
timeline: '',
slaTemplate: '',
slaHours: 0,
customSlaHours: 0,
slaEndDate: undefined,
expectedCompletionDate: undefined,
breachEscalation: true,
reminderSchedule: '50',
workflowType: 'sequential',
requiresAllApprovals: true,
escalationEnabled: true,
reminderEnabled: true,
minimumLevel: 1,
maxLevel: 1,
approvers: [],
approverCount: 1,
spectators: [],
ccList: [],
invitedUsers: [],
allowComments: true,
allowDocumentUpload: true,
documents: [],
tags: [],
relatedRequests: [],
costCenter: '',
project: ''
};
export interface SystemPolicy {
maxApprovalLevels: number;
maxParticipants: number;
allowSpectators: boolean;
maxSpectators: number;
}
export interface DocumentPolicy {
maxFileSizeMB: number;
allowedFileTypes: string[];
}
/**
* Custom Hook: useCreateRequestForm
*
* Purpose: Manages form state, policies, and draft loading for CreateRequest
*
* Responsibilities:
* - Manages form data state
* - Loads system and document policies
* - Handles draft loading in edit mode
* - Provides form data update functions
*/
export function useCreateRequestForm(
isEditing: boolean,
editRequestId: string,
templates: RequestTemplate[]
) {
const { user } = useAuth();
const [formData, setFormData] = useState<FormData>(initialFormData);
const [selectedTemplate, setSelectedTemplate] = useState<RequestTemplate | null>(null);
const [loadingDraft, setLoadingDraft] = useState(isEditing);
const [systemPolicy, setSystemPolicy] = useState<SystemPolicy>({
maxApprovalLevels: 10,
maxParticipants: 50,
allowSpectators: true,
maxSpectators: 20
});
const [documentPolicy, setDocumentPolicy] = useState<DocumentPolicy>({
maxFileSizeMB: 10,
allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'gif']
});
const [existingDocuments, setExistingDocuments] = useState<any[]>([]);
// Load policies on mount
useEffect(() => {
const loadPolicies = async () => {
try {
// Load document policy
const docConfigs = await getAllConfigurations('DOCUMENT_POLICY');
const docConfigMap: Record<string, string> = {};
docConfigs.forEach((c: AdminConfiguration) => {
docConfigMap[c.configKey] = c.configValue;
});
const maxFileSizeMB = parseInt(docConfigMap['MAX_FILE_SIZE_MB'] || '10');
const allowedFileTypesStr = docConfigMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif';
const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase());
setDocumentPolicy({
maxFileSizeMB,
allowedFileTypes
});
// Load system policy
const workflowConfigs = await getAllConfigurations('WORKFLOW_SHARING');
const tatConfigs = await getAllConfigurations('TAT_SETTINGS');
const allConfigs = [...workflowConfigs, ...tatConfigs];
const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
setSystemPolicy({
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
});
} catch (error) {
console.error('Failed to load policies:', error);
}
};
loadPolicies();
}, []);
// Load draft data when in edit mode
useEffect(() => {
if (!isEditing || !editRequestId) return;
let mounted = true;
(async () => {
try {
setLoadingDraft(true);
const details = await getWorkflowDetails(editRequestId);
if (!mounted || !details) return;
const wf = details.workflow || {};
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
const participants = Array.isArray(details.participants) ? details.participants : [];
const documents = Array.isArray(details.documents) ? details.documents.filter((d: any) => !d.isDeleted) : [];
// Store existing documents for tracking
setExistingDocuments(documents);
// Map priority
const priority = (wf.priority || '').toString().toLowerCase();
const priorityMap: Record<string, string> = {
'standard': 'standard',
'express': 'express'
};
// Map template type
const templateType = wf.templateType === 'TEMPLATE' ? 'existing-template' : 'custom';
const template = templates.find(t => t.id === templateType) || templates[0] || null;
setSelectedTemplate(template);
// Map approvers
const mappedApprovers = approvals
.sort((a: any, b: any) => (a.levelNumber || 0) - (b.levelNumber || 0))
.map((approval: any) => {
const tatHours = Number(approval.tatHours || 24);
const tatDays = Math.floor(tatHours / 24);
const tatHoursRemainder = tatHours % 24;
return {
id: approval.approverId || `temp-${approval.levelNumber}`,
name: approval.approverName || approval.approverEmail || '',
email: approval.approverEmail || '',
role: approval.levelName || `Level ${approval.levelNumber}`,
department: '',
avatar: (approval.approverName || approval.approverEmail || 'XX').substring(0, 2).toUpperCase(),
level: approval.levelNumber || 1,
canClose: false,
tat: tatDays > 0 ? tatDays : tatHoursRemainder,
tatType: tatDays > 0 ? 'days' as const : 'hours' as const,
userId: approval.approverId
};
});
// Map spectators
const mappedSpectators = participants
.filter((p: any) => {
const pt = (p.participantType || p.participant_type || '').toString().toUpperCase().trim();
const isSpectator = pt === 'SPECTATOR';
if (!isSpectator) return false;
const hasEmail = !!(p.userEmail || p.user_email || p.email);
return hasEmail;
})
.map((p: any, index: number) => {
const userId = p.userId || p.user_id || p.id;
const userName = p.userName || p.user_name || p.name || '';
const userEmail = p.userEmail || p.user_email || p.email || '';
const avatarText = userName || userEmail || 'XX';
const avatar = avatarText
.split(' ')
.map((s: string) => s[0])
.filter(Boolean)
.join('')
.slice(0, 2)
.toUpperCase();
return {
id: userId || `spectator-${editRequestId}-${index}-${Date.now()}`,
userId: userId,
name: userName || userEmail || 'Spectator',
email: userEmail,
role: 'Spectator',
department: p.department || '',
avatar: avatar,
level: 1,
canClose: false
};
});
setFormData(prev => ({
...prev,
template: templateType,
title: wf.title || '',
description: wf.description || '',
priority: priorityMap[priority] || 'standard',
approvers: mappedApprovers,
approverCount: mappedApprovers.length || 1,
spectators: mappedSpectators,
maxLevel: Math.max(...mappedApprovers.map((a: any) => a.level || 1), 1)
}));
} catch (error) {
console.error('Failed to load draft:', error);
} finally {
if (mounted) setLoadingDraft(false);
}
})();
return () => { mounted = false; };
}, [isEditing, editRequestId, templates]);
const updateFormData = (field: keyof FormData, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
return {
formData,
setFormData,
updateFormData,
selectedTemplate,
setSelectedTemplate,
loadingDraft,
systemPolicy,
documentPolicy,
existingDocuments,
setExistingDocuments
};
}

View File

@ -0,0 +1,189 @@
import { useState, useRef } from 'react';
import { getDocumentPreviewUrl } from '@/services/workflowApi';
import { toast } from 'sonner';
export interface DocumentPolicy {
maxFileSizeMB: number;
allowedFileTypes: string[];
}
export interface PreviewDocument {
fileName: string;
fileType: string;
fileUrl: string;
fileSize?: number;
file?: File;
documentId?: string;
}
/**
* Custom Hook: useDocumentManagement
*
* Purpose: Manages document upload, validation, and preview
*
* Responsibilities:
* - Validates files against policy
* - Manages document lists (new and existing)
* - Handles document deletion
* - Manages document preview
*/
export function useDocumentManagement(
documentPolicy: DocumentPolicy,
isEditing: boolean = false
) {
const [documents, setDocuments] = useState<File[]>([]);
const [existingDocuments, setExistingDocuments] = useState<any[]>([]);
const [documentsToDelete, setDocumentsToDelete] = useState<string[]>([]);
const [previewDocument, setPreviewDocument] = useState<PreviewDocument | null>(null);
const [documentErrorModal, setDocumentErrorModal] = useState<{
open: boolean;
errors: Array<{ fileName: string; reason: string }>;
}>({
open: false,
errors: []
});
const fileInputRef = useRef<HTMLInputElement>(null);
const validateFile = (file: File): { valid: boolean; reason?: string } => {
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
if (file.size > maxSizeBytes) {
return {
valid: false,
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
};
}
const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
return {
valid: false,
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
};
}
return { valid: true };
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
const validationErrors: Array<{ fileName: string; reason: string }> = [];
const validFiles: File[] = [];
files.forEach(file => {
const validation = validateFile(file);
if (!validation.valid) {
validationErrors.push({
fileName: file.name,
reason: validation.reason || 'Unknown validation error'
});
} else {
validFiles.push(file);
}
});
if (validationErrors.length > 0) {
setDocumentErrorModal({
open: true,
errors: validationErrors
});
}
if (validFiles.length > 0) {
setDocuments(prev => [...prev, ...validFiles]);
if (validFiles.length < files.length) {
toast.warning(`${validFiles.length} of ${files.length} file(s) were added. ${validationErrors.length} file(s) were rejected.`);
} else {
toast.success(`${validFiles.length} file(s) added successfully`);
}
}
if (event.target) {
event.target.value = '';
}
};
const removeDocument = (index: number) => {
setDocuments(prev => prev.filter((_, i) => i !== index));
};
const markDocumentForDeletion = (documentId: string) => {
setDocumentsToDelete(prev => [...prev, documentId]);
};
const unmarkDocumentForDeletion = (documentId: string) => {
setDocumentsToDelete(prev => prev.filter(id => id !== documentId));
};
const openPreview = (doc: any, isExisting: boolean = false) => {
if (isExisting) {
const docId = doc.documentId || doc.document_id || '';
setPreviewDocument({
fileName: doc.originalFileName || doc.fileName || 'Document',
fileType: doc.fileType || doc.file_type || 'application/octet-stream',
fileUrl: getDocumentPreviewUrl(docId),
fileSize: Number(doc.fileSize || doc.file_size || 0),
documentId: docId
});
} else {
const fileUrl = URL.createObjectURL(doc);
setPreviewDocument({
fileName: doc.name,
fileType: doc.type || 'application/octet-stream',
fileUrl: fileUrl,
fileSize: doc.size,
file: doc
});
}
};
const closePreview = () => {
if (previewDocument?.fileUrl && previewDocument?.file) {
URL.revokeObjectURL(previewDocument.fileUrl);
}
setPreviewDocument(null);
};
const canPreview = (doc: any, isExisting: boolean = false): boolean => {
if (isExisting) {
const type = (doc.fileType || doc.file_type || '').toLowerCase();
const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
} else {
const type = (doc.type || '').toLowerCase();
const name = (doc.name || '').toLowerCase();
return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
}
};
return {
documents,
setDocuments,
existingDocuments,
setExistingDocuments,
documentsToDelete,
setDocumentsToDelete,
previewDocument,
documentErrorModal,
setDocumentErrorModal,
fileInputRef,
handleFileUpload,
removeDocument,
markDocumentForDeletion,
unmarkDocumentForDeletion,
openPreview,
closePreview,
canPreview
};
}

View File

@ -0,0 +1,150 @@
import { SystemPolicy, FormData } from './useCreateRequestForm';
export interface PolicyViolation {
type: string;
message: string;
currentValue?: number;
maxValue?: number;
}
/**
* Custom Hook: usePolicyValidation
*
* Purpose: Validates actions against system policies
*
* Responsibilities:
* - Validates approver additions against policy limits
* - Validates spectator additions against policy limits
* - Checks for duplicate users across roles
* - Returns policy violations for display
*/
export function usePolicyValidation(systemPolicy: SystemPolicy) {
const validateAddApprover = (
formData: FormData,
newApprover: any
): PolicyViolation[] => {
const violations: PolicyViolation[] = [];
const updatedApprovers = [...formData.approvers, newApprover];
const maxLevel = Math.max(...updatedApprovers.map((a: any) => a.level || 1), 1);
if (maxLevel > systemPolicy.maxApprovalLevels) {
violations.push({
type: 'Maximum Approval Levels Exceeded',
message: `Adding this approver would exceed the maximum approval levels limit.`,
currentValue: maxLevel,
maxValue: systemPolicy.maxApprovalLevels
});
}
const totalParticipants = 1 + updatedApprovers.length + formData.spectators.length;
if (totalParticipants > systemPolicy.maxParticipants) {
violations.push({
type: 'Maximum Participants Exceeded',
message: `Adding this approver would exceed the maximum participants limit.`,
currentValue: totalParticipants,
maxValue: systemPolicy.maxParticipants
});
}
return violations;
};
const validateAddSpectator = (
formData: FormData,
newSpectator: any
): PolicyViolation[] => {
const violations: PolicyViolation[] = [];
if (!systemPolicy.allowSpectators) {
violations.push({
type: 'Spectators Not Allowed',
message: `Adding spectators is not allowed by system policy.`,
});
}
const updatedSpectators = [...formData.spectators, newSpectator];
if (updatedSpectators.length > systemPolicy.maxSpectators) {
violations.push({
type: 'Maximum Spectators Exceeded',
message: `Adding this spectator would exceed the maximum spectators limit.`,
currentValue: updatedSpectators.length,
maxValue: systemPolicy.maxSpectators
});
}
const totalParticipants = 1 + formData.approvers.length + updatedSpectators.length;
if (totalParticipants > systemPolicy.maxParticipants) {
violations.push({
type: 'Maximum Participants Exceeded',
message: `Adding this spectator would exceed the maximum participants limit.`,
currentValue: totalParticipants,
maxValue: systemPolicy.maxParticipants
});
}
return violations;
};
const checkDuplicateUser = (
formData: FormData,
user: any,
targetType: 'approvers' | 'spectators'
): { isDuplicate: boolean; message?: string } => {
const userEmail = (user.email || '').toLowerCase();
const userId = user.id || user.userId;
if (targetType === 'spectators') {
const isApprover = formData.approvers.find((a: any) =>
a.id === userId || (a.email || '').toLowerCase() === userEmail
);
if (isApprover) {
return {
isDuplicate: true,
message: `${user.name || user.email} is already an approver and cannot be added as a spectator.`
};
}
} else if (targetType === 'approvers') {
const isSpectator = formData.spectators.find((s: any) =>
s.id === userId || (s.email || '').toLowerCase() === userEmail
);
if (isSpectator) {
return {
isDuplicate: true,
message: `${user.name || user.email} is already a spectator and cannot be added as an approver.`
};
}
// Check if user is already another approver
const isOtherApprover = formData.approvers.find((a: any) =>
(a.id === userId || (a.email || '').toLowerCase() === userEmail) && a !== user
);
if (isOtherApprover) {
return {
isDuplicate: true,
message: `${user.name || user.email} is already an approver at another level.`
};
}
}
// Check if already in target list
const currentList = formData[targetType];
const isInList = currentList.find((u: any) =>
u.id === userId || (u.email || '').toLowerCase() === userEmail
);
if (isInList) {
return {
isDuplicate: true,
message: `${user.name || user.email} is already added as ${targetType.slice(0, -1)}.`
};
}
return { isDuplicate: false };
};
return {
validateAddApprover,
validateAddSpectator,
checkDuplicateUser
};
}

142
src/hooks/useUserSearch.ts Normal file
View File

@ -0,0 +1,142 @@
import { useState, useRef } from 'react';
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
/**
* Custom Hook: useUserSearch
*
* Purpose: Manages user search functionality with debouncing
*
* Responsibilities:
* - Handles user search with @ prefix
* - Debounces search requests
* - Manages search results and loading states
* - Validates and ensures users exist in database
*/
export function useUserSearch() {
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const searchTimer = useRef<any>(null);
const searchUsersDebounced = async (searchTerm: string, limit: number = 10) => {
if (searchTimer.current) {
clearTimeout(searchTimer.current);
}
if (!searchTerm || !searchTerm.startsWith('@') || searchTerm.length < 2) {
setSearchResults([]);
setSearchLoading(false);
return;
}
setSearchLoading(true);
searchTimer.current = setTimeout(async () => {
try {
const term = searchTerm.slice(1); // remove leading '@'
const response = await searchUsers(term, limit);
const results = response.data?.data || [];
setSearchResults(results);
} catch (err) {
console.error('User search failed:', err);
setSearchResults([]);
} finally {
setSearchLoading(false);
}
}, 300);
};
const clearSearch = () => {
if (searchTimer.current) {
clearTimeout(searchTimer.current);
}
setSearchResults([]);
setSearchLoading(false);
};
const ensureUser = async (user: UserSummary) => {
try {
const dbUser = await ensureUserExists({
userId: user.userId,
email: user.email,
displayName: user.displayName,
firstName: user.firstName,
lastName: user.lastName,
department: user.department,
phone: user.phone,
mobilePhone: user.mobilePhone,
designation: user.designation,
jobTitle: user.jobTitle,
manager: user.manager,
employeeId: user.employeeId,
employeeNumber: user.employeeNumber,
secondEmail: user.secondEmail,
location: user.location
});
return dbUser;
} catch (err) {
console.error('Failed to ensure user exists:', err);
throw err;
}
};
return {
searchResults,
searchLoading,
searchUsersDebounced,
clearSearch,
ensureUser
};
}
/**
* Custom Hook: useMultiUserSearch
*
* Purpose: Manages multiple independent user searches (e.g., for multiple approver fields)
*/
export function useMultiUserSearch() {
const [userSearchResults, setUserSearchResults] = useState<Record<number, UserSummary[]>>({});
const [userSearchLoading, setUserSearchLoading] = useState<Record<number, boolean>>({});
const searchTimers = useRef<Record<number, any>>({});
const searchUsersForIndex = async (index: number, searchTerm: string, limit: number = 10) => {
if (searchTimers.current[index]) {
clearTimeout(searchTimers.current[index]);
}
if (!searchTerm || !searchTerm.startsWith('@') || searchTerm.length < 2) {
setUserSearchResults(prev => ({ ...prev, [index]: [] }));
setUserSearchLoading(prev => ({ ...prev, [index]: false }));
return;
}
setUserSearchLoading(prev => ({ ...prev, [index]: true }));
searchTimers.current[index] = setTimeout(async () => {
try {
const term = searchTerm.slice(1);
const response = await searchUsers(term, limit);
const results = response.data?.data || [];
setUserSearchResults(prev => ({ ...prev, [index]: results }));
} catch (err) {
console.error(`User search failed for index ${index}:`, err);
setUserSearchResults(prev => ({ ...prev, [index]: [] }));
} finally {
setUserSearchLoading(prev => ({ ...prev, [index]: false }));
}
}, 300);
};
const clearSearchForIndex = (index: number) => {
if (searchTimers.current[index]) {
clearTimeout(searchTimers.current[index]);
}
setUserSearchResults(prev => ({ ...prev, [index]: [] }));
setUserSearchLoading(prev => ({ ...prev, [index]: false }));
};
return {
userSearchResults,
userSearchLoading,
searchUsersForIndex,
clearSearchForIndex
};
}

View File

@ -0,0 +1,108 @@
import { useState } from 'react';
import { FormData } from './useCreateRequestForm';
const STEP_NAMES = [
'Template Selection',
'Basic Information',
'Approval Workflow',
'Participants & Access',
'Documents & Attachments',
'Review & Submit'
];
/**
* Custom Hook: useWizardNavigation
*
* Purpose: Manages wizard step navigation and validation
*
* Responsibilities:
* - Manages current step state
* - Validates steps before navigation
* - Handles step transitions
*/
export function useWizardNavigation(
isEditing: boolean,
selectedTemplate: any,
formData: FormData
) {
const [currentStep, setCurrentStep] = useState(isEditing ? 2 : 1);
const totalSteps = STEP_NAMES.length;
const validateEmail = (email: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const isStepValid = (): boolean => {
switch (currentStep) {
case 1:
return selectedTemplate !== null;
case 2:
return formData.title.trim() !== '' &&
formData.description.trim() !== '' &&
formData.priority !== '';
case 3:
return (formData.approverCount || 1) > 0 &&
formData.approvers.length === (formData.approverCount || 1) &&
formData.approvers.every(approver => {
if (!approver || !approver.email) return false;
if (!validateEmail(approver.email)) return false;
if (!approver.userId) return true; // Will be validated on next step
const tatType = approver.tatType || 'hours';
if (tatType === 'hours') {
return approver.tat && approver.tat > 0 && approver.tat <= 720;
} else if (tatType === 'days') {
return approver.tat && approver.tat > 0 && approver.tat <= 30;
}
return false;
});
case 4:
return true; // Participants are optional
case 5:
return true; // Documents are optional
case 6:
return true; // Review & Submit
default:
return false;
}
};
const nextStep = () => {
if (!isStepValid()) return;
if (window.innerWidth < 640) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
if (currentStep < totalSteps) {
setCurrentStep(currentStep + 1);
}
};
const prevStep = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
if (window.innerWidth < 640) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
};
const goToStep = (step: number) => {
if (step >= 1 && step <= totalSteps) {
setCurrentStep(step);
}
};
return {
currentStep,
setCurrentStep,
totalSteps,
stepNames: STEP_NAMES,
isStepValid,
nextStep,
prevStep,
goToStep,
validateEmail
};
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,111 @@
/**
* Approver's Actions Statistics Component
*/
import { CheckCircle, XCircle, Clock, FileText, Users, Target, Award, AlertCircle, BarChart3 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import type { ApproverPerformance } from '@/services/dashboard.service';
import type { ApproverPerformanceStats } from '../types/approverPerformance.types';
interface ApproverPerformanceActionsStatsProps {
approverName: string;
approverStats: ApproverPerformance | null;
calculatedStats: ApproverPerformanceStats;
}
export function ApproverPerformanceActionsStats({
approverName,
approverStats,
calculatedStats
}: ApproverPerformanceActionsStatsProps) {
return (
<Card data-testid="approver-actions-stats">
<CardHeader>
<CardTitle>Approver's Actions (Filtered)</CardTitle>
<CardDescription>
Statistics based on {approverName}'s actions with current filters applied
</CardDescription>
</CardHeader>
<CardContent>
{/* Approver's Actions - What the approver actually did */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<Users className="w-4 h-4" />
Approver's Actions
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center justify-between mb-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-xs text-green-600 font-medium">
{calculatedStats.completedActions > 0 ? `${calculatedStats.approvalRate}%` : '0%'}
</span>
</div>
<div className="text-2xl font-bold text-green-700">{calculatedStats.approvedByApprover}</div>
<div className="text-xs text-gray-600 mt-1">Approved by Approver</div>
</div>
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
<div className="flex items-center justify-between mb-2">
<XCircle className="w-5 h-5 text-red-600" />
<span className="text-xs text-red-600 font-medium">
{calculatedStats.completedActions > 0 ? `${calculatedStats.rejectionRate}%` : '0%'}
</span>
</div>
<div className="text-2xl font-bold text-red-700">{calculatedStats.rejectedByApprover}</div>
<div className="text-xs text-gray-600 mt-1">Rejected by Approver</div>
</div>
<div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<div className="flex items-center justify-between mb-2">
<Clock className="w-5 h-5 text-yellow-600" />
</div>
<div className="text-2xl font-bold text-yellow-700">{calculatedStats.pendingByApprover}</div>
<div className="text-xs text-gray-600 mt-1">Pending Actions</div>
</div>
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center justify-between mb-2">
<FileText className="w-5 h-5 text-blue-600" />
</div>
<div className="text-2xl font-bold text-blue-700">{calculatedStats.total}</div>
<div className="text-xs text-gray-600 mt-1">Total Requests</div>
</div>
</div>
</div>
{/* TAT Compliance Stats */}
<div className="mb-6 pt-4 border-t">
<h4 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<Target className="w-4 h-4" />
TAT Compliance
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center justify-between mb-2">
<Award className="w-5 h-5 text-green-600" />
<span className="text-xs text-green-600 font-medium">
{approverStats?.tatCompliancePercent !== undefined ? `${approverStats.tatCompliancePercent}%` : (calculatedStats.completedActions > 0 ? `${calculatedStats.tatComplianceRate}%` : 'N/A')}
</span>
</div>
<div className="text-2xl font-bold text-green-700">{calculatedStats.compliant}</div>
<div className="text-xs text-gray-600 mt-1">TAT Compliant</div>
</div>
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
<div className="flex items-center justify-between mb-2">
<AlertCircle className="w-5 h-5 text-red-600" />
</div>
<div className="text-2xl font-bold text-red-700">{calculatedStats.breached}</div>
<div className="text-xs text-gray-600 mt-1">TAT Breached</div>
</div>
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
<div className="flex items-center justify-between mb-2">
<BarChart3 className="w-5 h-5 text-purple-600" />
</div>
<div className="text-2xl font-bold text-purple-700">{calculatedStats.completedActions}</div>
<div className="text-xs text-gray-600 mt-1">Completed Actions</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,31 @@
/**
* Approver Performance Empty State Component
*/
import { AlertCircle, ArrowLeft } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useNavigate } from 'react-router-dom';
export function ApproverPerformanceEmpty() {
const navigate = useNavigate();
return (
<div className="flex-1 p-3 lg:p-6 overflow-auto min-w-0" data-testid="approver-performance-empty">
<div className="max-w-7xl mx-auto p-4">
<Card>
<CardContent className="p-8 text-center">
<AlertCircle className="w-12 h-12 text-yellow-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Approver ID Required</h2>
<p className="text-gray-600">Please select an approver to view their performance details.</p>
<Button onClick={() => navigate(-1)} className="mt-4" data-testid="back-button">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,240 @@
/**
* Approver Performance Filters Component
*/
import { Search, Calendar as CalendarIcon } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
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 { format } from 'date-fns';
import type { DateRange } from '@/services/dashboard.service';
interface ApproverPerformanceFiltersProps {
searchTerm: string;
statusFilter: string;
priorityFilter: string;
slaComplianceFilter: string;
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
tempCustomStartDate?: Date;
tempCustomEndDate?: Date;
onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void;
onPriorityChange: (value: string) => void;
onSlaComplianceChange: (value: string) => void;
onDateRangeChange: (value: string) => void;
onShowCustomDatePickerChange: (open: boolean) => void;
onTempStartDateChange: (date: Date | undefined) => void;
onTempEndDateChange: (date: Date | undefined) => void;
onApplyCustomDate: () => void;
onClearFilters: () => void;
}
export function ApproverPerformanceFilters({
searchTerm,
statusFilter,
priorityFilter,
slaComplianceFilter,
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
tempCustomStartDate,
tempCustomEndDate,
onSearchChange,
onStatusChange,
onPriorityChange,
onSlaComplianceChange,
onDateRangeChange,
onShowCustomDatePickerChange,
onTempStartDateChange,
onTempEndDateChange,
onApplyCustomDate,
onClearFilters
}: ApproverPerformanceFiltersProps) {
return (
<Card data-testid="approver-performance-filters">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Filters</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-xs"
data-testid="clear-filters-button"
>
Clear All
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search requests..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
data-testid="search-input"
/>
</div>
{/* Status Filter */}
<Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger data-testid="status-filter">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
{/* Priority Filter */}
<Select value={priorityFilter} onValueChange={onPriorityChange}>
<SelectTrigger 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>
{/* SLA Compliance Filter */}
<Select value={slaComplianceFilter} onValueChange={onSlaComplianceChange}>
<SelectTrigger data-testid="sla-compliance-filter">
<SelectValue placeholder="SLA Compliance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All SLA</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>
{/* Date Range Filter */}
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={dateRange} onValueChange={onDateRangeChange}>
<SelectTrigger className="flex-1" data-testid="date-range-filter">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{/* Custom Date Range Picker */}
{dateRange === 'custom' && (
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2" data-testid="custom-date-trigger">
<CalendarIcon className="w-4 h-4" />
{tempCustomStartDate && tempCustomEndDate
? `${format(tempCustomStartDate, 'MMM d')} - ${format(tempCustomEndDate, 'MMM d')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start" sideOffset={8}>
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date" className="text-sm font-medium">Start Date</Label>
<Input
id="start-date"
type="date"
value={tempCustomStartDate ? format(tempCustomStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
onTempStartDateChange(date);
if (tempCustomEndDate && date > tempCustomEndDate) {
onTempEndDateChange(date);
}
} else {
onTempStartDateChange(undefined);
}
}}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date" className="text-sm font-medium">End Date</Label>
<Input
id="end-date"
type="date"
value={tempCustomEndDate ? format(tempCustomEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
onTempEndDateChange(date);
if (tempCustomStartDate && date < tempCustomStartDate) {
onTempStartDateChange(date);
}
} else {
onTempEndDateChange(undefined);
}
}}
min={tempCustomStartDate ? format(tempCustomStartDate, '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={onApplyCustomDate}
disabled={!tempCustomStartDate || !tempCustomEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
data-testid="apply-date-button"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
onShowCustomDatePickerChange(false);
onTempStartDateChange(customStartDate);
onTempEndDateChange(customEndDate);
if (!customStartDate || !customEndDate) {
onDateRangeChange('month');
}
}}
data-testid="cancel-date-button"
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,44 @@
/**
* Approver Performance Header Component
*/
import { RefreshCw, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ApproverPerformanceHeaderProps {
approverName: string;
refreshing: boolean;
onRefresh: () => void;
}
export function ApproverPerformanceHeader({
approverName,
refreshing,
onRefresh
}: ApproverPerformanceHeaderProps) {
return (
<div className="flex items-center justify-between" data-testid="approver-performance-header">
<div className="flex items-center gap-3">
<div className="p-3 bg-yellow-100 rounded-lg">
<Users className="h-6 w-6 text-yellow-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Approver Performance Report</h1>
<p className="text-sm text-gray-600">{approverName}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={refreshing}
className="gap-2"
data-testid="refresh-button"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">Refresh</span>
</Button>
</div>
);
}

View File

@ -0,0 +1,192 @@
/**
* Approver Performance Request List Component
*/
import { useNavigate } 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 { FileText, RefreshCw, ArrowRight, CheckCircle, XCircle, Clock, User, Target, Timer } from 'lucide-react';
import { Pagination } from '@/components/common/Pagination';
import { getPriorityConfig, getStatusConfig, getSLAConfig } from '../utils/configMappers';
import { formatDate, formatDateTime } from '../utils/formatters';
import { formatHoursMinutes } from '@/utils/slaTracker';
import type { ApproverPerformanceRequest } from '../types/approverPerformance.types';
interface ApproverPerformanceRequestListProps {
requests: ApproverPerformanceRequest[];
loading: boolean;
approverName: string;
currentPage: number;
totalPages: number;
totalRecords: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
}
export function ApproverPerformanceRequestList({
requests,
loading,
approverName,
currentPage,
totalPages,
totalRecords,
itemsPerPage,
onPageChange
}: ApproverPerformanceRequestListProps) {
const navigate = useNavigate();
return (
<Card data-testid="approver-performance-request-list">
<CardHeader>
<CardTitle>Request Details</CardTitle>
<CardDescription>
All requests handled by {approverName} with applied filters
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12" data-testid="loading-state">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
<span className="ml-2 text-sm text-gray-600">Loading requests...</span>
</div>
) : requests.length === 0 ? (
<div className="text-center py-12 text-gray-500" data-testid="empty-state">
<FileText className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="text-sm">No requests found for this approver</p>
</div>
) : (
<div className="space-y-4">
{requests.map((request) => {
const priorityConfig = getPriorityConfig(request.priority);
const statusConfig = getStatusConfig(request.status);
const slaConfig = getSLAConfig(request.slaStatus || '');
const PriorityIcon = priorityConfig.icon;
const StatusIcon = statusConfig.icon;
return (
<Card
key={request.requestId}
className="hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate(`/request/${request.requestId}`)}
data-testid={`request-card-${request.requestId}`}
>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<span className="font-semibold text-sm text-blue-600 hover:underline" data-testid="request-number">
{request.requestNumber}
</span>
<Badge className={priorityConfig.color} data-testid="priority-badge">
<PriorityIcon className={`w-3 h-3 mr-1 ${priorityConfig.iconColor}`} />
{request.priority}
</Badge>
<Badge className={statusConfig.color} data-testid="status-badge">
<StatusIcon className={`w-3 h-3 mr-1 ${statusConfig.iconColor}`} />
{request.status}
</Badge>
{/* Show approver's action status */}
{request.approvalStatus && (
<Badge className={
request.approvalStatus === 'approved' || request.approvalStatus === 'APPROVED'
? 'bg-green-100 text-green-800 border-green-200'
: request.approvalStatus === 'rejected' || request.approvalStatus === 'REJECTED'
? 'bg-red-100 text-red-800 border-red-200'
: 'bg-yellow-100 text-yellow-800 border-yellow-200'
} data-testid="approval-status-badge">
{request.approvalStatus === 'approved' || request.approvalStatus === 'APPROVED' ? (
<>
<CheckCircle className="w-3 h-3 mr-1" />
Approved
</>
) : request.approvalStatus === 'rejected' || request.approvalStatus === 'REJECTED' ? (
<>
<XCircle className="w-3 h-3 mr-1" />
Rejected
</>
) : (
<>
<Clock className="w-3 h-3 mr-1" />
Pending
</>
)}
</Badge>
)}
{request.slaStatus && (
<Badge className={slaConfig.color} data-testid="sla-status-badge">
{slaConfig.label}
</Badge>
)}
</div>
<h3 className="font-medium text-gray-900 mb-1 truncate" data-testid="request-title">
{request.title}
</h3>
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-500 mt-2">
<span className="flex items-center gap-1" data-testid="initiator-info">
<User className="w-3 h-3" />
{request.initiatorName}
{request.initiatorDepartment && (
<span className="ml-1">({request.initiatorDepartment})</span>
)}
</span>
<span className="flex items-center gap-1" data-testid="submission-date">
<Clock className="w-3 h-3" />
Submitted: {formatDate(request.submissionDate)}
</span>
{request.approvalActionDate && (
<span className="flex items-center gap-1" data-testid="action-date">
<CheckCircle className="w-3 h-3" />
Action: {formatDateTime(request.approvalActionDate)}
</span>
)}
<span className="flex items-center gap-1" data-testid="approval-level">
<Target className="w-3 h-3" />
Level {request.levelNumber} of {request.totalLevels}
</span>
{request.levelElapsedHours && request.levelElapsedHours > 0 && (
<span className="flex items-center gap-1" data-testid="tat-info">
<Timer className="w-3 h-3" />
{formatHoursMinutes(request.levelElapsedHours)} / {formatHoursMinutes(request.levelTatHours || 0)} TAT
</span>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
navigate(`/request/${request.requestId}`);
}}
data-testid="view-request-button"
>
<ArrowRight className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Pagination */}
{totalPages > 0 && (
<div className="mt-6" data-testid="pagination-container">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalRecords={totalRecords}
itemsPerPage={itemsPerPage}
onPageChange={onPageChange}
itemLabel="requests"
testIdPrefix="approver-performance"
/>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,112 @@
/**
* Approver Performance Overview Stats Cards Component
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { CheckCircle, Clock, Target, Timer } from 'lucide-react';
import type { ApproverPerformance } from '@/services/dashboard.service';
import type { ApproverPerformanceStats } from '../types/approverPerformance.types';
interface ApproverPerformanceStatsCardsProps {
approverStats: ApproverPerformance | null;
calculatedStats: ApproverPerformanceStats;
}
export function ApproverPerformanceStatsCards({
approverStats,
calculatedStats
}: ApproverPerformanceStatsCardsProps) {
if (!approverStats) return null;
const tatCompliance = approverStats?.tatCompliancePercent ?? calculatedStats.tatComplianceRate;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4" data-testid="approver-stats-cards">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-gray-600">TAT Compliance</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="text-3xl font-bold text-gray-900">
{tatCompliance}%
</div>
<div className={`p-2 rounded-lg ${
tatCompliance >= 95 ? 'bg-green-100' :
tatCompliance >= 90 ? 'bg-blue-100' :
tatCompliance >= 85 ? 'bg-orange-100' : 'bg-red-100'
}`}>
<Target className={`w-5 h-5 ${
tatCompliance >= 95 ? 'text-green-600' :
tatCompliance >= 90 ? 'text-blue-600' :
tatCompliance >= 85 ? 'text-orange-600' : 'text-red-600'
}`} />
</div>
</div>
<Progress
value={tatCompliance}
className="mt-2 h-2"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-gray-600">Total Approved</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="text-3xl font-bold text-gray-900">
{approverStats.totalApproved}
</div>
<div className="p-2 bg-blue-100 rounded-lg">
<CheckCircle className="w-5 h-5 text-blue-600" />
</div>
</div>
<p className="text-xs text-gray-500 mt-1">Requests handled</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-gray-600">Avg Response Time</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="text-3xl font-bold text-gray-900">
{approverStats.avgResponseHours.toFixed(1)}h
</div>
<div className="p-2 bg-purple-100 rounded-lg">
<Timer className="w-5 h-5 text-purple-600" />
</div>
</div>
<p className="text-xs text-gray-500 mt-1">
{approverStats.avgResponseHours < 24
? `${(approverStats.avgResponseHours / 8).toFixed(1)} working days`
: `${(approverStats.avgResponseHours / 24).toFixed(1)} days`
}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-gray-600">Pending Actions</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="text-3xl font-bold text-gray-900">
{approverStats.pendingCount}
</div>
<div className="p-2 bg-orange-100 rounded-lg">
<Clock className="w-5 h-5 text-orange-600" />
</div>
</div>
<p className="text-xs text-gray-500 mt-1">Awaiting approval</p>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,196 @@
/**
* Hook for fetching and managing Approver Performance data
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import type { DateRange } from '@/services/dashboard.service';
import type { ApproverPerformance } from '@/services/dashboard.service';
import dashboardService from '@/services/dashboard.service';
import type { ApproverPerformanceRequest } from '../types/approverPerformance.types';
interface UseApproverPerformanceDataOptions {
approverId: string;
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
statusFilter: string;
priorityFilter: string;
slaComplianceFilter: string;
searchTerm: string;
itemsPerPage: number;
}
export function useApproverPerformanceData({
approverId,
dateRange,
customStartDate,
customEndDate,
statusFilter,
priorityFilter,
slaComplianceFilter,
searchTerm,
itemsPerPage
}: UseApproverPerformanceDataOptions) {
const [requests, setRequests] = useState<ApproverPerformanceRequest[]>([]);
const [approverStats, setApproverStats] = useState<ApproverPerformance | null>(null);
const [allFilteredRequests, setAllFilteredRequests] = useState<ApproverPerformanceRequest[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalRecords, setTotalRecords] = useState(0);
const isInitialMount = useRef(true);
// Fetch approver performance stats
const fetchApproverStats = useCallback(async () => {
if (!approverId) return;
try {
const result = await dashboardService.getApproverPerformance(
dateRange,
1,
100,
customStartDate,
customEndDate
);
const approver = result.performance.find((p: ApproverPerformance) => p.approverId === approverId);
if (approver) {
setApproverStats(approver);
}
} catch (error) {
console.error('Failed to fetch approver stats:', error);
}
}, [approverId, dateRange, customStartDate, customEndDate]);
// Fetch requests for this approver
const fetchRequests = useCallback(async (page: number = 1) => {
if (!approverId) {
setLoading(false);
return;
}
try {
setLoading(true);
// Fetch current page only (server-side pagination)
const result = await dashboardService.getRequestsByApprover(
approverId,
page,
itemsPerPage,
dateRange,
customStartDate,
customEndDate,
statusFilter !== 'all' ? statusFilter : undefined,
priorityFilter !== 'all' ? priorityFilter : undefined,
slaComplianceFilter !== 'all' ? slaComplianceFilter : undefined,
searchTerm || undefined
);
setRequests(result.requests);
setTotalRecords(result.pagination.totalRecords);
setTotalPages(result.pagination.totalPages);
setCurrentPage(page);
// For stats calculation, fetch ALL data (without pagination)
const statsResult = await dashboardService.getRequestsByApprover(
approverId,
1,
10000,
dateRange,
customStartDate,
customEndDate,
statusFilter !== 'all' ? statusFilter : undefined,
priorityFilter !== 'all' ? priorityFilter : undefined,
slaComplianceFilter !== 'all' ? slaComplianceFilter : undefined,
searchTerm || undefined
);
setAllFilteredRequests(statsResult.requests);
} catch (error) {
console.error('Failed to fetch requests:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [
approverId,
dateRange,
customStartDate,
customEndDate,
statusFilter,
priorityFilter,
slaComplianceFilter,
searchTerm,
itemsPerPage
]);
// Initial fetch
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
fetchApproverStats();
fetchRequests(1);
}
}, []); // Only run on mount
// Refetch when filters change
useEffect(() => {
if (!isInitialMount.current) {
fetchApproverStats();
fetchRequests(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
dateRange,
customStartDate,
customEndDate,
statusFilter,
priorityFilter,
slaComplianceFilter,
searchTerm
// fetchApproverStats and fetchRequests excluded to prevent infinite loops
]);
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchApproverStats();
fetchRequests(1);
}, [fetchApproverStats, fetchRequests]);
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page);
// Use client-side pagination since we have allFilteredRequests
const startIdx = (page - 1) * itemsPerPage;
const endIdx = startIdx + itemsPerPage;
const paginatedRequests = allFilteredRequests.slice(startIdx, endIdx);
setRequests(paginatedRequests);
}, [allFilteredRequests, itemsPerPage]);
// Update paginated data when allFilteredRequests changes
useEffect(() => {
const startIdx = (currentPage - 1) * itemsPerPage;
const endIdx = startIdx + itemsPerPage;
const paginatedRequests = allFilteredRequests.slice(startIdx, endIdx);
setRequests(paginatedRequests);
setTotalPages(Math.ceil(allFilteredRequests.length / itemsPerPage));
if (currentPage > Math.ceil(allFilteredRequests.length / itemsPerPage) && allFilteredRequests.length > 0) {
setCurrentPage(1);
}
}, [allFilteredRequests, currentPage, itemsPerPage]);
return {
requests,
approverStats,
allFilteredRequests,
loading,
refreshing,
currentPage,
totalPages,
totalRecords,
handleRefresh,
handlePageChange
};
}

View File

@ -0,0 +1,101 @@
/**
* Hook for managing Approver Performance filters
*/
import { useState, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { DateRange } from '@/services/dashboard.service';
export function useApproverPerformanceFilters() {
const [searchParams] = useSearchParams();
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get('status') || 'all');
const [priorityFilter, setPriorityFilter] = useState<string>(searchParams.get('priority') || 'all');
const [slaComplianceFilter, setSlaComplianceFilter] = useState<string>(searchParams.get('slaCompliance') || 'all');
const [dateRange, setDateRange] = useState<DateRange>((searchParams.get('dateRange') as DateRange) || 'month');
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
searchParams.get('startDate') ? new Date(searchParams.get('startDate')!) : undefined
);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
searchParams.get('endDate') ? new Date(searchParams.get('endDate')!) : undefined
);
const [showCustomDatePicker, setShowCustomDatePicker] = useState(false);
const [tempCustomStartDate, setTempCustomStartDate] = useState<Date | undefined>(undefined);
const [tempCustomEndDate, setTempCustomEndDate] = useState<Date | undefined>(undefined);
const clearFilters = useCallback(() => {
setSearchTerm('');
setStatusFilter('all');
setPriorityFilter('all');
setSlaComplianceFilter('all');
setDateRange('month');
setCustomStartDate(undefined);
setCustomEndDate(undefined);
setTempCustomStartDate(undefined);
setTempCustomEndDate(undefined);
setShowCustomDatePicker(false);
}, []);
const handleDateRangeChange = useCallback((value: string) => {
const newRange = value as DateRange;
setDateRange(newRange);
if (newRange !== 'custom') {
setCustomStartDate(undefined);
setCustomEndDate(undefined);
setTempCustomStartDate(undefined);
setTempCustomEndDate(undefined);
setShowCustomDatePicker(false);
} else {
setTempCustomStartDate(customStartDate);
setTempCustomEndDate(customEndDate);
setShowCustomDatePicker(true);
}
}, [customStartDate, customEndDate]);
const handleApplyCustomDate = useCallback(() => {
if (tempCustomStartDate && tempCustomEndDate) {
if (tempCustomStartDate > tempCustomEndDate) {
const temp = tempCustomStartDate;
setCustomStartDate(tempCustomEndDate);
setCustomEndDate(temp);
setTempCustomStartDate(tempCustomEndDate);
setTempCustomEndDate(temp);
} else {
setCustomStartDate(tempCustomStartDate);
setCustomEndDate(tempCustomEndDate);
}
setShowCustomDatePicker(false);
}
}, [tempCustomStartDate, tempCustomEndDate]);
return {
// State
searchTerm,
statusFilter,
priorityFilter,
slaComplianceFilter,
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
tempCustomStartDate,
tempCustomEndDate,
// Setters
setSearchTerm,
setStatusFilter,
setPriorityFilter,
setSlaComplianceFilter,
setDateRange,
setCustomStartDate,
setCustomEndDate,
setShowCustomDatePicker,
setTempCustomStartDate,
setTempCustomEndDate,
// Helpers
clearFilters,
handleDateRangeChange,
handleApplyCustomDate
};
}

View File

@ -0,0 +1,48 @@
/**
* Type definitions for Approver Performance page
*/
import type { ApproverPerformance } from '@/services/dashboard.service';
export interface ApproverPerformanceStats {
total: number;
approvedByApprover: number;
rejectedByApprover: number;
pendingByApprover: number;
breached: number;
compliant: number;
approvalRate: number;
rejectionRate: number;
tatComplianceRate: number;
completedActions: number;
}
export interface ApproverPerformanceRequest {
requestId: string;
requestNumber: string;
title: string;
priority: string;
status: string;
approvalStatus?: string;
slaStatus?: string;
initiatorName?: string;
initiatorDepartment?: string;
submissionDate?: string | Date;
approvalActionDate?: string | Date;
levelNumber?: number;
totalLevels?: number;
levelElapsedHours?: number;
levelTatHours?: number;
isBreached?: boolean | number;
}
export interface ApproverPerformanceFilters {
search?: string;
status?: string;
priority?: string;
slaCompliance?: string;
dateRange?: string;
startDate?: Date;
endDate?: Date;
}

View File

@ -0,0 +1,101 @@
/**
* Configuration mappers for priority, status, and SLA badges
*/
import { TrendingUp, Target, CheckCircle, XCircle, Clock, FileText } from 'lucide-react';
export const getPriorityConfig = (priority: string) => {
switch (priority) {
case 'express':
return {
color: 'bg-red-100 text-red-800 border-red-200',
icon: TrendingUp,
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'
};
}
};
export 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 'in-progress':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
icon: Clock,
iconColor: 'text-blue-600'
};
case 'closed':
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: CheckCircle,
iconColor: 'text-gray-600'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: FileText,
iconColor: 'text-gray-600'
};
}
};
export const getSLAConfig = (status: string) => {
switch (status) {
case 'breached':
return {
color: 'bg-red-100 text-red-800 border-red-200',
label: 'Breached'
};
case 'critical':
return {
color: 'bg-orange-100 text-orange-800 border-orange-200',
label: 'Critical'
};
case 'approaching':
return {
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
label: 'Approaching'
};
case 'on_track':
case 'on-track':
return {
color: 'bg-green-100 text-green-800 border-green-200',
label: 'On Track'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: 'N/A'
};
}
};

View File

@ -0,0 +1,24 @@
/**
* Date and time formatting utilities
*/
import { format } from 'date-fns';
export function formatDate(date: string | Date | null | undefined): string {
if (!date) return 'N/A';
try {
return format(new Date(date), 'MMM d, yyyy');
} catch {
return 'N/A';
}
}
export function formatDateTime(date: string | Date | null | undefined): string {
if (!date) return 'N/A';
try {
return format(new Date(date), 'MMM d, yyyy HH:mm');
} catch {
return 'N/A';
}
}

View File

@ -0,0 +1,85 @@
/**
* Statistics calculation utilities for approver performance
*/
import type { ApproverPerformanceStats, ApproverPerformanceRequest } from '../types/approverPerformance.types';
export function calculateApproverStats(
allFilteredRequests: ApproverPerformanceRequest[]
): ApproverPerformanceStats {
const filtered = allFilteredRequests;
// Count by approver's approval status (what the approver actually did)
const approvedByApprover = filtered.filter(r => {
const status = (r.approvalStatus || '').toLowerCase();
return status === 'approved';
}).length;
const rejectedByApprover = filtered.filter(r => {
const status = (r.approvalStatus || '').toLowerCase();
return status === 'rejected';
}).length;
const pendingByApprover = filtered.filter(r => {
const status = (r.approvalStatus || '').toLowerCase();
return ['pending', 'in_progress', 'in-progress'].includes(status);
}).length;
// TAT compliance stats - check isBreached flag OR slaStatus === 'breached'
// This includes pending requests that have breached their TAT
const breached = filtered.filter(r => {
const isBreached = r.isBreached === true || r.isBreached === 1;
const slaStatusBreached = (r.slaStatus || '').toLowerCase() === 'breached';
return isBreached || slaStatusBreached;
}).length;
// Compliant: completed actions (approved/rejected) that are NOT breached
// OR pending requests that haven't breached yet
const compliant = filtered.filter(r => {
const status = (r.approvalStatus || '').toLowerCase();
const isCompleted = status === 'approved' || status === 'rejected';
const isPending = ['pending', 'in_progress', 'in-progress'].includes(status);
const isNotBreached = !(r.isBreached === true || r.isBreached === 1) &&
(r.slaStatus || '').toLowerCase() !== 'breached';
// Completed and not breached = compliant
// Pending and not breached = also counts as compliant (hasn't breached yet)
return (isCompleted && isNotBreached) || (isPending && isNotBreached);
}).length;
// Calculate approval rate (based on completed actions)
const completedActions = approvedByApprover + rejectedByApprover;
const approvalRate = completedActions > 0
? Math.round((approvedByApprover / completedActions) * 100)
: 0;
// Calculate rejection rate
const rejectionRate = completedActions > 0
? Math.round((rejectedByApprover / completedActions) * 100)
: 0;
// Calculate TAT compliance rate
// Use total requests (not just completed) to include pending requests in compliance calculation
// Use Math.floor to ensure consistent rounding with dashboard (prevents 79.5% vs 80% discrepancy)
const totalForCompliance = filtered.length;
const tatComplianceRate = totalForCompliance > 0
? Math.floor((compliant / totalForCompliance) * 100)
: 0;
return {
total: filtered.length,
// Approver's actions
approvedByApprover,
rejectedByApprover,
pendingByApprover,
// TAT stats
breached,
compliant,
// Rates
approvalRate,
rejectionRate,
tatComplianceRate,
completedActions
};
}

View File

@ -1,599 +1,118 @@
import { useEffect, useState, useCallback } from 'react';
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Calendar, Filter, Search, FileText, AlertCircle, CheckCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, X, XCircle } from 'lucide-react';
import workflowApi from '@/services/workflowApi';
import { formatDateTime } from '@/utils/dateFormatter';
import { useCallback, useRef } from 'react';
interface Request {
id: string;
title: string;
description: string;
status: 'approved' | 'rejected' | 'closed';
priority: 'express' | 'standard';
initiator: { name: string; avatar: string };
createdAt: string;
dueDate?: string;
reason?: string;
department?: string;
totalLevels?: number;
completedLevels?: number;
}
// Components
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
import { ClosedRequestsFilters as ClosedRequestsFiltersComponent } from './components/ClosedRequestsFilters';
import { ClosedRequestsList } from './components/ClosedRequestsList';
import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty';
import { ClosedRequestsPagination } from './components/ClosedRequestsPagination';
interface ClosedRequestsProps {
onViewRequest?: (requestId: string, requestTitle?: string) => void;
}
// Hooks
import { useClosedRequests } from './hooks/useClosedRequests';
import { useClosedRequestsFilters } from './hooks/useClosedRequestsFilters';
// Removed static data; will load from API
// 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 'approved':
return {
color: 'bg-emerald-100 text-emerald-800 border-emerald-300',
icon: CheckCircle,
iconColor: 'text-emerald-600',
label: 'Needs Closure',
description: 'Fully approved, awaiting initiator to finalize'
};
case 'closed':
return {
color: 'bg-slate-100 text-slate-800 border-slate-300',
icon: CheckCircle,
iconColor: 'text-slate-600',
label: 'Closed',
description: 'Request finalized and archived'
};
case 'rejected':
return {
color: 'bg-red-100 text-red-800 border-red-300',
icon: XCircle,
iconColor: 'text-red-600',
label: 'Rejected',
description: 'Request was declined'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: AlertCircle,
iconColor: 'text-gray-600',
label: status,
description: ''
};
}
};
// Types
import type { ClosedRequestsProps } from './types/closedRequests.types';
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
const [searchTerm, setSearchTerm] = useState('');
const [priorityFilter, setPriorityFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority'>('due');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [items, setItems] = useState<Request[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
// Data fetching hook
const closedRequests = useClosedRequests({ itemsPerPage: 10 });
// Pagination states
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalRecords, setTotalRecords] = useState(0);
const [itemsPerPage] = useState(10);
// Filters hook - use ref to avoid circular dependency
const fetchRef = useRef(closedRequests.fetchRequests);
fetchRef.current = closedRequests.fetchRequests;
// Fetch closed requests for the current user only (user-scoped, not organization-wide)
// Note: This endpoint returns only requests where the user is:
// - An approver (for APPROVED, REJECTED, CLOSED requests)
// - A spectator (for APPROVED, REJECTED, CLOSED requests)
// - An initiator (for REJECTED or CLOSED requests only, not APPROVED - those are in Open Requests)
// 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([]);
const filters = useClosedRequestsFilters({
onFiltersChange: useCallback(
(filters) => {
// Reset to page 1 when filters change
fetchRef.current(1, {
search: filters.search || undefined,
status: filters.status !== 'all' ? filters.status : undefined,
priority: filters.priority !== 'all' ? filters.priority : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
},
[]
),
});
// Page change handler
const handlePageChange = useCallback(
(newPage: number) => {
if (newPage >= 1 && newPage <= closedRequests.pagination.totalPages) {
closedRequests.fetchRequests(newPage, {
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
}
},
[closedRequests, filters]
);
console.log('[ClosedRequests] Fetching with filters:', { page, filters }); // Debug log
// 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.listClosedByMe({
page,
limit: itemsPerPage,
search: filters?.search,
status: filters?.status,
priority: filters?.priority,
sortBy: filters?.sortBy,
sortOrder: filters?.sortOrder
});
console.log('[ClosedRequests] API Response:', result); // Debug log
// Extract data - workflowApi now returns { data: [], pagination: {} }
const data = Array.isArray((result as any)?.data)
? (result as any).data
: [];
console.log('[ClosedRequests] Parsed data count:', data.length); // Debug log
// 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) => ({
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
requestId: r.requestId, // Keep requestId for reference
displayId: r.requestNumber || r.request_number || r.requestId,
title: r.title,
description: r.description,
status: (r.status || '').toString().toLowerCase(),
priority: (r.priority || '').toString().toLowerCase(),
initiator: { name: r.initiator?.displayName || r.initiator?.email || '—', avatar: (r.initiator?.displayName || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase() },
createdAt: r.submittedAt || r.createdAt || r.created_at || '—',
dueDate: r.closureDate || r.closure_date || r.closedAt || undefined,
reason: r.conclusionRemark || r.conclusion_remark,
department: r.department,
totalLevels: r.totalLevels || 0,
completedLevels: r.summary?.approvedLevels || 0,
}));
setItems(mapped);
} catch (error) {
console.error('[ClosedRequests] Error fetching requests:', error);
setItems([]);
} 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
// Refresh handler
const handleRefresh = useCallback(() => {
closedRequests.handleRefresh({
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.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;
};
// Initial fetch on mount and when filters/sorting change (with debouncing for search)
useEffect(() => {
// Debounce search: wait 500ms after user stops typing
const timeoutId = setTimeout(() => {
console.log('[ClosedRequests] Filter changed, fetching...', {
searchTerm,
statusFilter,
priorityFilter,
sortBy,
sortOrder
}); // Debug log
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 filtering/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;
}, [closedRequests, filters]);
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 Closed Requests</h1>
<p className="text-sm sm:text-base text-gray-600">Review your completed and archived requests</p>
</div>
</div>
</div>
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="closed-requests-page">
{/* Header */}
<ClosedRequestsHeader
totalRecords={closedRequests.pagination.totalRecords}
loading={closedRequests.loading}
refreshing={closedRequests.refreshing}
onRefresh={handleRefresh}
/>
<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} closed`}
<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>
{/* Filters */}
<ClosedRequestsFiltersComponent
searchTerm={filters.searchTerm}
priorityFilter={filters.priorityFilter}
statusFilter={filters.statusFilter}
sortBy={filters.sortBy}
sortOrder={filters.sortOrder}
activeFiltersCount={filters.activeFiltersCount}
onSearchChange={filters.setSearchTerm}
onPriorityChange={filters.setPriorityFilter}
onStatusChange={filters.setStatusFilter}
onSortByChange={filters.setSortBy}
onSortOrderChange={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')}
onClearFilters={filters.clearFilters}
/>
{/* 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 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">
<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">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="approved">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span>Approved</span>
</div>
</SelectItem>
<SelectItem value="closed">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-gray-600" />
<span>Closed</span>
</div>
</SelectItem>
<SelectItem value="rejected">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-600" />
<span>Rejected</span>
</div>
</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">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</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 - Balanced Compact View */}
<div className="space-y-4">
{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-5">
<div className="flex items-start gap-5">
{/* 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-3">
{/* 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.label}
</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>
{/* 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.totalLevels ?? 0) > 0 && (
<div className="flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
<span className="font-medium">{request.completedLevels || 0}/{request.totalLevels} Approvals</span>
</div>
)}
<div className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
<span>Created: {request.createdAt !== '—' ? formatDateTime(request.createdAt) : '—'}</span>
</div>
{request.dueDate && (
<div className="flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-slate-600" />
<span className="font-medium">Closed: {formatDateTime(request.dueDate)}</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>
{/* Requests List */}
<ClosedRequestsList
requests={closedRequests.requests}
loading={closedRequests.loading}
onViewRequest={onViewRequest}
/>
{/* 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 closed requests available at the moment.'
}
</p>
{activeFiltersCount > 0 && (
<Button
variant="outline"
className="mt-4"
onClick={clearFilters}
>
Clear all filters
</Button>
)}
</CardContent>
</Card>
{closedRequests.requests.length === 0 && !closedRequests.loading && (
<ClosedRequestsEmpty
searchTerm={filters.searchTerm}
activeFiltersCount={filters.activeFiltersCount}
onClearFilters={filters.clearFilters}
/>
)}
{/* 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} closed 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-blue-600 text-white hover:bg-blue-700' : ''}`}
>
{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>
{/* Pagination */}
{!closedRequests.loading && (
<ClosedRequestsPagination
pagination={closedRequests.pagination}
onPageChange={handlePageChange}
/>
)}
</div>
);

View File

@ -0,0 +1,112 @@
/**
* Individual request card component for Closed Requests
*/
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ArrowRight, Calendar, CheckCircle } from 'lucide-react';
import { formatDateTime } from '@/utils/dateFormatter';
import { ClosedRequest } from '../types/closedRequests.types';
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
interface ClosedRequestCardProps {
request: ClosedRequest;
onViewRequest?: (requestId: string, requestTitle?: string) => void;
}
export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardProps) {
const priorityConfig = getPriorityConfig(request.priority);
const statusConfig = getStatusConfig(request.status);
const PriorityIcon = priorityConfig.icon;
const StatusIcon = statusConfig.icon;
return (
<Card
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)}
data-testid={`closed-request-card-${request.id}`}
>
<CardContent className="p-5">
<div className="flex items-start gap-5">
{/* Left: Priority Icon */}
<div className="flex-shrink-0 pt-1">
<div className={`p-2.5 rounded-lg ${priorityConfig.color} border shadow-sm`}>
<PriorityIcon className={`w-5 h-5 ${priorityConfig.iconColor}`} />
</div>
</div>
{/* Center: Main Content */}
<div className="flex-1 min-w-0 space-y-3">
{/* 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.displayId || request.id}
</h3>
<Badge
variant="outline"
className={`${statusConfig.color} text-xs px-2.5 py-0.5 font-semibold shrink-0`}
>
<StatusIcon className="w-3.5 h-3.5 mr-1" />
{statusConfig.label}
</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>
{/* 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.totalLevels ?? 0) > 0 && (
<div className="flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
<span className="font-medium">{request.completedLevels || 0}/{request.totalLevels} Approvals</span>
</div>
)}
<div className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
<span>Created: {request.createdAt !== '—' ? formatDateTime(request.createdAt) : '—'}</span>
</div>
{request.dueDate && (
<div className="flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-slate-600" />
<span className="font-medium">Closed: {formatDateTime(request.dueDate)}</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>
);
}

View File

@ -0,0 +1,47 @@
/**
* Empty state component for Closed Requests
*/
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { FileText } from 'lucide-react';
interface ClosedRequestsEmptyProps {
searchTerm: string;
activeFiltersCount: number;
onClearFilters: () => void;
}
export function ClosedRequestsEmpty({
searchTerm,
activeFiltersCount,
onClearFilters,
}: ClosedRequestsEmptyProps) {
return (
<Card className="shadow-lg border-0" data-testid="closed-requests-empty">
<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 closed requests available at the moment.'
}
</p>
{activeFiltersCount > 0 && (
<Button
variant="outline"
className="mt-4"
onClick={onClearFilters}
data-testid="closed-requests-empty-clear-filters"
>
Clear all filters
</Button>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,161 @@
/**
* Filters component for Closed Requests
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, Flame, Target, CheckCircle, XCircle, X } from 'lucide-react';
interface ClosedRequestsFiltersProps {
searchTerm: string;
priorityFilter: string;
statusFilter: string;
sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc';
activeFiltersCount: number;
onSearchChange: (value: string) => void;
onPriorityChange: (value: string) => void;
onStatusChange: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
onSortOrderChange: () => void;
onClearFilters: () => void;
}
export function ClosedRequestsFilters({
searchTerm,
priorityFilter,
statusFilter,
sortBy,
sortOrder,
activeFiltersCount,
onSearchChange,
onPriorityChange,
onStatusChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
}: ClosedRequestsFiltersProps) {
return (
<Card className="shadow-lg border-0" data-testid="closed-requests-filters">
<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={onClearFilters}
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
data-testid="closed-requests-clear-filters"
>
<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">
<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) => onSearchChange(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 transition-colors"
data-testid="closed-requests-search"
/>
</div>
<Select value={priorityFilter} onValueChange={onPriorityChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-priority-filter">
<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={onStatusChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-status-filter">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="approved">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span>Approved</span>
</div>
</SelectItem>
<SelectItem value="closed">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-gray-600" />
<span>Closed</span>
</div>
</SelectItem>
<SelectItem value="rejected">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-600" />
<span>Rejected</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-sort-by">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={onSortOrderChange}
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
data-testid="closed-requests-sort-order"
>
{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>
);
}

View File

@ -0,0 +1,56 @@
/**
* Header component for Closed Requests page
*/
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { FileText, RefreshCw } from 'lucide-react';
interface ClosedRequestsHeaderProps {
totalRecords: number;
loading: boolean;
refreshing: boolean;
onRefresh: () => void;
}
export function ClosedRequestsHeader({
totalRecords,
loading,
refreshing,
onRefresh,
}: ClosedRequestsHeaderProps) {
return (
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 sm:gap-4 md:gap-6" data-testid="closed-requests-header">
<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 Closed Requests</h1>
<p className="text-sm sm:text-base text-gray-600">Review your completed and archived 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" data-testid="closed-requests-count">
{loading ? 'Loading…' : `${totalRecords} closed`}
<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={onRefresh}
disabled={refreshing}
data-testid="closed-requests-refresh"
>
<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>
);
}

View File

@ -0,0 +1,41 @@
/**
* List component for Closed Requests with loading and empty states
*/
import { ClosedRequest } from '../types/closedRequests.types';
import { ClosedRequestCard } from './ClosedRequestCard';
interface ClosedRequestsListProps {
requests: ClosedRequest[];
loading: boolean;
onViewRequest?: (requestId: string, requestTitle?: string) => void;
}
export function ClosedRequestsList({ requests, loading, onViewRequest }: ClosedRequestsListProps) {
if (loading && requests.length === 0) {
return (
<div className="space-y-4" data-testid="closed-requests-list-loading">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-32 bg-gray-100 animate-pulse rounded-lg" />
))}
</div>
);
}
if (requests.length === 0) {
return null; // Empty state is handled by parent
}
return (
<div className="space-y-4" data-testid="closed-requests-list">
{requests.map((request) => (
<ClosedRequestCard
key={request.id}
request={request}
onViewRequest={onViewRequest}
/>
))}
</div>
);
}

View File

@ -0,0 +1,107 @@
/**
* Pagination component for Closed Requests
*/
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ArrowRight } from 'lucide-react';
import { getPageNumbers } from '../utils/paginationHelpers';
import { PaginationState } from '../types/closedRequests.types';
interface ClosedRequestsPaginationProps {
pagination: PaginationState;
onPageChange: (page: number) => void;
}
export function ClosedRequestsPagination({
pagination,
onPageChange,
}: ClosedRequestsPaginationProps) {
const { currentPage, totalPages, totalRecords, itemsPerPage } = pagination;
if (totalPages <= 1) {
return null;
}
const pageNumbers = getPageNumbers(currentPage, totalPages);
return (
<Card className="shadow-md" data-testid="closed-requests-pagination">
<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} closed requests
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="h-8 w-8 p-0"
data-testid="closed-requests-pagination-prev"
>
<ArrowRight className="h-4 w-4 rotate-180" />
</Button>
{currentPage > 3 && totalPages > 5 && (
<>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(1)}
className="h-8 w-8 p-0"
data-testid="closed-requests-pagination-first"
>
1
</Button>
<span className="text-muted-foreground">...</span>
</>
)}
{pageNumbers.map((pageNum) => (
<Button
key={pageNum}
variant={pageNum === currentPage ? "default" : "outline"}
size="sm"
onClick={() => onPageChange(pageNum)}
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-blue-600 text-white hover:bg-blue-700' : ''}`}
data-testid={`closed-requests-pagination-page-${pageNum}`}
>
{pageNum}
</Button>
))}
{currentPage < totalPages - 2 && totalPages > 5 && (
<>
<span className="text-muted-foreground">...</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(totalPages)}
className="h-8 w-8 p-0"
data-testid="closed-requests-pagination-last"
>
{totalPages}
</Button>
</>
)}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
data-testid="closed-requests-pagination-next"
>
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,106 @@
/**
* Hook for fetching and managing Closed Requests data
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import workflowApi from '@/services/workflowApi';
import { ClosedRequest, PaginationState } from '../types/closedRequests.types';
import { transformClosedRequests } from '../utils/requestTransformers';
interface UseClosedRequestsOptions {
itemsPerPage?: number;
initialFilters?: {
search?: string;
status?: string;
priority?: string;
sortBy?: string;
sortOrder?: string;
};
}
export function useClosedRequests({ itemsPerPage = 10, initialFilters }: UseClosedRequestsOptions = {}) {
const [requests, setRequests] = useState<ClosedRequest[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [pagination, setPagination] = useState<PaginationState>({
currentPage: 1,
totalPages: 1,
totalRecords: 0,
itemsPerPage,
});
const isInitialMount = useRef(true);
const fetchRequests = useCallback(
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
try {
if (page === 1) {
setLoading(true);
setRequests([]);
}
// 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.listClosedByMe({
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 paginationData = (result as any)?.pagination;
if (paginationData) {
setPagination({
currentPage: paginationData.page || 1,
totalPages: paginationData.totalPages || 1,
totalRecords: paginationData.total || 0,
itemsPerPage,
});
}
const mapped = transformClosedRequests(data);
setRequests(mapped);
} catch (error) {
console.error('[ClosedRequests] Error fetching requests:', error);
setRequests([]);
} finally {
setLoading(false);
setRefreshing(false);
}
},
[itemsPerPage]
);
// Initial fetch on mount
useEffect(() => {
if (isInitialMount.current) {
fetchRequests(1, initialFilters);
isInitialMount.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only run on mount
const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
setRefreshing(true);
fetchRequests(pagination.currentPage, filters);
}, [fetchRequests, pagination.currentPage]);
return {
requests,
loading,
refreshing,
pagination,
fetchRequests,
handleRefresh,
};
}

View File

@ -0,0 +1,87 @@
/**
* Hook for managing Closed Requests filters and sorting
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { ClosedRequestsFilters } from '../types/closedRequests.types';
interface UseClosedRequestsFiltersOptions {
onFiltersChange?: (filters: ClosedRequestsFilters) => void;
debounceMs?: number;
}
export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseClosedRequestsFiltersOptions = {}) {
const [searchTerm, setSearchTerm] = useState('');
const [priorityFilter, setPriorityFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority'>('due');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isInitialMount = useRef(true);
const getFilters = useCallback((): ClosedRequestsFilters => {
return {
search: searchTerm,
status: statusFilter,
priority: priorityFilter,
sortBy,
sortOrder,
};
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder]);
// Debounced filter change handler
useEffect(() => {
// Skip initial mount to prevent double fetch
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
const timeoutId = setTimeout(() => {
if (onFiltersChange) {
onFiltersChange(getFilters());
}
}, searchTerm ? debounceMs : 0); // Debounce only for search, instant for dropdowns
debounceTimeoutRef.current = timeoutId;
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]);
const clearFilters = useCallback(() => {
setSearchTerm('');
setPriorityFilter('all');
setStatusFilter('all');
}, []);
const activeFiltersCount = [
searchTerm,
priorityFilter !== 'all' ? priorityFilter : null,
statusFilter !== 'all' ? statusFilter : null
].filter(Boolean).length;
return {
searchTerm,
priorityFilter,
statusFilter,
sortBy,
sortOrder,
setSearchTerm,
setPriorityFilter,
setStatusFilter,
setSortBy,
setSortOrder,
clearFilters,
activeFiltersCount,
getFilters,
};
}

View File

@ -0,0 +1,54 @@
/**
* Closed Requests TypeScript interfaces
*/
export interface ClosedRequest {
id: string;
requestId: string;
displayId?: string;
title: string;
description: string;
status: 'approved' | 'rejected' | 'closed';
priority: 'express' | 'standard';
initiator: { name: string; avatar: string };
createdAt: string;
dueDate?: string;
reason?: string;
department?: string;
totalLevels?: number;
completedLevels?: number;
}
export interface ClosedRequestsProps {
onViewRequest?: (requestId: string, requestTitle?: string) => void;
}
export interface ClosedRequestsFilters {
search: string;
status: string;
priority: string;
sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc';
}
export interface PaginationState {
currentPage: number;
totalPages: number;
totalRecords: number;
itemsPerPage: number;
}
export interface PriorityConfig {
color: string;
icon: React.ComponentType<{ className?: string }>;
iconColor: string;
}
export interface StatusConfig {
color: string;
icon: React.ComponentType<{ className?: string }>;
iconColor: string;
label: string;
description: string;
}

View File

@ -0,0 +1,67 @@
/**
* Configuration mappers for priority and status badges
*/
import { Flame, Target, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { PriorityConfig, StatusConfig } from '../types/closedRequests.types';
export function getPriorityConfig(priority: string): PriorityConfig {
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'
};
}
}
export function getStatusConfig(status: string): StatusConfig {
switch (status) {
case 'approved':
return {
color: 'bg-emerald-100 text-emerald-800 border-emerald-300',
icon: CheckCircle,
iconColor: 'text-emerald-600',
label: 'Needs Closure',
description: 'Fully approved, awaiting initiator to finalize'
};
case 'closed':
return {
color: 'bg-slate-100 text-slate-800 border-slate-300',
icon: CheckCircle,
iconColor: 'text-slate-600',
label: 'Closed',
description: 'Request finalized and archived'
};
case 'rejected':
return {
color: 'bg-red-100 text-red-800 border-red-300',
icon: XCircle,
iconColor: 'text-red-600',
label: 'Rejected',
description: 'Request was declined'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: AlertCircle,
iconColor: 'text-gray-600',
label: status,
description: ''
};
}
}

View File

@ -0,0 +1,21 @@
/**
* Pagination helper functions
*/
export function getPageNumbers(currentPage: number, totalPages: number): number[] {
const pages: number[] = [];
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;
}

View File

@ -0,0 +1,37 @@
/**
* Request data transformation utilities
*/
import { ClosedRequest } from '../types/closedRequests.types';
export function transformClosedRequest(r: any): ClosedRequest {
return {
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
requestId: r.requestId, // Keep requestId for reference
displayId: r.requestNumber || r.request_number || r.requestId,
title: r.title,
description: r.description,
status: (r.status || '').toString().toLowerCase() as 'approved' | 'rejected' | 'closed',
priority: (r.priority || '').toString().toLowerCase() as 'express' | 'standard',
initiator: {
name: r.initiator?.displayName || r.initiator?.email || '—',
avatar: (r.initiator?.displayName || 'NA')
.split(' ')
.map((s: string) => s[0])
.join('')
.slice(0, 2)
.toUpperCase()
},
createdAt: r.submittedAt || r.createdAt || r.created_at || '—',
dueDate: r.closureDate || r.closure_date || r.closedAt || undefined,
reason: r.conclusionRemark || r.conclusion_remark,
department: r.department,
totalLevels: r.totalLevels || 0,
completedLevels: r.summary?.approvedLevels || 0,
};
}
export function transformClosedRequests(data: any[]): ClosedRequest[] {
return data.map(transformClosedRequest);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
/**
* Content wrapper component for CreateRequest
*/
import { AnimatePresence } from 'framer-motion';
import { ReactNode } from 'react';
interface CreateRequestContentProps {
children: ReactNode;
}
export function CreateRequestContent({ children }: CreateRequestContentProps) {
return (
<div
className="flex-1 overflow-y-auto pb-24 sm:pb-4"
data-testid="create-request-content"
>
<div className="max-w-6xl mx-auto p-3 sm:p-6 pb-6 sm:pb-6">
<AnimatePresence mode="wait">{children}</AnimatePresence>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
/**
* Header component for CreateRequest
*/
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
interface CreateRequestHeaderProps {
isEditing: boolean;
currentStep: number;
totalSteps: number;
stepNames: string[];
onBack?: () => void;
}
export function CreateRequestHeader({
isEditing,
currentStep,
totalSteps,
stepNames,
onBack,
}: CreateRequestHeaderProps) {
return (
<div
className="bg-white border-b border-gray-200 px-3 sm:px-6 py-3 sm:py-4 flex-shrink-0"
data-testid="create-request-header"
>
<div className="flex items-center justify-between max-w-6xl mx-auto gap-2 sm:gap-4">
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="shrink-0 h-8 w-8 sm:h-10 sm:w-10"
data-testid="create-request-back-button"
>
<ArrowLeft className="h-4 w-4 sm:h-5 sm:w-5" />
</Button>
<div className="min-w-0">
<h1
className="text-base sm:text-xl md:text-2xl font-bold text-gray-900 truncate"
data-testid="create-request-title"
>
{isEditing ? 'Edit Draft' : 'New Request'}
</h1>
<p
className="text-xs sm:text-sm text-gray-600 hidden sm:block"
data-testid="create-request-step-info"
>
Step {currentStep} of {totalSteps}: {stepNames[currentStep - 1]}
</p>
</div>
</div>
<div
className="hidden md:flex items-center gap-4"
data-testid="create-request-progress-info"
>
<div className="text-right">
<p className="text-sm font-medium text-gray-900">
{Math.round((currentStep / totalSteps) * 100)}% Complete
</p>
<p className="text-xs text-gray-600">
{totalSteps - currentStep} steps remaining
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,92 @@
/**
* Document error modal component
*/
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertCircle } from 'lucide-react';
import { DocumentErrorModalState } from '../../types/createRequest.types';
import { DocumentPolicy } from '@/hooks/useCreateRequestForm';
interface DocumentErrorModalProps {
modal: DocumentErrorModalState;
documentPolicy: DocumentPolicy;
onClose: () => void;
}
export function DocumentErrorModal({
modal,
documentPolicy,
onClose,
}: DocumentErrorModalProps) {
return (
<Dialog
open={modal.open}
onOpenChange={(open) => !open && onClose()}
data-testid="create-request-document-error-modal"
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle
className="flex items-center gap-2"
data-testid="document-error-modal-title"
>
<AlertCircle className="w-5 h-5 text-red-600" />
Document Upload Policy Violation
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<p className="text-gray-700">
The following file(s) could not be uploaded due to policy
violations:
</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{modal.errors.map((error, index) => (
<div
key={index}
className="bg-red-50 border border-red-200 rounded-lg p-3"
data-testid={`document-error-${index}`}
>
<p className="font-medium text-red-900 text-sm">
{error.fileName}
</p>
<p className="text-xs text-red-700 mt-1">{error.reason}</p>
</div>
))}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm text-blue-800 font-semibold mb-1">
Document Policy:
</p>
<ul className="text-xs text-blue-700 space-y-1 list-disc list-inside">
<li>Maximum file size: {documentPolicy.maxFileSizeMB}MB</li>
<li>
Allowed file types:{' '}
{documentPolicy.allowedFileTypes.join(', ')}
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={onClose}
className="w-full sm:w-auto"
data-testid="document-error-modal-ok-button"
>
OK
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,138 @@
/**
* Validation error modal component
*/
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertCircle, Lightbulb } from 'lucide-react';
import { ValidationModalState } from '../../types/createRequest.types';
interface ValidationErrorModalProps {
modal: ValidationModalState;
onClose: () => void;
}
export function ValidationErrorModal({
modal,
onClose,
}: ValidationErrorModalProps) {
return (
<Dialog
open={modal.open}
onOpenChange={(open) => !open && onClose()}
data-testid="create-request-validation-modal"
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle
className="flex items-center gap-2"
data-testid="validation-modal-title"
>
{modal.type === 'self-assign' && (
<>
<AlertCircle className="w-5 h-5 text-amber-600" />
Cannot Add Yourself
</>
)}
{modal.type === 'not-found' && (
<>
<AlertCircle className="w-5 h-5 text-red-600" />
User Not Found
</>
)}
{modal.type === 'error' && (
<>
<AlertCircle className="w-5 h-5 text-red-600" />
Validation Error
</>
)}
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
{modal.type === 'self-assign' && (
<>
<p className="text-gray-700">
You cannot add yourself (<strong>{modal.email}</strong>) as
an approver.
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800">
<strong>Why?</strong> The initiator creates the request and
cannot approve their own request. Please select a different
user.
</p>
</div>
</>
)}
{modal.type === 'not-found' && (
<>
<p className="text-gray-700">
User <strong>{modal.email}</strong> was not found in the
organization directory.
</p>
<div className="bg-red-50 border border-red-200 rounded-lg p-3 space-y-2">
<p className="text-sm text-red-800 font-semibold">
Please verify:
</p>
<ul className="text-sm text-red-700 space-y-1 list-disc list-inside">
<li>Email address is spelled correctly</li>
<li>User exists in Okta/SSO system</li>
<li>User has an active account</li>
</ul>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm text-blue-800 flex items-center gap-1">
<Lightbulb className="w-4 h-4" />
<strong>Tip:</strong> Use{' '}
<span className="font-mono bg-blue-100 px-1 rounded">
@
</span>{' '}
sign to search and select users from the directory for
guaranteed results.
</p>
</div>
</>
)}
{modal.type === 'error' && (
<>
<p className="text-gray-700">
{modal.email && (
<>
Failed to validate <strong>{modal.email}</strong>.
</>
)}
{!modal.email && <>An error occurred during validation.</>}
</p>
{modal.message && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<p className="text-sm text-gray-700">{modal.message}</p>
</div>
)}
</>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={onClose}
className="w-full sm:w-auto"
data-testid="validation-modal-ok-button"
>
{modal.type === 'not-found' ? 'Fix Email' : 'OK'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,36 @@
/**
* Request template definitions
*/
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
import { Lightbulb, FileText } from 'lucide-react';
export const REQUEST_TEMPLATES: RequestTemplate[] = [
{
id: 'custom',
name: 'Custom Request',
description:
'Create a custom request for unique business needs with full flexibility to define your own workflow and requirements',
category: 'General',
icon: Lightbulb,
estimatedTime: 'Variable',
commonApprovers: [],
suggestedSLA: 3,
priority: 'medium',
fields: {},
},
{
id: 'existing-template',
name: 'Existing Template',
description:
'Use a pre-configured template with predefined approval workflows, timelines, and requirements for faster processing',
category: 'Templates',
icon: FileText,
estimatedTime: '1-2 days',
commonApprovers: ['Department Head', 'Manager'],
suggestedSLA: 2,
priority: 'medium',
fields: { timeline: true },
},
];

View File

@ -0,0 +1,183 @@
/**
* Hook for validating approvers during step transitions
*/
import { searchUsers, ensureUserExists } from '@/services/userApi';
interface ValidationResult {
success: boolean;
error?: {
type: 'self-assign' | 'not-found' | 'error';
email: string;
message: string;
};
}
/**
* Validate a single approver
*/
async function validateApprover(
approver: any,
initiatorEmail: string
): Promise<ValidationResult> {
// Check if approver email matches initiator email
if (approver.email.toLowerCase() === initiatorEmail.toLowerCase()) {
return {
success: false,
error: {
type: 'self-assign',
email: approver.email,
message: '',
},
};
}
try {
// Search for user in Okta directory
const response = await searchUsers(approver.email, 1);
const searchResults = response.data?.data || [];
if (searchResults.length === 0) {
return {
success: false,
error: {
type: 'not-found',
email: approver.email,
message: '',
},
};
}
const foundUser = searchResults[0];
if (!foundUser) {
return {
success: false,
error: {
type: 'error',
email: approver.email,
message: 'Could not retrieve user details. Please try again.',
},
};
}
// Ensure user exists in database
await ensureUserExists({
userId: foundUser.userId,
email: foundUser.email,
displayName: foundUser.displayName,
firstName: foundUser.firstName,
lastName: foundUser.lastName,
department: foundUser.department,
phone: foundUser.phone,
mobilePhone: foundUser.mobilePhone,
designation: foundUser.designation,
jobTitle: foundUser.jobTitle,
manager: foundUser.manager,
employeeId: foundUser.employeeId,
employeeNumber: foundUser.employeeNumber,
secondEmail: foundUser.secondEmail,
location: foundUser.location,
});
return {
success: true,
};
} catch (error) {
console.error(`Failed to validate approver ${approver.email}:`, error);
return {
success: false,
error: {
type: 'error',
email: approver.email,
message: 'Failed to validate user. Please try again or select a different user.',
},
};
}
}
/**
* Validate all approvers that need validation
*/
export async function validateApprovers(
approvers: any[],
initiatorEmail: string
): Promise<{
success: boolean;
validatedApprovers?: any[];
error?: {
type: 'self-assign' | 'not-found' | 'error';
email: string;
message: string;
};
}> {
const approversToValidate = approvers.filter(
(a) => a && a.email && !a.userId
);
if (approversToValidate.length === 0) {
return { success: true, validatedApprovers: approvers };
}
const updatedApprovers = [...approvers];
for (let i = 0; i < updatedApprovers.length; i++) {
const approver = updatedApprovers[i];
// Skip if already has userId (selected via @ search)
if (approver.userId || !approver.email) continue;
const validation = await validateApprover(approver, initiatorEmail);
if (!validation.success) {
return {
success: false,
error: validation.error,
};
}
// Update approver with validated data
try {
const response = await searchUsers(approver.email, 1);
const searchResults = response.data?.data || [];
const foundUser = searchResults[0];
if (foundUser) {
const dbUser = await ensureUserExists({
userId: foundUser.userId,
email: foundUser.email,
displayName: foundUser.displayName,
firstName: foundUser.firstName,
lastName: foundUser.lastName,
department: foundUser.department,
phone: foundUser.phone,
mobilePhone: foundUser.mobilePhone,
designation: foundUser.designation,
jobTitle: foundUser.jobTitle,
manager: foundUser.manager,
employeeId: foundUser.employeeId,
employeeNumber: foundUser.employeeNumber,
secondEmail: foundUser.secondEmail,
location: foundUser.location,
});
updatedApprovers[i] = {
...approver,
userId: dbUser.userId,
name: dbUser.displayName || approver.name,
department: dbUser.department || approver.department,
avatar: (dbUser.displayName || dbUser.email)
.substring(0, 2)
.toUpperCase(),
};
}
} catch (error) {
console.error(`Failed to update approver ${approver.email}:`, error);
}
}
return {
success: true,
validatedApprovers: updatedApprovers,
};
}

View File

@ -0,0 +1,157 @@
/**
* Hook for CreateRequest event handlers
*
* Contains all handler functions for:
* - Template selection
* - Step navigation
* - Document preview
*/
import { useState } from 'react';
import { RequestTemplate, FormData } from '@/hooks/useCreateRequestForm';
import { PreviewDocument } from '../types/createRequest.types';
import { getDocumentPreviewUrl } from '@/services/workflowApi';
import { validateApprovers } from './useApproverValidation';
interface UseHandlersOptions {
selectedTemplate: RequestTemplate | null;
setSelectedTemplate: (template: RequestTemplate | null) => void;
updateFormData: (field: keyof FormData, value: any) => void;
formData: FormData;
currentStep: number;
isStepValid: () => boolean;
wizardNextStep: () => void;
wizardPrevStep: () => void;
user: any;
openValidationModal: (
type: 'error' | 'self-assign' | 'not-found',
email: string,
message: string
) => void;
onSubmit?: (requestData: any) => void;
}
export function useCreateRequestHandlers({
selectedTemplate,
setSelectedTemplate,
updateFormData,
formData,
currentStep,
isStepValid,
wizardNextStep,
wizardPrevStep,
user,
openValidationModal,
onSubmit,
}: UseHandlersOptions) {
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [previewDocument, setPreviewDocument] =
useState<PreviewDocument | null>(null);
// Template selection handler
const selectTemplate = (template: RequestTemplate) => {
setSelectedTemplate(template);
updateFormData('template', template.id);
updateFormData('category', template.category);
updateFormData('priority', template.priority);
const suggestedDate = new Date();
suggestedDate.setDate(suggestedDate.getDate() + template.suggestedSLA);
updateFormData('slaEndDate', suggestedDate);
if (template.id === 'existing-template') {
setShowTemplateModal(true);
}
};
const handleTemplateSelection = (templateId: string) => {
if (onSubmit) {
onSubmit({ templateType: templateId });
}
};
// Step navigation with validation
const nextStep = async () => {
if (!isStepValid()) return;
if (window.innerWidth < 640) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// Special validation when leaving step 3 (Approval Workflow)
if (currentStep === 3) {
const initiatorEmail = (user as any)?.email?.toLowerCase() || '';
const validation = await validateApprovers(
formData.approvers,
initiatorEmail
);
if (!validation.success && validation.error) {
openValidationModal(
validation.error.type,
validation.error.email,
validation.error.message
);
return;
}
if (validation.validatedApprovers) {
updateFormData('approvers', validation.validatedApprovers);
}
}
wizardNextStep();
};
const prevStep = () => {
wizardPrevStep();
// Scroll to top on mobile to ensure content is visible
if (window.innerWidth < 640) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
// Document preview handlers
const handlePreviewDocument = (doc: any, isExisting: boolean) => {
if (isExisting) {
const docId = doc.documentId || doc.document_id || '';
setPreviewDocument({
fileName: doc.originalFileName || doc.fileName || 'Document',
fileType:
doc.fileType || doc.file_type || 'application/octet-stream',
fileUrl: getDocumentPreviewUrl(docId),
fileSize: Number(doc.fileSize || doc.file_size || 0),
documentId: docId,
});
} else {
const fileUrl = URL.createObjectURL(doc);
setPreviewDocument({
fileName: doc.name,
fileType: doc.type || 'application/octet-stream',
fileUrl: fileUrl,
fileSize: doc.size,
file: doc,
});
}
};
const closePreview = () => {
if (previewDocument?.fileUrl && previewDocument?.file) {
URL.revokeObjectURL(previewDocument.fileUrl);
}
setPreviewDocument(null);
};
return {
showTemplateModal,
setShowTemplateModal,
previewDocument,
selectTemplate,
handleTemplateSelection,
nextStep,
prevStep,
handlePreviewDocument,
closePreview,
};
}

View File

@ -0,0 +1,167 @@
/**
* Hook for handling workflow submission and draft saving
*/
import { useState } from 'react';
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
import {
buildCreatePayload,
buildUpdatePayload,
validateApproversForSubmission,
} from '../utils/payloadBuilders';
import {
createAndSubmitWorkflow,
updateAndSubmitWorkflow,
createWorkflow,
updateWorkflowRequest,
} from '../services/createRequestService';
interface UseSubmissionOptions {
formData: FormData;
selectedTemplate: RequestTemplate | null;
documents: File[];
documentsToDelete: string[];
user: any;
isEditing: boolean;
editRequestId: string;
onSubmit?: (requestData: any) => void;
}
export function useCreateRequestSubmission({
formData,
selectedTemplate,
documents,
documentsToDelete,
user,
isEditing,
editRequestId,
onSubmit,
}: UseSubmissionOptions) {
const [submitting, setSubmitting] = useState(false);
const [savingDraft, setSavingDraft] = useState(false);
const handleSubmit = async () => {
if (submitting || savingDraft) return;
// Validate approvers
const validation = validateApproversForSubmission(
formData.approvers || [],
formData.approverCount || 1
);
if (!validation.valid) {
alert(validation.message);
return;
}
setSubmitting(true);
try {
if (isEditing && editRequestId) {
// Update existing workflow
const updatePayload = buildUpdatePayload(
formData,
user,
documentsToDelete
);
await updateAndSubmitWorkflow(
editRequestId,
updatePayload,
documents,
documentsToDelete
);
onSubmit?.({
...formData,
backendId: editRequestId,
template: selectedTemplate,
});
} else {
// Create new workflow
const createPayload = buildCreatePayload(
formData,
selectedTemplate,
user
);
const result = await createAndSubmitWorkflow(createPayload, documents);
onSubmit?.({
...formData,
backendId: result.id,
template: selectedTemplate,
});
}
} catch (error) {
console.error('Failed to submit workflow:', error);
setSubmitting(false);
}
};
const handleSaveDraft = async () => {
// Validate minimum required fields
if (
!selectedTemplate ||
!formData.title.trim() ||
!formData.description.trim() ||
!formData.priority
) {
return;
}
if (submitting || savingDraft) return;
setSavingDraft(true);
try {
if (isEditing && editRequestId) {
// Update existing draft
const updatePayload = buildUpdatePayload(
formData,
user,
documentsToDelete
);
await updateWorkflowRequest(
editRequestId,
updatePayload,
documents,
documentsToDelete
);
onSubmit?.({
...formData,
backendId: editRequestId,
template: selectedTemplate,
});
} else {
// Create new draft
const createPayload = buildCreatePayload(
formData,
selectedTemplate,
user
);
const result = await createWorkflow(createPayload, documents);
onSubmit?.({
...formData,
backendId: result.id,
template: selectedTemplate,
});
}
} catch (error) {
console.error('Failed to save draft:', error);
setSavingDraft(false);
}
};
return {
submitting,
savingDraft,
handleSubmit,
handleSaveDraft,
};
}

View File

@ -0,0 +1,92 @@
/**
* Hook for managing modal states in CreateRequest
*/
import { useState } from 'react';
import {
ValidationModalState,
PolicyViolationModalState,
DocumentErrorModalState,
} from '../types/createRequest.types';
export function useRequestModals() {
const [validationModal, setValidationModal] = useState<ValidationModalState>({
open: false,
type: 'error',
email: '',
message: '',
});
const [policyViolationModal, setPolicyViolationModal] =
useState<PolicyViolationModalState>({
open: false,
violations: [],
});
const [documentErrorModal, setDocumentErrorModal] =
useState<DocumentErrorModalState>({
open: false,
errors: [],
});
const openValidationModal = (
type: 'error' | 'self-assign' | 'not-found',
email: string,
message: string = ''
) => {
setValidationModal({
open: true,
type,
email,
message,
});
};
const closeValidationModal = () => {
setValidationModal((prev) => ({ ...prev, open: false }));
};
const openPolicyViolationModal = (
violations: Array<{
type: string;
message: string;
currentValue?: number;
maxValue?: number;
}>
) => {
setPolicyViolationModal({
open: true,
violations,
});
};
const closePolicyViolationModal = () => {
setPolicyViolationModal({ open: false, violations: [] });
};
const openDocumentErrorModal = (
errors: Array<{ fileName: string; reason: string }>
) => {
setDocumentErrorModal({
open: true,
errors,
});
};
const closeDocumentErrorModal = () => {
setDocumentErrorModal({ open: false, errors: [] });
};
return {
validationModal,
policyViolationModal,
documentErrorModal,
openValidationModal,
closeValidationModal,
openPolicyViolationModal,
closePolicyViolationModal,
openDocumentErrorModal,
closeDocumentErrorModal,
};
}

View File

@ -0,0 +1,86 @@
/**
* Service layer for CreateRequest API operations
*/
import {
createWorkflowMultipart,
submitWorkflow,
updateWorkflow,
updateWorkflowMultipart,
} from '@/services/workflowApi';
import {
CreateWorkflowPayload,
UpdateWorkflowPayload,
} from '../types/createRequest.types';
/**
* Create a new workflow
*/
export async function createWorkflow(
payload: CreateWorkflowPayload,
documents: File[]
): Promise<{ id: string }> {
const res = await createWorkflowMultipart(
payload as any,
documents || [],
'SUPPORTING'
);
return { id: (res as any).id };
}
/**
* Update an existing workflow
*/
export async function updateWorkflowRequest(
requestId: string,
payload: UpdateWorkflowPayload,
documents: File[],
documentsToDelete: string[]
): Promise<void> {
const hasNewFiles = documents && documents.length > 0;
const hasDeletions = documentsToDelete.length > 0;
if (hasNewFiles || hasDeletions) {
await updateWorkflowMultipart(
requestId,
payload,
documents || [],
documentsToDelete
);
} else {
await updateWorkflow(requestId, payload);
}
}
/**
* Submit a workflow
*/
export async function submitWorkflowRequest(requestId: string): Promise<void> {
await submitWorkflow(requestId);
}
/**
* Create and submit a workflow in one operation
*/
export async function createAndSubmitWorkflow(
payload: CreateWorkflowPayload,
documents: File[]
): Promise<{ id: string }> {
const result = await createWorkflow(payload, documents);
await submitWorkflowRequest(result.id);
return result;
}
/**
* Update and submit a workflow in one operation
*/
export async function updateAndSubmitWorkflow(
requestId: string,
payload: UpdateWorkflowPayload,
documents: File[],
documentsToDelete: string[]
): Promise<void> {
await updateWorkflowRequest(requestId, payload, documents, documentsToDelete);
await submitWorkflowRequest(requestId);
}

View File

@ -0,0 +1,124 @@
/**
* Type definitions for CreateRequest module
*/
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
export interface Participant {
userId?: string;
userEmail: string;
userName: string;
participantType: 'INITIATOR' | 'APPROVER' | 'SPECTATOR';
canComment: boolean;
canViewDocuments: boolean;
canDownloadDocuments: boolean;
notificationEnabled: boolean;
addedBy?: string;
}
export interface ApprovalLevel {
levelNumber: number;
levelName: string;
approverId: string;
approverEmail: string;
approverName: string;
tatHours: number;
isFinalApprover: boolean;
}
export interface Approver {
userId?: string;
email: string;
name?: string;
tat?: number | string;
tatType?: 'hours' | 'days';
level?: number;
}
export interface Spectator {
id?: string;
userId?: string;
email: string;
name: string;
}
export interface CreateWorkflowPayload {
templateId: string | null;
templateType: 'CUSTOM' | 'TEMPLATE';
title: string;
description: string;
priorityUi: 'express' | 'standard';
approverCount: number;
approvers: Array<{
userId: string;
email: string;
name?: string;
tat: number | string;
tatType: 'hours' | 'days';
}>;
spectators: Array<{
userId: string;
name: string;
email: string;
}>;
ccList: Array<{
id?: string;
name: string;
email: string;
}>;
participants: Participant[];
}
export interface UpdateWorkflowPayload {
title: string;
description: string;
priority: 'EXPRESS' | 'STANDARD';
approvalLevels: ApprovalLevel[];
participants: Participant[];
deleteDocumentIds?: string[];
}
export interface ValidationModalState {
open: boolean;
type: 'error' | 'self-assign' | 'not-found';
email: string;
message: string;
}
export interface PolicyViolationModalState {
open: boolean;
violations: Array<{
type: string;
message: string;
currentValue?: number;
maxValue?: number;
}>;
}
export interface DocumentErrorModalState {
open: boolean;
errors: Array<{
fileName: string;
reason: string;
}>;
}
export interface PreviewDocument {
fileName: string;
fileType: string;
fileUrl: string;
fileSize?: number;
file?: File;
documentId?: string;
}
export interface SubmissionContext {
formData: FormData;
selectedTemplate: RequestTemplate | null;
documents: File[];
documentsToDelete: string[];
user: any;
isEditing: boolean;
editRequestId: string;
}

View File

@ -0,0 +1,51 @@
/**
* Utility functions for building approval levels
*/
import { ApprovalLevel, Approver } from '../types/createRequest.types';
/**
* Calculate TAT in hours
*/
export function calculateTatHours(tat: number | string, tatType: 'hours' | 'days'): number {
const tatValue = typeof tat === 'number' ? tat : parseInt(String(tat)) || 0;
return tatType === 'days' ? tatValue * 24 : tatValue || 24;
}
/**
* Build a single approval level
*/
export function buildApproverLevel(
approver: Approver,
levelNumber: number,
isFinalApprover: boolean
): ApprovalLevel {
const tatHours = calculateTatHours(approver.tat || 24, approver.tatType || 'hours');
return {
levelNumber,
levelName: `Level ${levelNumber}`,
approverId: approver.userId || '',
approverEmail: approver.email || '',
approverName: approver.name || approver.email?.split('@')[0] || `Approver ${levelNumber}`,
tatHours,
isFinalApprover,
};
}
/**
* Build all approval levels from approvers array
*/
export function buildApprovalLevels(
approvers: Approver[],
approverCount: number
): ApprovalLevel[] {
return approvers
.slice(0, approverCount)
.map((approver, index) => {
const levelNumber = index + 1;
const isFinalApprover = levelNumber === approverCount;
return buildApproverLevel(approver, levelNumber, isFinalApprover);
});
}

View File

@ -0,0 +1,110 @@
/**
* Utility functions for mapping users to participants
*/
import { Participant, Approver, Spectator } from '../types/createRequest.types';
/**
* Map initiator to participant
*/
export function mapInitiatorToParticipant(user: any): Participant {
const initiatorId = user?.userId || '';
const initiatorEmail = (user as any)?.email || '';
const initiatorName = (user as any)?.displayName ||
(user as any)?.name ||
initiatorEmail.split('@')[0] ||
'Initiator';
return {
userId: initiatorId,
userEmail: initiatorEmail,
userName: initiatorName,
participantType: 'INITIATOR',
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
notificationEnabled: true,
addedBy: initiatorId,
};
}
/**
* Map approvers to participants
*/
export function mapApproversToParticipants(
approvers: Approver[],
initiatorId: string
): Participant[] {
return approvers
.filter((a) => a?.email)
.map((a) => ({
userId: a.userId || undefined,
userEmail: a.email,
userName: a.name || a.email.split('@')[0] || 'Approver',
participantType: 'APPROVER' as const,
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
notificationEnabled: true,
addedBy: initiatorId,
}));
}
/**
* Map spectators to participants
*/
export function mapSpectatorsToParticipants(
spectators: Spectator[],
initiatorId: string
): Participant[] {
return spectators
.filter((s) => s?.email)
.map((s) => ({
userId: s.id || s.userId || undefined,
userEmail: s.email,
userName: s.name || s.email.split('@')[0] || 'Spectator',
participantType: 'SPECTATOR' as const,
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
notificationEnabled: true,
addedBy: initiatorId,
}));
}
/**
* Build complete participants array
*/
export function buildParticipantsArray(
user: any,
approvers: Approver[],
spectators: Spectator[]
): Participant[] {
const initiator = mapInitiatorToParticipant(user);
const initiatorId = user?.userId || '';
const approverParticipants = mapApproversToParticipants(approvers, initiatorId);
const spectatorParticipants = mapSpectatorsToParticipants(spectators, initiatorId);
return [initiator, ...approverParticipants, ...spectatorParticipants];
}
/**
* Filter duplicate participants (for edit mode)
*/
export function filterDuplicateParticipants(
approvers: Approver[],
spectators: Spectator[]
): Spectator[] {
const approverIds = approvers.map((a) => a?.userId).filter(Boolean);
const approverEmails = approvers
.map((a) => a?.email?.toLowerCase?.())
.filter(Boolean);
return spectators.filter((s) => {
const isDuplicateId = approverIds.includes(s?.id);
const isDuplicateEmail = approverEmails.includes((s?.email || '').toLowerCase());
return !isDuplicateId && !isDuplicateEmail;
});
}

View File

@ -0,0 +1,114 @@
/**
* Utility functions for building API payloads
*/
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
import {
CreateWorkflowPayload,
UpdateWorkflowPayload,
} from '../types/createRequest.types';
import {
buildParticipantsArray,
filterDuplicateParticipants,
} from './participantMappers';
import { buildApprovalLevels } from './approvalLevelBuilders';
/**
* Build create workflow payload
*/
export function buildCreatePayload(
formData: FormData,
selectedTemplate: RequestTemplate | null,
user: any
): CreateWorkflowPayload {
const participants = buildParticipantsArray(
user,
formData.approvers || [],
formData.spectators || []
);
const filteredSpectators = filterDuplicateParticipants(
formData.approvers || [],
formData.spectators || []
);
return {
templateId: selectedTemplate?.id || null,
templateType: selectedTemplate?.id === 'custom' ? 'CUSTOM' : 'TEMPLATE',
title: formData.title,
description: formData.description,
priorityUi: formData.priority === 'express' ? 'express' : 'standard',
approverCount: formData.approverCount || 1,
approvers: (formData.approvers || []).map((a: any) => ({
userId: a?.userId || '',
email: a?.email || '',
name: a?.name,
tat: a?.tat || '',
tatType: a?.tatType || 'hours',
})),
spectators: filteredSpectators.map((s) => ({
userId: s?.id || '',
name: s?.name || s?.email?.split('@')?.[0] || 'Spectator',
email: s?.email || '',
})),
ccList: (formData.ccList || []).map((c: any) => ({
id: c?.id,
name: c?.name || c?.email?.split('@')?.[0] || 'CC',
email: c?.email || '',
})),
participants,
};
}
/**
* Build update workflow payload
*/
export function buildUpdatePayload(
formData: FormData,
user: any,
documentsToDelete: string[]
): UpdateWorkflowPayload {
const approvalLevels = buildApprovalLevels(
formData.approvers || [],
formData.approverCount || 1
);
const participants = buildParticipantsArray(
user,
(formData.approvers || []).filter((a: any) => a?.email && a?.userId),
(formData.spectators || []).filter((s: any) => s?.email)
);
return {
title: formData.title,
description: formData.description,
priority: formData.priority === 'express' ? 'EXPRESS' : 'STANDARD',
approvalLevels,
participants,
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
};
}
/**
* Validate approvers before submission
*/
export function validateApproversForSubmission(
approvers: any[],
approverCount: number
): { valid: boolean; message?: string } {
const approversToCheck = approvers.slice(0, approverCount);
const hasMissingIds = approversToCheck.some(
(a: any) => !a?.userId || !a?.email
);
if (hasMissingIds) {
return {
valid: false,
message: 'Please select approvers using @ search so we can capture their user IDs.',
};
}
return { valid: true };
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,202 @@
/**
* Dashboard Filters Bar Component
* Handles date range filtering and refresh functionality
*/
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Filter, Calendar as CalendarIcon, RefreshCw } from 'lucide-react';
import { format } from 'date-fns';
import { DateRange } from '@/services/dashboard.service';
interface DashboardFiltersBarProps {
isAdmin: boolean;
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
refreshing: boolean;
onDateRangeChange: (value: string) => void;
onCustomStartDateChange: (date: Date | undefined) => void;
onCustomEndDateChange: (date: Date | undefined) => void;
onShowCustomDatePickerChange: (show: boolean) => void;
onApplyCustomDate: () => void;
onResetCustomDates: () => void;
onRefresh: () => void;
}
export function DashboardFiltersBar({
isAdmin,
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
refreshing,
onDateRangeChange,
onCustomStartDateChange,
onCustomEndDateChange,
onShowCustomDatePickerChange,
onApplyCustomDate,
onResetCustomDates,
onRefresh,
}: DashboardFiltersBarProps) {
return (
<Card className="shadow-md" data-testid="dashboard-filters-bar">
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-muted-foreground" />
<h3 className="font-semibold text-gray-900">Filters</h3>
{isAdmin && (
<Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200" data-testid="management-badge">
Management View
</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-3 w-full sm:w-auto">
{/* Date Range Filter */}
{isAdmin ? (
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={dateRange} onValueChange={onDateRangeChange} data-testid="date-range-select">
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{/* Custom Date Range Picker */}
{dateRange === 'custom' && (
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2" data-testid="custom-date-trigger">
<CalendarIcon className="w-4 h-4" />
{customStartDate && customEndDate
? `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start" sideOffset={8} data-testid="custom-date-picker">
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date" className="text-sm font-medium">Start Date</Label>
<Input
id="start-date"
type="date"
value={customStartDate ? format(customStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
onCustomStartDateChange(date);
if (customEndDate && date > customEndDate) {
onCustomEndDateChange(date);
}
} else {
onCustomStartDateChange(undefined);
}
}}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
data-testid="start-date-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date" className="text-sm font-medium">End Date</Label>
<Input
id="end-date"
type="date"
value={customEndDate ? format(customEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
onCustomEndDateChange(date);
if (customStartDate && date < customStartDate) {
onCustomStartDateChange(date);
}
} else {
onCustomEndDateChange(undefined);
}
}}
min={customStartDate ? format(customStartDate, 'yyyy-MM-dd') : undefined}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
data-testid="end-date-input"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={onApplyCustomDate}
disabled={!customStartDate || !customEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
data-testid="apply-custom-date"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={onResetCustomDates}
data-testid="cancel-custom-date"
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
) : (
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={dateRange} onValueChange={(value) => onDateRangeChange(value)} data-testid="date-range-select-user">
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
</SelectContent>
</Select>
</div>
)}
<Separator orientation="vertical" className="h-6 hidden sm:block" />
{/* Refresh Button */}
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={refreshing}
className="gap-2 min-w-[110px]"
data-testid="refresh-button"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
<span className="inline-block w-[60px] text-center">
{refreshing ? 'Refreshing...' : 'Refresh'}
</span>
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,70 @@
/**
* Dashboard Hero Section Component
* Displays the main dashboard header with title and quick actions
*/
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Shield, Zap, Activity } from 'lucide-react';
import { QuickAction } from '../utils/dashboardNavigation';
interface DashboardHeroProps {
isAdmin: boolean;
quickActions: QuickAction[];
}
export function DashboardHero({ isAdmin, quickActions }: DashboardHeroProps) {
return (
<Card className="relative overflow-hidden shadow-xl border-0" data-testid="dashboard-hero">
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"></div>
<CardContent className="relative z-10 p-4 sm:p-6 lg:p-12">
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 sm:gap-6">
<div className="text-white w-full lg:w-auto">
<div className="flex items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
<div className="w-12 h-12 sm:w-16 sm:h-16 bg-yellow-400 rounded-xl flex items-center justify-center shadow-lg" data-testid="hero-icon">
<Shield className="w-6 h-6 sm:w-8 sm:h-8 text-slate-900" />
</div>
<div>
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold mb-1 sm:mb-2 text-white" data-testid="hero-title">
{isAdmin ? 'Management Dashboard' : 'My Dashboard'}
</h1>
<p className="text-sm sm:text-lg lg:text-xl text-gray-200" data-testid="hero-subtitle">
{isAdmin ? 'Organization-wide analytics and insights' : 'Track your requests and approvals'}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 sm:gap-4 mt-4 sm:mt-8" data-testid="quick-actions">
{quickActions.map((action, index) => (
<Button
key={index}
onClick={action.action}
className={`${action.color} text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200`}
size={window.innerWidth < 640 ? "sm" : "lg"}
data-testid={`quick-action-${action.label.toLowerCase().replace(/\s+/g, '-')}`}
>
<action.icon className="w-4 h-4 sm:w-5 sm:h-5 mr-1 sm:mr-2" />
{action.label}
</Button>
))}
</div>
</div>
{/* Decorative Elements */}
<div className="hidden lg:flex items-center gap-4" data-testid="hero-decorations">
<div className="w-24 h-24 bg-yellow-400/20 rounded-full flex items-center justify-center">
<div className="w-16 h-16 bg-yellow-400/30 rounded-full flex items-center justify-center">
<Zap className="w-8 h-8 text-yellow-400" />
</div>
</div>
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center">
<Activity className="w-6 h-6 text-white/80" />
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,92 @@
/**
* AI Remark Utilization Report Component
* Displays AI remark usage statistics and trends
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Zap } from 'lucide-react';
import { AIRemarkUtilization } from '@/services/dashboard.service';
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
interface AIRemarkUtilizationReportProps {
aiRemarkUtilization: AIRemarkUtilization | null;
}
export function AIRemarkUtilizationReport({ aiRemarkUtilization }: AIRemarkUtilizationReportProps) {
if (!aiRemarkUtilization) return null;
return (
<Card className="shadow-md hover:shadow-lg transition-shadow" data-testid="ai-remark-utilization-report">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="bg-purple-50 p-2 sm:p-3 rounded-lg">
<Zap className="h-5 w-5 sm:h-6 sm:w-6 text-purple-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg lg:text-xl">AI Remark Utilization Report</CardTitle>
<CardDescription className="text-xs sm:text-sm">AI-generated remarks usage and edits</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{/* Summary Cards */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-purple-50 p-4 rounded-lg text-center" data-testid="ai-total-usage">
<div className="text-sm text-gray-600 mb-1">Total Usage</div>
<div className="text-2xl font-bold text-gray-900">{aiRemarkUtilization.totalUsage}</div>
</div>
<div className="bg-pink-50 p-4 rounded-lg text-center" data-testid="ai-total-edits">
<div className="text-sm text-gray-600 mb-1">Total Edits</div>
<div className="text-2xl font-bold text-gray-900">{aiRemarkUtilization.totalEdits}</div>
</div>
<div className="bg-purple-50 p-4 rounded-lg text-center" data-testid="ai-edit-rate">
<div className="text-sm text-gray-600 mb-1">Edit Rate</div>
<div className="text-2xl font-bold text-gray-900">{aiRemarkUtilization.editRate}%</div>
</div>
</div>
{/* Monthly Trends Line Graph */}
{aiRemarkUtilization.monthlyTrends && aiRemarkUtilization.monthlyTrends.length > 0 && (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={aiRemarkUtilization.monthlyTrends}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="month" stroke="#999" tick={{ fontSize: 11 }} />
<YAxis stroke="#999" tick={{ fontSize: 11 }} />
<Tooltip
contentStyle={{
backgroundColor: '#ffffff',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '12px',
}}
/>
<Legend
verticalAlign="bottom"
height={36}
iconType="circle"
wrapperStyle={{ fontSize: '12px', paddingTop: '10px' }}
/>
<Line
type="monotone"
dataKey="aiUsage"
stroke="#8b5cf6"
strokeWidth={2}
name="AI Usage"
dot={{ fill: '#8b5cf6', r: 4 }}
/>
<Line
type="monotone"
dataKey="manualEdits"
stroke="#ec4899"
strokeWidth={2}
name="Manual Edits"
dot={{ fill: '#ec4899', r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,213 @@
/**
* Admin Analytics Section Component
* Displays Active Levels, Collaboration, and Department Stats
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Activity, MessageSquare, PieChart, Download } from 'lucide-react';
import { DashboardKPIs, DepartmentStats, UpcomingDeadline, DateRange } from '@/services/dashboard.service';
import { CriticalRequest, CriticalAlertData } from '@/services/dashboard.service';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
import { KPIClickFilters } from '../../components/types/dashboard.types';
interface AdminAnalyticsSectionProps {
kpis: DashboardKPIs | null;
upcomingDeadlines: UpcomingDeadline[];
criticalRequests: (CriticalRequest | CriticalAlertData)[];
departmentStats: DepartmentStats[];
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
exportingDeptStats: boolean;
onKPIClick: (filters: KPIClickFilters) => void;
onExportDepartmentStats: (dateRange: DateRange, startDate?: Date, endDate?: Date) => void;
}
export function AdminAnalyticsSection({
kpis,
upcomingDeadlines,
criticalRequests,
departmentStats,
dateRange,
customStartDate,
customEndDate,
exportingDeptStats,
onKPIClick,
onExportDepartmentStats,
}: AdminAnalyticsSectionProps) {
if (!kpis) return null;
const getFilterParams = () => ({
dateRange,
startDate: customStartDate,
endDate: customEndDate,
});
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6" data-testid="admin-analytics-section">
{/* Left Side: Active Levels and Collaboration stacked - 1/3 width */}
<div className="flex flex-col gap-4 sm:gap-6 lg:col-span-1">
{/* Active Levels */}
<Card className="shadow-md hover:shadow-lg transition-shadow flex-1" data-testid="active-levels-card">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="p-2 bg-blue-100 rounded-lg">
<Activity className="h-4 w-4 text-blue-600" />
</div>
<CardTitle className="text-sm">Active Levels</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-baseline gap-2">
<span className="text-2xl sm:text-3xl font-bold text-blue-600">{upcomingDeadlines.length}</span>
<span className="text-xs text-muted-foreground">levels</span>
</div>
<Separator />
<div className="space-y-1 text-xs">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Avg Time/Level</span>
<span className="font-semibold">
{upcomingDeadlines.length > 0
? (kpis.tatEfficiency.avgCycleTimeHours / upcomingDeadlines.length).toFixed(1)
: '0'}
h
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">At Risk</span>
<span className="font-semibold text-red-600">
{criticalRequests.filter((r) => r.breachCount > 0).length}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Collaboration */}
<Card className="shadow-md hover:shadow-lg transition-shadow flex-1" data-testid="collaboration-card">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="p-2 bg-indigo-100 rounded-lg">
<MessageSquare className="h-4 w-4 text-indigo-600" />
</div>
<CardTitle className="text-sm">Collaboration</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div className="text-center p-2 bg-indigo-50 rounded">
<p className="text-xs text-muted-foreground">Notes</p>
<p className="text-lg sm:text-xl font-bold text-indigo-600">{kpis.engagement.workNotesAdded}</p>
</div>
<div className="text-center p-2 bg-purple-50 rounded">
<p className="text-xs text-muted-foreground">Files</p>
<p className="text-lg sm:text-xl font-bold text-purple-600">
{kpis.engagement.attachmentsUploaded}
</p>
</div>
</div>
<Separator />
<div className="text-xs text-muted-foreground text-center">
{kpis.requestVolume.totalRequests > 0
? (kpis.engagement.workNotesAdded / kpis.requestVolume.totalRequests).toFixed(1)
: '0'}{' '}
avg notes per request
</div>
</div>
</CardContent>
</Card>
</div>
{/* Right Side: Department-wise Workflow Summary - Full height - 2/3 width */}
{departmentStats.length > 0 && (
<Card className="shadow-md hover:shadow-lg transition-shadow lg:col-span-2" data-testid="department-stats-card">
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="bg-blue-50 p-2 sm:p-3 rounded-lg">
<PieChart className="h-5 w-5 sm:h-6 sm:w-6 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg lg:text-xl">Department-wise Workflow Summary</CardTitle>
<CardDescription className="text-xs sm:text-sm">
Workflow distribution across departments
</CardDescription>
</div>
</div>
<Button
variant="outline"
size="sm"
className="gap-2 self-start sm:self-auto"
onClick={() => onExportDepartmentStats(dateRange, customStartDate, customEndDate)}
disabled={exportingDeptStats}
data-testid="export-dept-stats-button"
>
<Download className={`w-3 h-3 sm:w-4 sm:h-4 ${exportingDeptStats ? 'animate-pulse' : ''}`} />
<span className="text-xs sm:text-sm">{exportingDeptStats ? 'Exporting...' : 'Export'}</span>
</Button>
</div>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={departmentStats}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey="department"
stroke="#999"
tick={(props) => {
const { x, y, payload } = props;
return (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={16}
textAnchor="middle"
fill="#999"
fontSize={11}
className="cursor-pointer hover:text-blue-600 hover:underline"
onClick={() => {
onKPIClick({
...getFilterParams(),
department: payload.value,
});
}}
>
{payload.value}
</text>
</g>
);
}}
/>
<YAxis stroke="#999" tick={{ fontSize: 11 }} />
<Tooltip
contentStyle={{
backgroundColor: '#ffffff',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '12px',
}}
/>
<Legend
verticalAlign="bottom"
height={36}
iconType="square"
wrapperStyle={{ fontSize: '12px', paddingTop: '10px' }}
/>
<Bar dataKey="approved" fill="#10b981" name="Approved" />
<Bar dataKey="inProgress" fill="#f59e0b" name="Pending" />
<Bar dataKey="rejected" fill="#ef4444" name="Rejected" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,191 @@
/**
* Admin KPI Cards Section Component
* Displays comprehensive KPI cards for admin users
*/
import { FileText, Clock, Target } from 'lucide-react';
import { KPICard } from '@/components/dashboard/KPICard';
import { StatCard } from '@/components/dashboard/StatCard';
import { Progress } from '@/components/ui/progress';
import { DashboardKPIs, DateRange, PriorityDistribution } from '@/services/dashboard.service';
import { formatHoursMinutes } from '@/utils/slaTracker';
import { KPIClickFilters } from '../../components/types/dashboard.types';
interface AdminKPICardsProps {
kpis: DashboardKPIs | null;
priorityDistribution: PriorityDistribution[];
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
onKPIClick: (filters: KPIClickFilters) => void;
}
export function AdminKPICards({
kpis,
priorityDistribution,
dateRange,
customStartDate,
customEndDate,
onKPIClick,
}: AdminKPICardsProps) {
const getFilterParams = () => ({
dateRange,
startDate: customStartDate,
endDate: customEndDate,
});
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6" data-testid="admin-kpi-cards">
{/* Total Requests */}
<KPICard
title="Total Requests"
value={kpis?.requestVolume.totalRequests || 0}
icon={FileText}
iconBgColor="bg-blue-50"
iconColor="text-blue-600"
testId="kpi-total-requests"
onClick={() => onKPIClick(getFilterParams())}
>
{/* Row 1: Approved and Rejected */}
<div className="grid grid-cols-2 gap-2 mb-2">
<StatCard
label="Approved"
value={kpis?.requestVolume.approvedRequests || 0}
bgColor="bg-green-50"
textColor="text-green-600"
testId="stat-approved"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'approved' });
}}
/>
<StatCard
label="Rejected"
value={kpis?.requestVolume.rejectedRequests || 0}
bgColor="bg-red-50"
textColor="text-red-600"
testId="stat-rejected"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'rejected' });
}}
/>
</div>
{/* Row 2: Pending and Closed */}
<div className="grid grid-cols-2 gap-2">
<StatCard
label="Pending"
value={kpis?.requestVolume.openRequests || 0}
bgColor="bg-orange-50"
textColor="text-orange-600"
testId="stat-pending"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'pending' });
}}
/>
<StatCard
label="Closed"
value={kpis?.requestVolume.closedRequests || 0}
bgColor="bg-gray-50"
textColor="text-gray-600"
testId="stat-closed"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'closed' });
}}
/>
</div>
</KPICard>
{/* SLA Compliance */}
<KPICard
title="SLA Compliance"
value={`${kpis?.tatEfficiency.avgTATCompliance || 0}%`}
icon={Target}
iconBgColor="bg-green-50"
iconColor="text-green-600"
testId="kpi-sla-compliance"
onClick={() => onKPIClick(getFilterParams())}
>
<div className="flex flex-col flex-1">
<Progress
value={kpis?.tatEfficiency.avgTATCompliance || 0}
className="h-2 mb-2"
data-testid="sla-progress-bar"
/>
<div className="grid grid-cols-2 gap-2 mt-auto">
<StatCard
label="Compliant"
value={kpis?.tatEfficiency.compliantWorkflows || 0}
bgColor="bg-green-50"
textColor="text-green-600"
testId="stat-compliant"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), slaCompliance: 'compliant' });
}}
/>
<StatCard
label="Breached"
value={kpis?.tatEfficiency.delayedWorkflows || 0}
bgColor="bg-red-50"
textColor="text-red-600"
testId="stat-breached"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), slaCompliance: 'breached' });
}}
/>
</div>
</div>
</KPICard>
{/* Avg Cycle Time */}
<KPICard
title="Avg Cycle Time"
value={kpis?.tatEfficiency.avgCycleTimeHours ? formatHoursMinutes(kpis.tatEfficiency.avgCycleTimeHours) : '0h'}
icon={Clock}
iconBgColor="bg-purple-50"
iconColor="text-purple-600"
subtitle={`${kpis?.tatEfficiency.avgCycleTimeDays.toFixed(1) || 0} working days`}
testId="kpi-avg-cycle-time"
onClick={() => onKPIClick(getFilterParams())}
>
<div className="grid grid-cols-2 gap-2 mt-auto">
<StatCard
label="Express"
value={(() => {
const express = priorityDistribution.find((p) => p.priority === 'express');
const hours = express ? Number(express.avgCycleTimeHours) : 0;
return hours > 0 ? formatHoursMinutes(hours) : 'N/A';
})()}
bgColor="bg-orange-50"
textColor="text-orange-600"
testId="stat-express-time"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), priority: 'express' });
}}
/>
<StatCard
label="Standard"
value={(() => {
const standard = priorityDistribution.find((p) => p.priority === 'standard');
const hours = standard ? Number(standard.avgCycleTimeHours) : 0;
return hours > 0 ? formatHoursMinutes(hours) : 'N/A';
})()}
bgColor="bg-blue-50"
textColor="text-blue-600"
testId="stat-standard-time"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), priority: 'standard' });
}}
/>
</div>
</KPICard>
</div>
);
}

View File

@ -0,0 +1,154 @@
/**
* Approver Performance Report Component
* Displays approver performance metrics with pagination
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Users, RefreshCw, Download } from 'lucide-react';
import { ApproverPerformance, DateRange } from '@/services/dashboard.service';
import { Pagination } from '@/components/common/Pagination';
import { formatHoursMinutes } from '@/utils/slaTracker';
import { getTATColorClass } from '../../utils/dashboardCalculations';
interface ApproverPerformanceReportProps {
approverPerformance: ApproverPerformance[];
pagination: {
page: number;
totalPages: number;
totalRecords: number;
};
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
loading: boolean;
exportingApproverPerformance: boolean;
onPageChange: (page: number) => void;
onExport: (dateRange: DateRange, startDate?: Date, endDate?: Date) => void;
onNavigate?: (path: string) => void;
}
export function ApproverPerformanceReport({
approverPerformance,
pagination,
dateRange,
customStartDate,
customEndDate,
loading,
exportingApproverPerformance,
onPageChange,
onExport,
onNavigate,
}: ApproverPerformanceReportProps) {
if (approverPerformance.length === 0) return null;
return (
<Card className="shadow-md hover:shadow-lg transition-shadow" data-testid="approver-performance-report">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="bg-yellow-50 p-2 sm:p-3 rounded-lg">
<Users className="h-5 w-5 sm:h-6 sm:w-6 text-yellow-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg lg:text-xl">Approver Performance Report</CardTitle>
<CardDescription className="text-xs sm:text-sm">Response time & TAT compliance tracking</CardDescription>
</div>
</div>
<Button
onClick={() => onExport(dateRange, customStartDate, customEndDate)}
disabled={exportingApproverPerformance || loading}
className="bg-re-green hover:bg-re-green/90 text-white shrink-0"
size="sm"
data-testid="export-approver-performance-button"
>
{exportingApproverPerformance ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Exporting...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Export
</>
)}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{approverPerformance.map((approver, idx) => {
const tatPercent = approver.tatCompliancePercent;
return (
<div
key={idx}
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => {
const params = new URLSearchParams();
params.set('approverId', approver.approverId);
params.set('approverName', approver.approverName);
params.set('dateRange', dateRange);
if (dateRange === 'custom' && customStartDate && customEndDate) {
params.set('startDate', customStartDate.toISOString());
params.set('endDate', customEndDate.toISOString());
}
onNavigate?.(`/approver-performance?${params.toString()}`);
}}
data-testid={`approver-item-${approver.approverId}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-orange-500 rounded-full flex items-center justify-center text-white font-semibold text-sm flex-shrink-0">
{idx + 1}
</div>
<div className="min-w-0">
<div className="font-medium text-gray-900 truncate">{approver.approverName}</div>
<div className="text-xs text-gray-500">{approver.totalApproved} requests approved</div>
</div>
</div>
<span
className={`px-2 py-1 rounded text-xs font-medium ${getTATColorClass(tatPercent)} flex-shrink-0`}
>
{tatPercent}% TAT
</span>
</div>
<div className="grid grid-cols-2 gap-4 mt-3">
<div>
<div className="text-xs text-gray-500">Avg Response</div>
<div className="text-sm font-medium text-gray-900">
{formatHoursMinutes(approver.avgResponseHours)}
</div>
</div>
<div>
<div className="text-xs text-gray-500">Pending</div>
<div className="text-sm font-medium text-gray-900">
<span className="bg-gray-500 text-white px-2 py-1 rounded text-xs">
{approver.pendingCount}
</span>
</div>
</div>
</div>
</div>
);
})}
</div>
{/* Pagination for Approver Performance */}
<div className="mt-4">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalRecords={pagination.totalRecords}
itemsPerPage={10}
onPageChange={onPageChange}
itemLabel="approvers"
testIdPrefix="dashboard-approver-pagination"
/>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,134 @@
/**
* Critical Alerts Section Component
* Displays critical alerts with pagination
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Flame, CheckCircle, ArrowRight } from 'lucide-react';
import { CriticalAlertCard } from '@/components/dashboard/CriticalAlertCard';
import { CriticalRequest, CriticalAlertData } from '@/services/dashboard.service';
import { getPageNumbers } from '../../utils/dashboardCalculations';
interface CriticalAlertsSectionProps {
isAdmin: boolean;
breachedRequests: (CriticalRequest | CriticalAlertData)[];
pagination: {
page: number;
totalPages: number;
totalRecords: number;
};
onPageChange: (page: number) => void;
onNavigate?: (path: string) => void;
}
export function CriticalAlertsSection({
isAdmin,
breachedRequests,
pagination,
onPageChange,
onNavigate,
}: CriticalAlertsSectionProps) {
return (
<Card
className="lg:col-span-2 shadow-md hover:shadow-lg transition-shadow flex flex-col overflow-hidden"
style={{ height: '100%' }}
data-testid="critical-alerts-section"
>
<CardHeader className="pb-3 sm:pb-4 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-2 sm:p-3 bg-red-100 rounded-lg">
<Flame className="h-4 w-4 sm:h-5 sm:w-5 text-red-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg text-gray-900">Critical Alerts</CardTitle>
<CardDescription className="text-xs sm:text-sm text-gray-600">
{isAdmin ? 'Organization-wide' : 'My requests'}
</CardDescription>
</div>
</div>
<Badge
variant="destructive"
className={`font-semibold text-xs sm:text-sm ${pagination.totalRecords > 0 ? 'animate-pulse' : ''}`}
data-testid="critical-count-badge"
>
{pagination.totalRecords}
</Badge>
</div>
</CardHeader>
<CardContent
className="overflow-y-auto flex-1 p-4"
style={{ maxHeight: pagination.totalPages > 1 ? 'calc(100% - 140px)' : 'calc(100% - 80px)' }}
>
<div className="space-y-3 sm:space-y-4">
{breachedRequests.length === 0 ? (
<div className="text-center py-6 sm:py-8 text-muted-foreground" data-testid="no-critical-alerts">
<CheckCircle className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-2 text-green-500" />
<p className="text-sm">No critical alerts</p>
<p className="text-xs">All requests are within TAT</p>
</div>
) : (
breachedRequests.map((request) => (
<CriticalAlertCard
key={request.requestId}
alert={request}
onNavigate={(reqNum) => onNavigate?.(`request/${reqNum}`)}
testId="dashboard-critical-alert"
/>
))
)}
</div>
</CardContent>
{/* Pagination - Outside scrollable area, compact design */}
{pagination.totalPages > 1 && breachedRequests.length > 0 && (
<div className="border-t bg-gray-50 px-4 py-2 flex-shrink-0">
<div className="flex flex-col gap-2">
<div className="text-xs text-muted-foreground text-center">
Page {pagination.page} of {pagination.totalPages} ({pagination.totalRecords} total)
</div>
<div className="flex items-center justify-center gap-1 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page - 1)}
disabled={pagination.page === 1}
className="h-7 w-7 p-0"
data-testid="critical-pagination-prev"
>
<ArrowRight className="h-3 w-3 rotate-180" />
</Button>
{getPageNumbers(pagination.page, pagination.totalPages, 3).map((pageNum) => (
<Button
key={pageNum}
variant={pageNum === pagination.page ? 'default' : 'outline'}
size="sm"
onClick={() => onPageChange(pageNum)}
className={`h-7 w-7 p-0 text-xs ${pageNum === pagination.page ? 'bg-red-600 text-white hover:bg-red-700' : ''}`}
data-testid={`critical-pagination-page-${pageNum}`}
>
{pageNum}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page + 1)}
disabled={pagination.page === pagination.totalPages}
className="h-7 w-7 p-0"
data-testid="critical-pagination-next"
>
<ArrowRight className="h-3 w-3" />
</Button>
</div>
</div>
</div>
)}
</Card>
);
}

View File

@ -0,0 +1,134 @@
/**
* Priority Distribution Report Component
* Displays priority distribution analysis with pie chart
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Target } from 'lucide-react';
import { PriorityDistribution } from '@/services/dashboard.service';
import { formatHoursMinutes } from '@/utils/slaTracker';
import { ResponsiveContainer, PieChart as RechartsPieChart, Pie, Cell, Tooltip } from 'recharts';
interface PriorityDistributionReportProps {
priorityDistribution: PriorityDistribution[];
onNavigate?: (path: string) => void;
}
export function PriorityDistributionReport({
priorityDistribution,
onNavigate,
}: PriorityDistributionReportProps) {
if (priorityDistribution.length === 0) return null;
return (
<Card className="shadow-md hover:shadow-lg transition-shadow" data-testid="priority-distribution-report">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="bg-green-50 p-2 sm:p-3 rounded-lg">
<Target className="h-5 w-5 sm:h-6 sm:w-6 text-green-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg lg:text-xl">Priority Distribution Report</CardTitle>
<CardDescription className="text-xs sm:text-sm">Express vs Standard workflow analysis</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-4 mb-6">
{priorityDistribution.map((priority, idx) => {
const avgCycleTime = Number(priority.avgCycleTimeHours) || 0;
const isExpress = priority.priority === 'express';
const bgColor = isExpress ? 'bg-red-50' : 'bg-blue-50';
const dotColor = isExpress ? 'bg-red-500' : 'bg-blue-500';
return (
<div key={idx} className={`${bgColor} p-4 rounded-lg`} data-testid={`priority-card-${priority.priority}`}>
<div className="flex items-center gap-2 mb-2">
<div className={`w-3 h-3 ${dotColor} rounded-full`}></div>
<span className="text-sm text-gray-600 capitalize">{priority.priority}</span>
</div>
<div className="text-2xl sm:text-3xl font-bold text-gray-900 mb-1">{priority.totalCount}</div>
<div className="text-xs text-gray-500">Avg: {formatHoursMinutes(avgCycleTime)} cycle</div>
</div>
);
})}
</div>
{/* Pie Chart */}
<div className="flex justify-center">
<ResponsiveContainer width="100%" height={300}>
<RechartsPieChart>
<Pie
data={priorityDistribution.map((p) => ({
name: p.priority.charAt(0).toUpperCase() + p.priority.slice(1),
value: p.totalCount,
priority: p.priority,
percentage: Math.round(
(p.totalCount / priorityDistribution.reduce((sum, item) => sum + item.totalCount, 0)) * 100
),
}))}
cx="50%"
cy="50%"
labelLine={true}
label={({ cx, cy, midAngle, outerRadius, name, percentage }) => {
const RADIAN = Math.PI / 180;
const radius = outerRadius + 35;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
return (
<text
x={x}
y={y}
fill="#1f2937"
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
style={{
fontSize: '14px',
fontWeight: '600',
pointerEvents: 'none',
}}
>
{`${name}: ${percentage}%`}
</text>
);
}}
outerRadius={90}
fill="#8884d8"
dataKey="value"
onClick={(data: any) => {
if (data && data.priority && onNavigate) {
onNavigate(`requests?priority=${data.priority}`);
}
}}
style={{ cursor: 'pointer' }}
>
{priorityDistribution.map((priority, index) => (
<Cell
key={`cell-${index}`}
fill={priority.priority === 'express' ? '#ef4444' : '#3b82f6'}
style={{ cursor: 'pointer' }}
/>
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#ffffff',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '12px',
}}
formatter={(value: any, _name: any, props: any) => {
const priority = props.payload?.priority || '';
return [`${value} requests`, `Click for ${priority}`];
}}
/>
</RechartsPieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,151 @@
/**
* Recent Activity Section Component
* Displays recent activity feed with pagination
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Activity, RefreshCw, ArrowRight } from 'lucide-react';
import { ActivityFeedItem, ActivityData } from '@/components/dashboard/ActivityFeedItem';
import { getPageNumbers } from '../../utils/dashboardCalculations';
interface RecentActivitySectionProps {
isAdmin: boolean;
recentActivity: ActivityData[];
pagination: {
page: number;
totalPages: number;
totalRecords: number;
};
refreshing: boolean;
onPageChange: (page: number) => void;
onRefresh: () => void;
onNavigate?: (path: string) => void;
currentUserId?: string;
currentUserDisplayName?: string;
currentUserEmail?: string;
}
export function RecentActivitySection({
isAdmin,
recentActivity,
pagination,
refreshing,
onPageChange,
onRefresh,
onNavigate,
currentUserId,
currentUserDisplayName,
currentUserEmail,
}: RecentActivitySectionProps) {
return (
<Card
className="lg:col-span-1 shadow-md hover:shadow-lg transition-shadow flex flex-col overflow-hidden"
style={{ height: '100%' }}
data-testid="recent-activity-section"
>
<CardHeader className="pb-3 sm:pb-4 flex-shrink-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 sm:gap-3 flex-1 min-w-0">
<div className="p-2 sm:p-3 bg-blue-100 rounded-lg">
<Activity className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div className="min-w-0">
<CardTitle className="text-base sm:text-lg text-gray-900">Recent Activity</CardTitle>
<CardDescription className="text-xs sm:text-sm text-gray-600 truncate">
{isAdmin ? 'All workflow updates' : 'My workflow updates'}
</CardDescription>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="text-blue-600 hover:bg-blue-50 font-medium flex-shrink-0 h-8 sm:h-9 px-2 sm:px-3 sm:min-w-[100px]"
onClick={onRefresh}
disabled={refreshing}
data-testid="activity-refresh-button"
>
<RefreshCw className={`w-3 h-3 sm:w-4 sm:h-4 ${refreshing ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline ml-2 sm:w-[60px] sm:text-center">
{refreshing ? 'Refreshing...' : 'Refresh'}
</span>
</Button>
</div>
</CardHeader>
<CardContent
className="overflow-y-auto flex-1 p-4"
style={{ maxHeight: pagination.totalPages > 1 ? 'calc(100% - 140px)' : 'calc(100% - 80px)' }}
>
<div className="space-y-2 sm:space-y-3">
{recentActivity.length === 0 ? (
<div className="text-center py-6 sm:py-8 text-muted-foreground" data-testid="no-recent-activity">
<Activity className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-2 text-gray-400" />
<p className="text-sm">No recent activity</p>
<p className="text-xs">Activity will appear here once requests are processed</p>
</div>
) : (
recentActivity.map((activity) => (
<ActivityFeedItem
key={activity.activityId}
activity={activity}
currentUserId={currentUserId}
currentUserDisplayName={currentUserDisplayName}
currentUserEmail={currentUserEmail}
onNavigate={(reqNum) => onNavigate?.(`request/${reqNum}`)}
testId="dashboard-activity"
/>
))
)}
</div>
</CardContent>
{/* Pagination - Outside scrollable area, compact design */}
{pagination.totalPages > 1 && recentActivity.length > 0 && (
<div className="border-t bg-gray-50 px-4 py-2 flex-shrink-0">
<div className="flex flex-col gap-2">
<div className="text-xs text-muted-foreground text-center">
Page {pagination.page} of {pagination.totalPages} ({pagination.totalRecords} total)
</div>
<div className="flex items-center justify-center gap-1 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page - 1)}
disabled={pagination.page === 1}
className="h-7 w-7 p-0"
data-testid="activity-pagination-prev"
>
<ArrowRight className="h-3 w-3 rotate-180" />
</Button>
{getPageNumbers(pagination.page, pagination.totalPages, 3).map((pageNum) => (
<Button
key={pageNum}
variant={pageNum === pagination.page ? 'default' : 'outline'}
size="sm"
onClick={() => onPageChange(pageNum)}
className={`h-7 w-7 p-0 text-xs ${pageNum === pagination.page ? 'bg-blue-600 text-white hover:bg-blue-700' : ''}`}
data-testid={`activity-pagination-page-${pageNum}`}
>
{pageNum}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page + 1)}
disabled={pagination.page === pagination.totalPages}
className="h-7 w-7 p-0"
data-testid="activity-pagination-next"
>
<ArrowRight className="h-3 w-3" />
</Button>
</div>
</div>
</div>
)}
</Card>
);
}

View File

@ -0,0 +1,192 @@
/**
* TAT Breach Report Component
* Displays requests that breached TAT with detailed table
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { AlertTriangle } from 'lucide-react';
import { CriticalRequest, CriticalAlertData, DateRange } from '@/services/dashboard.service';
import { Pagination } from '@/components/common/Pagination';
import { formatBreachTime } from '../../utils/dashboardCalculations';
import { KPIClickFilters } from '../../components/types/dashboard.types';
interface TATBreachReportProps {
breachedRequests: (CriticalRequest | CriticalAlertData)[];
pagination: {
page: number;
totalPages: number;
totalRecords: number;
};
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
onPageChange: (page: number) => void;
onKPIClick: (filters: KPIClickFilters) => void;
onNavigate?: (path: string) => void;
}
export function TATBreachReport({
breachedRequests,
pagination,
dateRange,
customStartDate,
customEndDate,
onPageChange,
onKPIClick,
onNavigate,
}: TATBreachReportProps) {
if (breachedRequests.length === 0) return null;
const getFilterParams = () => ({
dateRange,
startDate: customStartDate,
endDate: customEndDate,
});
return (
<Card className="shadow-md hover:shadow-lg transition-shadow" data-testid="tat-breach-report">
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="bg-red-50 p-2 sm:p-3 rounded-lg">
<AlertTriangle className="h-5 w-5 sm:h-6 sm:w-6 text-red-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg lg:text-xl">TAT Breach Report</CardTitle>
<CardDescription className="text-xs sm:text-sm">
Requests that breached defined turnaround time
</CardDescription>
</div>
</div>
<Badge variant="destructive" className="text-sm font-medium self-start sm:self-auto">
{breachedRequests.length} {breachedRequests.length === 1 ? 'Breach' : 'Breaches'}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto -mx-4 sm:mx-0">
<table className="w-full min-w-[1000px]">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Request ID</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Title</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Department</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Approver</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Level</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Breach Time</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 min-w-[200px] max-w-[300px]">
Reason
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Priority</th>
</tr>
</thead>
<tbody>
{breachedRequests.map((req: any, idx) => {
const breachTime = req.breachTime || 0;
return (
<tr key={idx} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
<td
className="py-3 px-4 text-sm font-medium text-blue-600 cursor-pointer hover:underline"
onClick={() => onNavigate?.(`request/${req.requestNumber}`)}
data-testid={`breach-request-${req.requestNumber}`}
>
{req.requestNumber}
</td>
<td className="py-3 px-4 text-sm text-gray-900 max-w-xs truncate" title={req.title}>
{req.title}
</td>
<td
className="py-3 px-4 text-sm text-gray-700 cursor-pointer hover:text-blue-600 hover:underline"
onClick={() => {
if (req.department && req.department !== 'Unknown') {
onKPIClick({
...getFilterParams(),
department: req.department,
});
}
}}
>
{req.department || 'Unknown'}
</td>
<td className="py-3 px-4 text-sm text-gray-700">
{req.approverId ? (
<span
className="cursor-pointer hover:text-blue-600 hover:underline"
onClick={() => {
if (onNavigate) {
const params = new URLSearchParams();
params.set('approver', req.approverId!);
params.set('approverType', 'current');
params.set('slaCompliance', 'breached');
onNavigate(`requests?${params.toString()}`);
}
}}
title="Click to view all requests for this approver"
>
{req.approver || 'N/A'}
</span>
) : (
req.approver || 'N/A'
)}
</td>
<td className="py-3 px-4 text-sm text-gray-700">
{req.currentLevel && req.totalLevels ? (
<span className="font-medium">
{req.currentLevel}/{req.totalLevels}
</span>
) : req.currentLevel ? (
<span className="font-medium">{req.currentLevel}/</span>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="py-3 px-4">
<span className="bg-red-500 text-white px-2 py-1 rounded text-xs font-medium">
{formatBreachTime(breachTime)}
</span>
</td>
<td className="py-3 px-4 text-sm text-gray-700 min-w-[200px] max-w-[300px]">
<div className="max-h-32 overflow-y-auto">
<p className="whitespace-pre-line break-words leading-relaxed">
{req.breachReason || 'TAT Exceeded'}
</p>
</div>
</td>
<td className="py-3 px-4">
<Badge
variant="outline"
className={`text-xs font-medium ${
req.priority === 'express'
? 'bg-orange-100 text-orange-800 border-orange-200'
: 'bg-blue-100 text-blue-800 border-blue-200'
}`}
>
{req.priority}
</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination for TAT Breach Report */}
<div className="mt-4">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalRecords={pagination.totalRecords}
itemsPerPage={10}
onPageChange={onPageChange}
itemLabel="critical requests"
testIdPrefix="dashboard-critical-pagination"
/>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,129 @@
/**
* Upcoming Deadlines Section Component
* Displays upcoming deadlines with pagination
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Calendar as CalendarIcon } from 'lucide-react';
import { UpcomingDeadline } from '@/services/dashboard.service';
import { Pagination } from '@/components/common/Pagination';
import { formatHoursMinutes } from '@/utils/slaTracker';
interface UpcomingDeadlinesSectionProps {
isAdmin: boolean;
upcomingDeadlines: UpcomingDeadline[];
pagination: {
page: number;
totalPages: number;
totalRecords: number;
};
onPageChange: (page: number) => void;
onNavigate?: (path: string) => void;
}
export function UpcomingDeadlinesSection({
isAdmin,
upcomingDeadlines,
pagination,
onPageChange,
onNavigate,
}: UpcomingDeadlinesSectionProps) {
if (upcomingDeadlines.length === 0) return null;
return (
<Card className="shadow-md hover:shadow-lg transition-shadow" data-testid="upcoming-deadlines-section">
<CardHeader>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 sm:h-5 sm:w-5 text-orange-600" />
<div>
<CardTitle className="text-base sm:text-lg">Upcoming Deadlines</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{isAdmin ? 'Current active levels organization-wide' : 'Requests awaiting your approval'}
</CardDescription>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 sm:space-y-3">
{upcomingDeadlines.map((deadline, idx) => {
const tatPercentage = Number(deadline.tatPercentageUsed) || 0;
const elapsedHours = Number(deadline.elapsedHours) || 0;
const remainingHours = Number(deadline.remainingHours) || 0;
return (
<div
key={idx}
className="p-3 sm:p-4 border rounded-lg hover:shadow-md transition-all cursor-pointer"
onClick={() => onNavigate?.(`request/${deadline.requestNumber}`)}
data-testid={`deadline-item-${deadline.requestNumber}`}
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 sm:gap-2 mb-1 flex-wrap">
<span className="font-semibold text-xs sm:text-sm">{deadline.requestNumber}</span>
<Badge
variant="outline"
className={`text-xs ${
deadline.priority === 'express' ? 'bg-orange-50 text-orange-700' : 'bg-blue-50 text-blue-700'
}`}
>
{deadline.priority}
</Badge>
</div>
<p className="text-xs sm:text-sm text-muted-foreground truncate">{deadline.requestTitle}</p>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground flex-wrap">
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
Level {deadline.levelNumber || '?'}/{(deadline as any).totalLevels || '?'}
</Badge>
<span className="truncate">{deadline.approverName}</span>
</div>
</div>
<div className="text-right flex-shrink-0">
<p className="text-xs text-muted-foreground">TAT Used</p>
<p
className={`text-base sm:text-lg font-bold ${
tatPercentage >= 80 ? 'text-red-600' : tatPercentage >= 50 ? 'text-orange-600' : 'text-green-600'
}`}
>
{tatPercentage.toFixed(0)}%
</p>
</div>
</div>
<div className="space-y-1">
<Progress
value={tatPercentage}
className={`h-1.5 sm:h-2 ${
tatPercentage >= 80 ? 'bg-red-100' : tatPercentage >= 50 ? 'bg-orange-100' : 'bg-green-100'
}`}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{formatHoursMinutes(elapsedHours)} elapsed</span>
<span>{formatHoursMinutes(remainingHours)} left</span>
</div>
</div>
</div>
);
})}
</div>
{/* Pagination for Upcoming Deadlines */}
<div className="mt-4">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalRecords={pagination.totalRecords}
itemsPerPage={10}
onPageChange={onPageChange}
itemLabel="deadlines"
testIdPrefix="dashboard-deadlines-pagination"
/>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,193 @@
/**
* User KPI Cards Section Component
* Displays personal KPI cards for regular users
*/
import { FileText, Clock, Flame, CheckCircle } from 'lucide-react';
import { KPICard } from '@/components/dashboard/KPICard';
import { StatCard } from '@/components/dashboard/StatCard';
import { Progress } from '@/components/ui/progress';
import { DashboardKPIs, DateRange } from '@/services/dashboard.service';
import { CriticalRequest, CriticalAlertData } from '@/services/dashboard.service';
import { KPIClickFilters } from '../../components/types/dashboard.types';
interface UserKPICardsProps {
kpis: DashboardKPIs | null;
criticalRequests: (CriticalRequest | CriticalAlertData)[];
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
onKPIClick: (filters: KPIClickFilters) => void;
}
export function UserKPICards({
kpis,
criticalRequests,
dateRange,
customStartDate,
customEndDate,
onKPIClick,
}: UserKPICardsProps) {
const getFilterParams = () => ({
dateRange,
startDate: customStartDate,
endDate: customEndDate,
});
const successRate = kpis && kpis.requestVolume.totalRequests > 0
? ((kpis.requestVolume.approvedRequests / kpis.requestVolume.totalRequests) * 100)
: 0;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 items-stretch" data-testid="user-kpi-cards">
{/* My Requests Created */}
<KPICard
title="My Requests (Submitted)"
value={kpis?.requestVolume.totalRequests || 0}
icon={FileText}
iconBgColor="bg-blue-50"
iconColor="text-blue-600"
testId="kpi-my-requests"
onClick={() => onKPIClick(getFilterParams())}
>
<div className="grid grid-cols-2 gap-1.5 sm:gap-2">
<StatCard
label="Approved"
value={kpis?.requestVolume.approvedRequests || 0}
bgColor="bg-green-50"
textColor="text-green-600"
testId="stat-user-approved"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'approved' });
}}
/>
<StatCard
label="Pending"
value={kpis?.requestVolume.openRequests || 0}
bgColor="bg-orange-50"
textColor="text-orange-600"
testId="stat-user-pending"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'pending' });
}}
/>
<StatCard
label="Draft"
value={kpis?.requestVolume.draftRequests || 0}
bgColor="bg-gray-50"
textColor="text-gray-600"
testId="stat-user-draft"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'draft' });
}}
/>
<StatCard
label="Closed"
value={kpis?.requestVolume.closedRequests || 0}
bgColor="bg-blue-50"
textColor="text-blue-600"
testId="stat-user-closed"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'closed' });
}}
/>
</div>
</KPICard>
{/* Awaiting My Approval */}
<KPICard
title="Awaiting My Approval"
value={kpis?.approverLoad.pendingActions || 0}
icon={Clock}
iconBgColor="bg-orange-50"
iconColor="text-orange-600"
subtitle="at current level"
testId="kpi-pending-actions"
>
<div className="grid grid-cols-2 gap-2 mt-auto">
<StatCard
label="Approved Today"
value={kpis?.approverLoad.completedToday || 0}
bgColor="bg-blue-50"
textColor="text-blue-600"
testId="stat-today"
/>
<StatCard
label="This Week"
value={kpis?.approverLoad.completedThisWeek || 0}
bgColor="bg-green-50"
textColor="text-green-600"
testId="stat-week"
/>
</div>
</KPICard>
{/* Critical Alerts */}
<KPICard
title="Critical Alerts"
value={criticalRequests.length}
icon={Flame}
iconBgColor="bg-red-50"
iconColor="text-red-600"
testId="kpi-user-critical"
>
<div className="grid grid-cols-2 gap-2 mt-auto">
<StatCard
label="Breached"
value={criticalRequests.filter((r) => r.breachCount > 0).length}
bgColor="bg-orange-50"
textColor="text-red-600"
testId="stat-user-breached"
/>
<StatCard
label="Warning"
value={criticalRequests.filter((r) => r.breachCount === 0).length}
bgColor="bg-yellow-50"
textColor="text-orange-600"
testId="stat-user-warning"
/>
</div>
</KPICard>
{/* Success Rate with Progress Bar */}
<KPICard
title="Success Rate"
value={`${successRate.toFixed(0)}%`}
icon={CheckCircle}
iconBgColor="bg-green-50"
iconColor="text-green-600"
subtitle={`of ${kpis?.requestVolume.totalRequests || 0} requests approved`}
testId="kpi-success-rate"
>
<div className="space-y-4 mt-3 flex flex-col flex-1">
<div className="space-y-3">
<Progress
value={successRate}
className="h-4 bg-gray-200 [&>div]:bg-green-600"
data-testid="success-rate-progress"
/>
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Rejected</span>
<span className="font-semibold text-red-600">{kpis?.requestVolume.rejectedRequests || 0}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2 mt-auto">
<div className="bg-green-50 rounded-lg p-2 text-center">
<div className="text-xs text-muted-foreground mb-1">Approved</div>
<div className="text-lg font-semibold text-green-600">{kpis?.requestVolume.approvedRequests || 0}</div>
</div>
<div className="bg-red-50 rounded-lg p-2 text-center">
<div className="text-xs text-muted-foreground mb-1">Rejected</div>
<div className="text-lg font-semibold text-red-600">{kpis?.requestVolume.rejectedRequests || 0}</div>
</div>
</div>
</div>
</KPICard>
</div>
);
}

View File

@ -0,0 +1,76 @@
/**
* User Metrics Section Component
* Displays personal metrics for regular users
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { MessageSquare, Clock } from 'lucide-react';
import { DashboardKPIs } from '@/services/dashboard.service';
import { formatHoursMinutes } from '@/utils/slaTracker';
interface UserMetricsSectionProps {
kpis: DashboardKPIs | null;
}
export function UserMetricsSection({ kpis }: UserMetricsSectionProps) {
if (!kpis) return null;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6" data-testid="user-metrics-section">
{/* Collaboration Activity */}
<Card className="shadow-md hover:shadow-lg transition-shadow" data-testid="user-activity-card">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="p-2 bg-indigo-100 rounded-lg">
<MessageSquare className="h-4 w-4 text-indigo-600" />
</div>
<CardTitle className="text-sm">My Activity</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div className="text-center p-3 bg-indigo-50 rounded">
<p className="text-xs text-muted-foreground mb-1">Work Notes</p>
<p className="text-xl sm:text-2xl font-bold text-indigo-600">{kpis.engagement.workNotesAdded}</p>
</div>
<div className="text-center p-3 bg-purple-50 rounded">
<p className="text-xs text-muted-foreground mb-1">Attachments</p>
<p className="text-xl sm:text-2xl font-bold text-purple-600">
{kpis.engagement.attachmentsUploaded}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Avg Response Time */}
<Card className="shadow-md hover:shadow-lg transition-shadow" data-testid="user-response-time-card">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="p-2 bg-purple-100 rounded-lg">
<Clock className="h-4 w-4 text-purple-600" />
</div>
<CardTitle className="text-sm">Avg Response Time</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-baseline gap-2">
<span className="text-2xl sm:text-3xl font-bold text-purple-600">
{formatHoursMinutes(kpis.tatEfficiency.avgCycleTimeHours)}
</span>
</div>
<Separator />
<div className="text-xs text-muted-foreground text-center">
{kpis.tatEfficiency.avgCycleTimeDays.toFixed(1)} working days
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,16 @@
/**
* Dashboard component types
*/
import { DateRange } from '@/services/dashboard.service';
export interface KPIClickFilters {
status?: string;
priority?: string;
slaCompliance?: string;
department?: string;
dateRange?: DateRange;
startDate?: Date;
endDate?: Date;
}

View File

@ -0,0 +1,203 @@
/**
* Hook for fetching and managing dashboard data
*/
import { useState, useCallback, useRef } from 'react';
import { dashboardService, type DashboardKPIs, type DateRange, type AIRemarkUtilization, type ApproverPerformance, type DepartmentStats, type PriorityDistribution, type UpcomingDeadline, type RecentActivity, type CriticalRequest } from '@/services/dashboard.service';
import { ActivityData, CriticalAlertData } from '@/components/dashboard/ActivityFeedItem';
interface UseDashboardDataOptions {
isAdmin: boolean;
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
onPaginationUpdate: {
activity: (page: number, totalPages: number, totalRecords: number) => void;
critical: (page: number, totalPages: number, totalRecords: number) => void;
deadlines: (page: number, totalPages: number, totalRecords: number) => void;
approver: (page: number, totalPages: number, totalRecords: number) => void;
};
}
export function useDashboardData({
isAdmin,
dateRange,
customStartDate,
customEndDate,
onPaginationUpdate,
}: UseDashboardDataOptions) {
const [kpis, setKpis] = useState<DashboardKPIs | null>(null);
const [recentActivity, setRecentActivity] = useState<ActivityData[]>([]);
const [criticalRequests, setCriticalRequests] = useState<(CriticalRequest | CriticalAlertData)[]>([]);
const [departmentStats, setDepartmentStats] = useState<DepartmentStats[]>([]);
const [priorityDistribution, setPriorityDistribution] = useState<PriorityDistribution[]>([]);
const [upcomingDeadlines, setUpcomingDeadlines] = useState<UpcomingDeadline[]>([]);
const [aiRemarkUtilization, setAiRemarkUtilization] = useState<AIRemarkUtilization | null>(null);
const [approverPerformance, setApproverPerformance] = useState<ApproverPerformance[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// Use ref to store latest pagination callbacks to avoid dependency issues
const paginationCallbacksRef = useRef(onPaginationUpdate);
paginationCallbacksRef.current = onPaginationUpdate;
const fetchDashboardData = useCallback(async (showRefreshing = false) => {
try {
if (showRefreshing) {
setRefreshing(true);
} else {
setLoading(true);
}
// Fetch common data for all users
const commonPromises = [
dashboardService.getKPIs(dateRange, customStartDate, customEndDate),
dashboardService.getRecentActivity(1, 10),
dashboardService.getCriticalRequests(1, 10),
dashboardService.getUpcomingDeadlines(1, 10)
];
// Fetch admin-only data if user is admin
const adminPromises = isAdmin ? [
dashboardService.getDepartmentStats(dateRange, customStartDate, customEndDate),
dashboardService.getPriorityDistribution(dateRange, customStartDate, customEndDate),
dashboardService.getAIRemarkUtilization(dateRange, customStartDate, customEndDate),
dashboardService.getApproverPerformance(dateRange, 1, 10, customStartDate, customEndDate)
] : [];
// Fetch all data in parallel
const results = await Promise.all([...commonPromises, ...adminPromises]);
const kpisData = results[0] as DashboardKPIs;
const activityResult = results[1] as { activities: RecentActivity[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
const criticalResult = results[2] as { criticalRequests: CriticalRequest[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
const deadlinesResult = results[3] as { deadlines: UpcomingDeadline[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
setKpis(kpisData);
setRecentActivity(activityResult.activities);
paginationCallbacksRef.current.activity(
activityResult.pagination.currentPage,
activityResult.pagination.totalPages,
activityResult.pagination.totalRecords
);
setCriticalRequests(criticalResult.criticalRequests);
paginationCallbacksRef.current.critical(
criticalResult.pagination.currentPage,
criticalResult.pagination.totalPages,
criticalResult.pagination.totalRecords
);
setUpcomingDeadlines(deadlinesResult.deadlines);
paginationCallbacksRef.current.deadlines(
deadlinesResult.pagination.currentPage,
deadlinesResult.pagination.totalPages,
deadlinesResult.pagination.totalRecords
);
// Only set admin-specific data if user is admin
if (isAdmin && results.length >= 8) {
const deptStats = results[4] as DepartmentStats[];
const priorityDist = results[5] as PriorityDistribution[];
const aiUtilization = results[6] as AIRemarkUtilization;
const approverResult = results[7] as { performance: ApproverPerformance[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
setDepartmentStats(deptStats);
setPriorityDistribution(priorityDist);
setAiRemarkUtilization(aiUtilization);
setApproverPerformance(approverResult.performance);
paginationCallbacksRef.current.approver(
approverResult.pagination.currentPage,
approverResult.pagination.totalPages,
approverResult.pagination.totalRecords
);
} else if (!isAdmin) {
// Reset admin-specific data for normal users
setDepartmentStats([]);
setPriorityDistribution([]);
setAiRemarkUtilization(null);
setApproverPerformance([]);
}
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [isAdmin, dateRange, customStartDate, customEndDate]);
// Fetch individual data with pagination
const fetchRecentActivities = useCallback(async (page: number = 1) => {
try {
const result = await dashboardService.getRecentActivity(page, 10);
setRecentActivity(result.activities);
paginationCallbacksRef.current.activity(
result.pagination.currentPage,
result.pagination.totalPages,
result.pagination.totalRecords
);
} catch (error) {
console.error('Failed to fetch recent activities:', error);
}
}, []);
const fetchCriticalRequests = useCallback(async (page: number = 1) => {
try {
const result = await dashboardService.getCriticalRequests(page, 10);
setCriticalRequests(result.criticalRequests);
paginationCallbacksRef.current.critical(
result.pagination.currentPage,
result.pagination.totalPages,
result.pagination.totalRecords
);
} catch (error) {
console.error('Failed to fetch critical requests:', error);
}
}, []);
const fetchUpcomingDeadlines = useCallback(async (page: number = 1) => {
try {
const result = await dashboardService.getUpcomingDeadlines(page, 10);
setUpcomingDeadlines(result.deadlines);
paginationCallbacksRef.current.deadlines(
result.pagination.currentPage,
result.pagination.totalPages,
result.pagination.totalRecords
);
} catch (error) {
console.error('Failed to fetch upcoming deadlines:', error);
}
}, []);
const fetchApproverPerformance = useCallback(async (page: number = 1) => {
try {
const result = await dashboardService.getApproverPerformance(dateRange, page, 10, customStartDate, customEndDate);
setApproverPerformance(result.performance);
paginationCallbacksRef.current.approver(
result.pagination.currentPage,
result.pagination.totalPages,
result.pagination.totalRecords
);
} catch (error) {
console.error('Failed to fetch approver performance:', error);
}
}, [dateRange, customStartDate, customEndDate]);
return {
kpis,
recentActivity,
criticalRequests,
departmentStats,
priorityDistribution,
upcomingDeadlines,
aiRemarkUtilization,
approverPerformance,
loading,
refreshing,
fetchDashboardData,
fetchRecentActivities,
fetchCriticalRequests,
fetchUpcomingDeadlines,
fetchApproverPerformance,
};
}

View File

@ -0,0 +1,52 @@
/**
* Hook for managing dashboard export functionality
*/
import { useState, useCallback } from 'react';
import { DateRange } from '@/services/dashboard.service';
import { exportApproverPerformanceToCSV, exportDepartmentStatsToCSV } from '../utils/dashboardExports';
export function useDashboardExports() {
const [exportingDeptStats, setExportingDeptStats] = useState(false);
const [exportingApproverPerformance, setExportingApproverPerformance] = useState(false);
const handleExportDepartmentStats = useCallback(async (
dateRange: DateRange,
customStartDate?: Date,
customEndDate?: Date
) => {
try {
setExportingDeptStats(true);
await exportDepartmentStatsToCSV(dateRange, customStartDate, customEndDate);
} catch (error: any) {
console.error('Failed to export department stats:', error);
alert('Failed to export department statistics. Please try again.');
} finally {
setExportingDeptStats(false);
}
}, []);
const handleExportApproverPerformance = useCallback(async (
dateRange: DateRange,
customStartDate?: Date,
customEndDate?: Date
) => {
try {
setExportingApproverPerformance(true);
await exportApproverPerformanceToCSV(dateRange, customStartDate, customEndDate);
} catch (error: any) {
console.error('Failed to export approver performance:', error);
alert('Failed to export approver performance data. Please try again.');
} finally {
setExportingApproverPerformance(false);
}
}, []);
return {
exportingDeptStats,
exportingApproverPerformance,
handleExportDepartmentStats,
handleExportApproverPerformance,
};
}

View File

@ -0,0 +1,62 @@
/**
* Hook for managing dashboard filters (date range, custom dates)
*/
import { useState, useCallback } from 'react';
import { DateRange } from '@/services/dashboard.service';
export function useDashboardFilters() {
const [dateRange, setDateRange] = useState<DateRange>('month');
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
const [showCustomDatePicker, setShowCustomDatePicker] = useState(false);
const handleDateRangeChange = useCallback((value: string) => {
const newRange = value as DateRange;
setDateRange(newRange);
if (newRange !== 'custom') {
setCustomStartDate(undefined);
setCustomEndDate(undefined);
setShowCustomDatePicker(false);
} else {
setShowCustomDatePicker(true);
}
}, []);
const handleApplyCustomDate = useCallback((onApply: (start: Date, end: Date) => void) => {
if (customStartDate && customEndDate) {
if (customStartDate > customEndDate) {
// Swap dates if start is after end
const temp = customStartDate;
setCustomStartDate(customEndDate);
setCustomEndDate(temp);
onApply(customEndDate, temp);
} else {
onApply(customStartDate, customEndDate);
}
setShowCustomDatePicker(false);
}
}, [customStartDate, customEndDate]);
const resetCustomDates = useCallback(() => {
setCustomStartDate(undefined);
setCustomEndDate(undefined);
setShowCustomDatePicker(false);
setDateRange('month');
}, []);
return {
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
setDateRange,
setCustomStartDate,
setCustomEndDate,
setShowCustomDatePicker,
handleDateRangeChange,
handleApplyCustomDate,
resetCustomDates,
};
}

View File

@ -0,0 +1,97 @@
/**
* Hook for managing dashboard pagination state and handlers
*/
import { useState, useCallback } from 'react';
interface PaginationState {
page: number;
totalPages: number;
totalRecords: number;
}
export function useDashboardPagination() {
const [activity, setActivity] = useState<PaginationState>({
page: 1,
totalPages: 1,
totalRecords: 0,
});
const [critical, setCritical] = useState<PaginationState>({
page: 1,
totalPages: 1,
totalRecords: 0,
});
const [deadlines, setDeadlines] = useState<PaginationState>({
page: 1,
totalPages: 1,
totalRecords: 0,
});
const [approver, setApprover] = useState<PaginationState>({
page: 1,
totalPages: 1,
totalRecords: 0,
});
const updateActivityPagination = useCallback((page: number, totalPages: number, totalRecords: number) => {
setActivity({ page, totalPages, totalRecords });
}, []);
const updateCriticalPagination = useCallback((page: number, totalPages: number, totalRecords: number) => {
setCritical({ page, totalPages, totalRecords });
}, []);
const updateDeadlinesPagination = useCallback((page: number, totalPages: number, totalRecords: number) => {
setDeadlines({ page, totalPages, totalRecords });
}, []);
const updateApproverPagination = useCallback((page: number, totalPages: number, totalRecords: number) => {
setApprover({ page, totalPages, totalRecords });
}, []);
const handleActivityPageChange = useCallback((newPage: number, onPageChange: (page: number) => void) => {
if (newPage >= 1 && newPage <= activity.totalPages) {
setActivity(prev => ({ ...prev, page: newPage }));
onPageChange(newPage);
}
}, [activity.totalPages]);
const handleCriticalPageChange = useCallback((newPage: number, onPageChange: (page: number) => void) => {
if (newPage >= 1 && newPage <= critical.totalPages) {
setCritical(prev => ({ ...prev, page: newPage }));
onPageChange(newPage);
}
}, [critical.totalPages]);
const handleDeadlinesPageChange = useCallback((newPage: number, onPageChange: (page: number) => void) => {
if (newPage >= 1 && newPage <= deadlines.totalPages) {
setDeadlines(prev => ({ ...prev, page: newPage }));
onPageChange(newPage);
}
}, [deadlines.totalPages]);
const handleApproverPageChange = useCallback((newPage: number, onPageChange: (page: number) => void) => {
if (newPage >= 1 && newPage <= approver.totalPages) {
setApprover(prev => ({ ...prev, page: newPage }));
onPageChange(newPage);
}
}, [approver.totalPages]);
return {
activity,
critical,
deadlines,
approver,
updateActivityPagination,
updateCriticalPagination,
updateDeadlinesPagination,
updateApproverPagination,
handleActivityPageChange,
handleCriticalPageChange,
handleDeadlinesPageChange,
handleApproverPageChange,
};
}

View File

@ -0,0 +1,62 @@
/**
* Dashboard-specific TypeScript types and interfaces
*/
import { DateRange, DashboardKPIs, CriticalRequest, UpcomingDeadline, RecentActivity, DepartmentStats, PriorityDistribution, AIRemarkUtilization, ApproverPerformance } from '@/services/dashboard.service';
import { ActivityData, CriticalAlertData } from '@/components/dashboard/ActivityFeedItem';
export interface DashboardFilters {
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
}
export interface DashboardPagination {
activity: {
page: number;
totalPages: number;
totalRecords: number;
};
critical: {
page: number;
totalPages: number;
totalRecords: number;
};
deadlines: {
page: number;
totalPages: number;
totalRecords: number;
};
approver: {
page: number;
totalPages: number;
totalRecords: number;
};
}
export interface DashboardState {
kpis: DashboardKPIs | null;
recentActivity: ActivityData[];
criticalRequests: (CriticalRequest | CriticalAlertData)[];
departmentStats: DepartmentStats[];
priorityDistribution: PriorityDistribution[];
upcomingDeadlines: UpcomingDeadline[];
aiRemarkUtilization: AIRemarkUtilization | null;
approverPerformance: ApproverPerformance[];
loading: boolean;
refreshing: boolean;
exportingDeptStats: boolean;
exportingApproverPerformance: boolean;
}
export interface KPIClickFilters {
status?: string;
priority?: string;
slaCompliance?: string;
department?: string;
dateRange?: DateRange;
startDate?: Date;
endDate?: Date;
}

View File

@ -0,0 +1,91 @@
/**
* Dashboard calculation and filtering utilities
*/
import { CriticalRequest, UpcomingDeadline } from '@/services/dashboard.service';
import { CriticalAlertData } from '@/components/dashboard/CriticalAlertCard';
/**
* Filter critical requests to show only breached requests
*/
export function getBreachedRequests(
criticalRequests: (CriticalRequest | CriticalAlertData)[]
): (CriticalRequest | CriticalAlertData)[] {
return criticalRequests.filter(r => {
const isBreached = (r.breachCount || 0) > 0;
const isCritical = (r as any).isCritical === true;
const status = (r as any).status;
if (status) {
const isPendingOrInProgress = status === 'pending' || status === 'in-progress' ||
status === 'PENDING' || status === 'IN_PROGRESS';
return (isBreached || isCritical) && isPendingOrInProgress;
}
return isBreached || isCritical;
});
}
/**
* Filter upcoming deadlines to show only requests about to breach (not yet breached)
*/
export function getUpcomingDeadlinesNotBreached(
upcomingDeadlines: UpcomingDeadline[]
): UpcomingDeadline[] {
return upcomingDeadlines.filter(deadline => {
const tatPercentage = Number(deadline.tatPercentageUsed) || 0;
const remainingHours = Number(deadline.remainingHours) || 0;
return remainingHours > 0 && tatPercentage < 100;
});
}
/**
* Format breach time for display
*/
export function formatBreachTime(hours: number): string {
if (hours <= 0) return 'Just breached';
if (hours < 1) return `${Math.round(hours * 60)} min`;
if (hours < 24) {
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
if (remainingHours > 0) {
const h = Math.floor(remainingHours);
const m = Math.round((remainingHours - h) * 60);
return m > 0 ? `${days}d ${h}h ${m}m` : `${days}d ${h}h`;
}
return `${days}d`;
}
/**
* Get TAT color class based on percentage
*/
export function getTATColorClass(tat: number): string {
if (tat >= 95) return 'bg-green-100 text-green-700';
if (tat >= 90) return 'bg-blue-100 text-blue-700';
if (tat >= 85) return 'bg-orange-100 text-orange-700';
return 'bg-red-100 text-red-700';
}
/**
* Get page numbers for pagination display
*/
export function getPageNumbers(currentPage: number, totalPages: number, maxPagesToShow: number = 3): number[] {
const pages = [];
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;
}

View File

@ -0,0 +1,110 @@
/**
* Dashboard export utility functions
* Handles CSV exports for dashboard data
*/
import { ApproverPerformance, DateRange } from '@/services/dashboard.service';
import { dashboardService } from '@/services/dashboard.service';
import { formatHoursMinutes } from '@/utils/slaTracker';
/**
* Export Approver Performance data to CSV
*/
export async function exportApproverPerformanceToCSV(
dateRange: DateRange,
customStartDate?: Date,
customEndDate?: Date
): Promise<void> {
// Fetch all approver performance data (no pagination)
const allData: ApproverPerformance[] = [];
let currentPage = 1;
let hasMore = true;
const maxPages = 100; // Safety limit
while (hasMore && currentPage <= maxPages) {
const result = await dashboardService.getApproverPerformance(
dateRange,
currentPage,
100, // Large page size
customStartDate,
customEndDate
);
if (result.performance && result.performance.length > 0) {
allData.push(...result.performance);
currentPage++;
hasMore = currentPage <= result.pagination.totalPages;
} else {
hasMore = false;
}
}
// Convert to CSV format
const csvRows = [
['Approver Name', 'Total Approved', 'TAT Compliance (%)', 'Avg Response Time', 'Pending Count'].join(',')
];
allData.forEach((approver) => {
const row = [
`"${(approver.approverName || 'Unknown').replace(/"/g, '""')}"`,
approver.totalApproved || 0,
approver.tatCompliancePercent || 0,
formatHoursMinutes(approver.avgResponseHours),
approver.pendingCount || 0
];
csvRows.push(row.join(','));
});
const csvContent = csvRows.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `approver-performance-report-${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Export Department Stats data to CSV
*/
export async function exportDepartmentStatsToCSV(
dateRange: DateRange,
customStartDate?: Date,
customEndDate?: Date
): Promise<void> {
// Get all department stats
const allDeptStats = await dashboardService.getDepartmentStats(dateRange, customStartDate, customEndDate);
const csvRows = [
['Department', 'Total Requests', 'Approved', 'Rejected', 'In Progress', 'Approval Rate (%)'].join(',')
];
allDeptStats.forEach((dept: any) => {
const row = [
`"${(dept.department || 'Unknown').replace(/"/g, '""')}"`,
dept.totalRequests || 0,
dept.approved || 0,
dept.rejected || 0,
dept.inProgress || 0,
dept.approvalRate || 0
];
csvRows.push(row.join(','));
});
const csvContent = csvRows.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `department-workflow-summary-${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}

View File

@ -0,0 +1,59 @@
/**
* Dashboard navigation utility functions
*/
import { DateRange } from '@/services/dashboard.service';
import { FileText, Clock, Settings, Activity, LucideIcon } from 'lucide-react';
/**
* Build filter URL params for navigation
*/
export function buildFilterUrl(filters: {
status?: string;
priority?: string;
slaCompliance?: string;
department?: string;
dateRange?: DateRange;
startDate?: Date;
endDate?: Date;
}): string {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.priority) params.set('priority', filters.priority);
if (filters.slaCompliance) params.set('slaCompliance', filters.slaCompliance);
if (filters.department) params.set('department', filters.department);
if (filters.dateRange) params.set('dateRange', filters.dateRange);
if (filters.startDate) params.set('startDate', filters.startDate.toISOString());
if (filters.endDate) params.set('endDate', filters.endDate.toISOString());
const queryString = params.toString();
return queryString ? `/requests?${queryString}` : '/requests';
}
export interface QuickAction {
label: string;
icon: LucideIcon;
action: () => void;
color: string;
}
/**
* Get quick actions for dashboard
*/
export function getQuickActions(
isAdmin: boolean,
onNewRequest?: () => void,
onNavigate?: (page: string) => void
): QuickAction[] {
const actions: QuickAction[] = [
{ label: 'New Request', icon: FileText, action: () => onNewRequest?.(), color: 'bg-emerald-600 hover:bg-emerald-700' },
{ label: 'View Pending', icon: Clock, action: () => onNavigate?.('open-requests'), color: 'bg-blue-600 hover:bg-blue-700' },
{ label: 'Settings', icon: Settings, action: () => onNavigate?.('settings'), color: 'bg-slate-600 hover:bg-slate-700' }
];
if (isAdmin) {
actions.splice(2, 0, { label: 'Reports', icon: Activity, action: () => onNavigate?.('detailed-reports'), color: 'bg-purple-600 hover:bg-purple-700' });
}
return actions;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,139 @@
/**
* Reusable Date Range Filter Component
*/
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Calendar as CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import { DateRange } from '@/services/dashboard.service';
interface DateRangeFilterProps {
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
tempCustomStartDate?: Date;
tempCustomEndDate?: Date;
onDateRangeChange: (value: string) => void;
onShowCustomDatePickerChange: (open: boolean) => void;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
onApply: () => void;
onCancel: () => void;
testIdPrefix?: string;
}
export function DateRangeFilter({
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
tempCustomStartDate,
tempCustomEndDate,
onDateRangeChange,
onShowCustomDatePickerChange,
onStartDateChange,
onEndDateChange,
onApply,
onCancel,
testIdPrefix = 'date-filter',
}: DateRangeFilterProps) {
const displayDateRange =
customStartDate && customEndDate
? `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`
: 'Select dates';
return (
<div className="flex items-center gap-2" data-testid={`${testIdPrefix}-container`}>
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={dateRange} onValueChange={onDateRangeChange} data-testid={`${testIdPrefix}-select`}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{/* Custom Date Range Picker */}
{dateRange === 'custom' && (
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2" data-testid={`${testIdPrefix}-custom-trigger`}>
<CalendarIcon className="w-4 h-4" />
{displayDateRange}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start" sideOffset={8}>
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor={`${testIdPrefix}-start-date`} className="text-sm font-medium">
Start Date
</Label>
<Input
id={`${testIdPrefix}-start-date`}
type="date"
value={tempCustomStartDate ? format(tempCustomStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
onStartDateChange(date);
}}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
data-testid={`${testIdPrefix}-start-input`}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${testIdPrefix}-end-date`} className="text-sm font-medium">
End Date
</Label>
<Input
id={`${testIdPrefix}-end-date`}
type="date"
value={tempCustomEndDate ? format(tempCustomEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
onEndDateChange(date);
}}
min={tempCustomStartDate ? format(tempCustomStartDate, 'yyyy-MM-dd') : undefined}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
data-testid={`${testIdPrefix}-end-input`}
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={onApply}
disabled={!tempCustomStartDate || !tempCustomEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
data-testid={`${testIdPrefix}-apply-button`}
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={onCancel}
data-testid={`${testIdPrefix}-cancel-button`}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
);
}

View File

@ -0,0 +1,33 @@
/**
* Detailed Reports Header Component
*/
import { Button } from '@/components/ui/button';
import { ArrowLeft, FileText } from 'lucide-react';
interface DetailedReportsHeaderProps {
onBack: () => void;
}
export function DetailedReportsHeader({ onBack }: DetailedReportsHeaderProps) {
return (
<div className="flex items-center justify-between" data-testid="detailed-reports-header">
<div className="flex items-center gap-4">
<Button variant="outline" onClick={onBack} className="inline-flex items-center gap-2" data-testid="back-button">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Dashboard
</Button>
<div className="flex items-center gap-3">
<div className="p-3 bg-purple-100 rounded-lg">
<FileText className="h-6 w-6 text-purple-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Detailed Reports</h1>
<p className="text-sm text-gray-600">Comprehensive workflow and activity reports</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,219 @@
/**
* Request Lifecycle Report Section Component
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Activity, Download, ChevronRight, Loader2, AlertCircle } from 'lucide-react';
import { LifecycleRequest, PaginationState } from '../../types/detailedReports.types';
import { DateRangeFilter } from '../DateRangeFilter';
import { DateRange } from '@/services/dashboard.service';
import { getPriorityColor, getStatusColor } from '../../utils/colorMappers';
interface RequestLifecycleReportProps {
lifecycleRequests: LifecycleRequest[];
loading: boolean;
loadingPage: boolean;
error: string | null;
pagination: PaginationState;
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
tempCustomStartDate?: Date;
tempCustomEndDate?: Date;
exporting: boolean;
onDateRangeChange: (value: string) => void;
onShowCustomDatePickerChange: (open: boolean) => void;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
onApplyCustomDate: () => void;
onCancelCustomDate: () => void;
onPageChange: (page: number) => void;
onExport: () => void;
onViewRequest: (requestId: string) => void;
}
export function RequestLifecycleReport({
lifecycleRequests,
loading,
loadingPage,
error,
pagination,
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
tempCustomStartDate,
tempCustomEndDate,
exporting,
onDateRangeChange,
onShowCustomDatePickerChange,
onStartDateChange,
onEndDateChange,
onApplyCustomDate,
onCancelCustomDate,
onPageChange,
onExport,
onViewRequest,
}: RequestLifecycleReportProps) {
return (
<Card className="shadow-lg hover:shadow-xl transition-shadow" data-testid="request-lifecycle-report">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-blue-100 rounded-lg">
<Activity className="h-5 w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-lg text-gray-900">Request Lifecycle Report</CardTitle>
<CardDescription className="text-gray-600">End-to-end status with timeline and TAT compliance</CardDescription>
</div>
</div>
<Button variant="outline" size="sm" className="gap-2" onClick={onExport} disabled={exporting} data-testid="export-lifecycle-button">
<Download className="w-4 h-4" />
{exporting ? 'Exporting...' : 'Download CSV'}
</Button>
</div>
<div className="mt-4 flex items-center gap-3 flex-wrap">
<DateRangeFilter
dateRange={dateRange}
customStartDate={customStartDate}
customEndDate={customEndDate}
showCustomDatePicker={showCustomDatePicker}
tempCustomStartDate={tempCustomStartDate}
tempCustomEndDate={tempCustomEndDate}
onDateRangeChange={onDateRangeChange}
onShowCustomDatePickerChange={onShowCustomDatePickerChange}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
onApply={onApplyCustomDate}
onCancel={onCancelCustomDate}
testIdPrefix="lifecycle"
/>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12" data-testid="lifecycle-loading">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
<span className="ml-2 text-sm text-gray-600">Loading lifecycle data...</span>
</div>
) : error ? (
<div className="flex items-center justify-center py-12 text-red-600" data-testid="lifecycle-error">
<AlertCircle className="w-5 h-5 mr-2" />
<span className="text-sm">{error}</span>
</div>
) : lifecycleRequests.length === 0 ? (
<div className="text-center py-12 text-gray-500" data-testid="lifecycle-empty">
<p className="text-sm">No lifecycle data available</p>
</div>
) : (
<div className="space-y-4">
{lifecycleRequests.map((request) => (
<div key={request.id} className="border rounded-xl overflow-hidden" data-testid={`lifecycle-request-${request.id}`}>
<div
className="p-4 bg-gradient-to-r from-gray-50 to-white hover:bg-gray-100 cursor-pointer transition-all"
onClick={() => onViewRequest(request.requestId || request.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex flex-col">
<div className="flex items-center gap-2 mb-1">
<button
onClick={(e) => {
e.stopPropagation();
onViewRequest(request.requestId || request.id);
}}
className="font-semibold text-sm text-blue-600 hover:text-blue-800 hover:underline cursor-pointer transition-colors"
data-testid={`lifecycle-request-link-${request.id}`}
>
{request.id}
</button>
<Badge className={getPriorityColor(request.priority)} data-testid={`lifecycle-priority-${request.id}`}>
{request.priority}
</Badge>
<Badge className={getStatusColor(request.status)} data-testid={`lifecycle-status-${request.id}`}>
{request.status}
</Badge>
</div>
<p className="text-sm text-gray-700">{request.title}</p>
<p className="text-xs text-gray-500 mt-1">
Initiated by <span className="font-medium">{request.initiator}</span> on {request.initDate}
</p>
{request.currentStage && !request.currentStage.includes('Level') && (
<p className="text-xs text-gray-400 mt-0.5">Stage: {request.currentStage}</p>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-xs text-gray-500">Current Stage</p>
<p className="text-sm font-semibold text-gray-900">{request.currentStage}</p>
<p className="text-xs text-gray-400 mt-0.5">
(Level {request.currentLevel}/{request.totalLevels})
</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500">Overall TAT</p>
<p className="text-sm font-semibold text-gray-900">{request.overallTAT}</p>
{request.breachCount > 0 && (
<p className="text-xs text-red-500 mt-0.5">
{request.breachCount} breach{request.breachCount > 1 ? 'es' : ''}
</p>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onViewRequest(request.requestId || request.id)}
className="gap-1.5"
data-testid={`lifecycle-view-button-${request.id}`}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
))}
</div>
)}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between p-4 border-t relative" data-testid="lifecycle-pagination">
{loadingPage && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10 rounded-b-lg">
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
</div>
)}
<p className="text-sm text-gray-600">
Showing page {pagination.page} of {pagination.totalPages} ({pagination.totalRecords} total records)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page - 1)}
disabled={pagination.page <= 1 || loadingPage}
data-testid="lifecycle-pagination-prev"
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages || loadingPage}
data-testid="lifecycle-pagination-next"
>
Next
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,266 @@
/**
* User Activity Log Report Section Component
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { History, Download, Loader2, AlertCircle } from 'lucide-react';
import { ActivityLogEntry, PaginationState } from '../../types/detailedReports.types';
import { DateRangeFilter } from '../DateRangeFilter';
import { DateRange } from '@/services/dashboard.service';
import { getActionBadgeColor } from '../../utils/colorMappers';
interface UserActivityLogReportProps {
activityLog: ActivityLogEntry[];
loading: boolean;
loadingPage: boolean;
error: string | null;
pagination: PaginationState;
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
tempCustomStartDate?: Date;
tempCustomEndDate?: Date;
filterCategory: string;
filterSeverity: string;
exporting: boolean;
onDateRangeChange: (value: string) => void;
onShowCustomDatePickerChange: (open: boolean) => void;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
onApplyCustomDate: () => void;
onCancelCustomDate: () => void;
onCategoryChange: (value: string) => void;
onSeverityChange: (value: string) => void;
onClearFilters: () => void;
onPageChange: (page: number) => void;
onExport: () => void;
onViewRequest: (requestId: string) => void;
}
export function UserActivityLogReport({
activityLog,
loading,
loadingPage,
error,
pagination,
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
tempCustomStartDate,
tempCustomEndDate,
filterCategory,
filterSeverity,
exporting,
onDateRangeChange,
onShowCustomDatePickerChange,
onStartDateChange,
onEndDateChange,
onApplyCustomDate,
onCancelCustomDate,
onCategoryChange,
onSeverityChange,
onClearFilters,
onPageChange,
onExport,
onViewRequest,
}: UserActivityLogReportProps) {
const hasActiveFilters =
filterCategory !== 'all' ||
filterSeverity !== 'all' ||
dateRange !== 'month' ||
customStartDate ||
customEndDate;
return (
<Card className="shadow-lg hover:shadow-xl transition-shadow" data-testid="user-activity-log-report">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-indigo-100 rounded-lg">
<History className="h-5 w-5 text-indigo-600" />
</div>
<div>
<CardTitle className="text-lg text-gray-900">User Activity Log Report</CardTitle>
<CardDescription className="text-gray-600">Consolidated user actions and activity history</CardDescription>
</div>
</div>
<Button variant="outline" size="sm" className="gap-2" onClick={onExport} disabled={exporting} data-testid="export-activity-button">
<Download className="w-4 h-4" />
{exporting ? 'Exporting...' : 'Download CSV'}
</Button>
</div>
<div className="mt-4 flex items-center gap-4 flex-wrap">
<DateRangeFilter
dateRange={dateRange}
customStartDate={customStartDate}
customEndDate={customEndDate}
showCustomDatePicker={showCustomDatePicker}
tempCustomStartDate={tempCustomStartDate}
tempCustomEndDate={tempCustomEndDate}
onDateRangeChange={onDateRangeChange}
onShowCustomDatePickerChange={onShowCustomDatePickerChange}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
onApply={onApplyCustomDate}
onCancel={onCancelCustomDate}
testIdPrefix="activity"
/>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 whitespace-nowrap">Category:</label>
<Select value={filterCategory} onValueChange={onCategoryChange} data-testid="activity-category-filter">
<SelectTrigger className="w-40">
<SelectValue placeholder="All Categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="WORKFLOW">Workflow</SelectItem>
<SelectItem value="COLLABORATION">Collaboration</SelectItem>
<SelectItem value="DOCUMENT">Document</SelectItem>
<SelectItem value="AUTHENTICATION">Authentication</SelectItem>
<SelectItem value="SYSTEM">System</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 whitespace-nowrap">Severity:</label>
<Select value={filterSeverity} onValueChange={onSeverityChange} data-testid="activity-severity-filter">
<SelectTrigger className="w-40">
<SelectValue placeholder="All Severities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Severities</SelectItem>
<SelectItem value="INFO">Info</SelectItem>
<SelectItem value="WARNING">Warning</SelectItem>
<SelectItem value="ERROR">Error</SelectItem>
<SelectItem value="CRITICAL">Critical</SelectItem>
</SelectContent>
</Select>
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={onClearFilters} className="text-xs" data-testid="activity-clear-filters">
Clear Filters
</Button>
)}
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12" data-testid="activity-loading">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
<span className="ml-2 text-sm text-gray-600">Loading activity data...</span>
</div>
) : error ? (
<div className="flex items-center justify-center py-12 text-red-600" data-testid="activity-error">
<AlertCircle className="w-5 h-5 mr-2" />
<span className="text-sm">{error}</span>
</div>
) : activityLog.length === 0 ? (
<div className="text-center py-12 text-gray-500" data-testid="activity-empty">
<p className="text-sm">No activity data available</p>
</div>
) : (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">Timestamp</TableHead>
<TableHead className="font-semibold">User</TableHead>
<TableHead className="font-semibold">Action</TableHead>
<TableHead className="font-semibold">Details</TableHead>
<TableHead className="font-semibold">IP Address</TableHead>
<TableHead className="font-semibold">Request ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activityLog.map((activity, index) => (
<TableRow key={index} className="hover:bg-gray-50" data-testid={`activity-row-${index}`}>
<TableCell className="text-xs font-medium">{activity.timestamp}</TableCell>
<TableCell className="text-sm">{activity.user}</TableCell>
<TableCell>
<Badge className={getActionBadgeColor(activity.action)} data-testid={`activity-action-${index}`}>
{activity.action}
</Badge>
</TableCell>
<TableCell className="text-sm text-gray-600">{activity.details}</TableCell>
<TableCell className="text-xs font-mono text-gray-500">
{activity.ip && activity.ip !== 'N/A' ? (
<span title={activity.userAgent || ''}>{activity.ip}</span>
) : (
<span className="text-gray-400 italic" title="IP address not yet captured">
N/A
</span>
)}
</TableCell>
<TableCell>
{activity.requestId !== '-' && activity.requestId !== 'System Login' ? (
<button
onClick={() => {
const requestIdentifier = activity.requestId;
if (requestIdentifier) {
onViewRequest(requestIdentifier);
}
}}
className="hover:underline"
data-testid={`activity-request-link-${index}`}
>
<Badge
variant="outline"
className="text-xs cursor-pointer hover:bg-blue-50 hover:border-blue-300 transition-colors"
>
{activity.requestId}
</Badge>
</button>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between p-4 border-t relative" data-testid="activity-pagination">
{loadingPage && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10 rounded-b-lg">
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
</div>
)}
<p className="text-sm text-gray-600">
Showing page {pagination.page} of {pagination.totalPages} ({pagination.totalRecords} total records)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page - 1)}
disabled={pagination.page <= 1 || loadingPage}
data-testid="activity-pagination-prev"
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages || loadingPage}
data-testid="activity-pagination-next"
>
Next
</Button>
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,262 @@
/**
* Workflow Aging Report Section Component
*/
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Timer, Download, Search, Loader2, AlertCircle } from 'lucide-react';
import { AgingWorkflow, PaginationState } from '../../types/detailedReports.types';
import { DateRangeFilter } from '../DateRangeFilter';
import { DateRange } from '@/services/dashboard.service';
import { getPriorityColor, getStatusColor, getDaysOpenColor } from '../../utils/colorMappers';
interface WorkflowAgingReportProps {
agingWorkflows: AgingWorkflow[];
loading: boolean;
loadingPage: boolean;
error: string | null;
pagination: PaginationState;
threshold: string;
thresholdError: string | null;
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
tempCustomStartDate?: Date;
tempCustomEndDate?: Date;
searchQuery: string;
exporting: boolean;
onThresholdChange: (value: string) => void;
onThresholdBlur: () => void;
onDateRangeChange: (value: string) => void;
onShowCustomDatePickerChange: (open: boolean) => void;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
onApplyCustomDate: () => void;
onCancelCustomDate: () => void;
onSearchChange: (value: string) => void;
onPageChange: (page: number) => void;
onExport: () => void;
onViewRequest: (requestId: string) => void;
}
export function WorkflowAgingReport({
agingWorkflows,
loading,
loadingPage,
error,
pagination,
threshold,
thresholdError,
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
tempCustomStartDate,
tempCustomEndDate,
searchQuery,
exporting,
onThresholdChange,
onThresholdBlur,
onDateRangeChange,
onShowCustomDatePickerChange,
onStartDateChange,
onEndDateChange,
onApplyCustomDate,
onCancelCustomDate,
onSearchChange,
onPageChange,
onExport,
onViewRequest,
}: WorkflowAgingReportProps) {
return (
<Card className="shadow-lg hover:shadow-xl transition-shadow" data-testid="workflow-aging-report">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-amber-100 rounded-lg">
<Timer className="h-5 w-5 text-amber-600" />
</div>
<div>
<CardTitle className="text-lg text-gray-900">Workflow Aging Report</CardTitle>
<CardDescription className="text-gray-600">Workflows exceeding aging threshold</CardDescription>
</div>
</div>
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 whitespace-nowrap">Threshold:</label>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
value={threshold}
onChange={(e) => onThresholdChange(e.target.value)}
onBlur={onThresholdBlur}
className={`w-24 ${thresholdError ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
placeholder="Days"
data-testid="aging-threshold-input"
/>
<span className="text-sm text-gray-600 whitespace-nowrap">days</span>
</div>
{thresholdError && <span className="text-xs text-red-600">{thresholdError}</span>}
</div>
</div>
<Badge variant="outline" className="font-semibold" data-testid="aging-total-badge">
{pagination.totalRecords} workflows
</Badge>
<Button variant="outline" size="sm" className="gap-2" onClick={onExport} disabled={exporting} data-testid="export-aging-button">
<Download className="w-4 h-4" />
{exporting ? 'Exporting...' : 'Download CSV'}
</Button>
</div>
</div>
<div className="mt-4 flex items-center gap-4 flex-wrap">
<DateRangeFilter
dateRange={dateRange}
customStartDate={customStartDate}
customEndDate={customEndDate}
showCustomDatePicker={showCustomDatePicker}
tempCustomStartDate={tempCustomStartDate}
tempCustomEndDate={tempCustomEndDate}
onDateRangeChange={onDateRangeChange}
onShowCustomDatePickerChange={onShowCustomDatePickerChange}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
onApply={onApplyCustomDate}
onCancel={onCancelCustomDate}
testIdPrefix="aging"
/>
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search by Request ID, Title, or Initiator..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
data-testid="aging-search-input"
/>
</div>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12" data-testid="aging-loading">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
<span className="ml-2 text-sm text-gray-600">Loading aging data...</span>
</div>
) : error ? (
<div className="flex items-center justify-center py-12 text-red-600" data-testid="aging-error">
<AlertCircle className="w-5 h-5 mr-2" />
<span className="text-sm">{error}</span>
</div>
) : agingWorkflows.length === 0 ? (
<div className="text-center py-12 text-gray-500" data-testid="aging-empty">
<p className="text-sm">No workflows found exceeding {threshold} days threshold</p>
</div>
) : (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">Request ID</TableHead>
<TableHead className="font-semibold">Title</TableHead>
<TableHead className="font-semibold">Initiator</TableHead>
<TableHead className="font-semibold">Start Date</TableHead>
<TableHead className="font-semibold">Days Open</TableHead>
<TableHead className="font-semibold">Current Stage</TableHead>
<TableHead className="font-semibold">Assigned To</TableHead>
<TableHead className="font-semibold">Priority</TableHead>
<TableHead className="font-semibold">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agingWorkflows.map((workflow) => (
<TableRow key={workflow.id} className="hover:bg-gray-50" data-testid={`aging-workflow-${workflow.id}`}>
<TableCell className="font-medium text-sm">
<button
onClick={() => onViewRequest(workflow.requestId || workflow.id)}
className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer transition-colors"
data-testid={`aging-request-link-${workflow.id}`}
>
{workflow.id}
</button>
</TableCell>
<TableCell className="text-sm">{workflow.title}</TableCell>
<TableCell className="text-sm">{workflow.initiator}</TableCell>
<TableCell className="text-sm text-gray-600">{workflow.startDate}</TableCell>
<TableCell>
<Badge
className={`${getDaysOpenColor(workflow.daysOpen)} text-white border-transparent`}
data-testid={`aging-days-${workflow.id}`}
>
{workflow.daysOpen} days
<span className="ml-1 text-xs opacity-75">(business)</span>
</Badge>
</TableCell>
<TableCell className="text-sm">{workflow.currentStage}</TableCell>
<TableCell className="text-sm">
{workflow.assignedTo && workflow.assignedTo !== 'N/A' ? (
workflow.assignedTo
) : (
<span className="text-gray-400 italic" title="Assigned approver not available">
N/A
</span>
)}
</TableCell>
<TableCell>
<Badge className={getPriorityColor(workflow.priority)} data-testid={`aging-priority-${workflow.id}`}>
{workflow.priority}
</Badge>
</TableCell>
<TableCell>
<Badge className={getStatusColor(workflow.status)} data-testid={`aging-status-${workflow.id}`}>
{workflow.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between p-4 border-t relative" data-testid="aging-pagination">
{loadingPage && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10 rounded-b-lg">
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
</div>
)}
<p className="text-sm text-gray-600">
Showing page {pagination.page} of {pagination.totalPages} ({pagination.totalRecords} total records)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page - 1)}
disabled={pagination.page <= 1 || loadingPage}
data-testid="aging-pagination-prev"
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages || loadingPage}
data-testid="aging-pagination-next"
>
Next
</Button>
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,129 @@
/**
* Hook for fetching and managing User Activity Log Report data
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { dashboardService, type DateRange } from '@/services/dashboard.service';
import { ActivityLogEntry, PaginationState } from '../types/detailedReports.types';
import { formatDate, mapActivityType } from '../utils/formatting';
const RECORDS_PER_PAGE = 20;
interface UseActivityLogReportOptions {
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
filterCategory?: string;
filterSeverity?: string;
}
export function useActivityLogReport({
dateRange,
customStartDate,
customEndDate,
filterCategory,
filterSeverity,
}: UseActivityLogReportOptions) {
const [activityLog, setActivityLog] = useState<ActivityLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [loadingPage, setLoadingPage] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState<PaginationState>({
page: 1,
totalPages: 1,
totalRecords: 0,
});
const isInitialMount = useRef(true);
const fetchData = useCallback(
async (page: number = 1) => {
const isInitialLoad = page === 1 && isInitialMount.current;
try {
if (isInitialLoad) {
setLoading(true);
} else {
setLoadingPage(true);
}
setError(null);
const result = await dashboardService.getActivityLogReport(
page,
RECORDS_PER_PAGE,
dateRange,
undefined, // filterUserId
undefined, // filterType
filterCategory && filterCategory !== 'all' ? filterCategory : undefined,
filterSeverity && filterSeverity !== 'all' ? filterSeverity : undefined,
customStartDate,
customEndDate
);
const mapped = result.activities.map((activity: any) => {
const action = mapActivityType(activity.type || '', activity.details as string);
// For login activities, show meaningful details
const isLoginActivity =
(activity.type || '').toLowerCase() === 'login' ||
activity.requestId === '00000000-0000-0000-0000-000000000001' ||
activity.requestId === 'SYSTEM_LOGIN';
const details = isLoginActivity
? activity.details || 'User login'
: activity.requestTitle || activity.requestNumber || activity.details || 'N/A';
return {
timestamp: activity.timestamp ? formatDate(activity.timestamp) : 'N/A',
user: activity.userName || 'Unknown',
action,
details,
ip: activity.ipAddress || 'N/A',
userAgent: activity.userAgent || null,
requestId: isLoginActivity
? 'System Login'
: activity.requestNumber || activity.requestId || '-',
userId: activity.userId,
};
});
setActivityLog(mapped);
setPagination({
page: result.pagination.currentPage,
totalPages: result.pagination.totalPages,
totalRecords: result.pagination.totalRecords,
});
} catch (error: any) {
console.error('Failed to fetch activity data:', error);
setError(error?.message || 'Failed to load activity data');
} finally {
if (isInitialLoad) {
setLoading(false);
} else {
setLoadingPage(false);
}
}
if (isInitialLoad) {
isInitialMount.current = false;
}
},
[filterCategory, filterSeverity, dateRange, customStartDate, customEndDate]
);
// Fetch data when filters change
useEffect(() => {
if (dateRange === 'custom' && (!customStartDate || !customEndDate)) {
return;
}
fetchData(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterCategory, filterSeverity, dateRange, customStartDate, customEndDate]);
return {
activityLog,
loading,
loadingPage,
error,
pagination,
fetchData,
};
}

View File

@ -0,0 +1,134 @@
/**
* Hook for fetching and managing Workflow Aging Report data
*/
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { dashboardService, type DateRange } from '@/services/dashboard.service';
import { AgingWorkflow, PaginationState } from '../types/detailedReports.types';
const RECORDS_PER_PAGE = 20;
interface UseAgingReportOptions {
threshold: string;
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
searchQuery?: string;
}
export function useAgingReport({
threshold,
dateRange,
customStartDate,
customEndDate,
searchQuery = '',
}: UseAgingReportOptions) {
const [agingWorkflows, setAgingWorkflows] = useState<AgingWorkflow[]>([]);
const [loading, setLoading] = useState(true);
const [loadingPage, setLoadingPage] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState<PaginationState>({
page: 1,
totalPages: 1,
totalRecords: 0,
});
const isInitialMount = useRef(true);
const fetchData = useCallback(
async (page: number = 1) => {
const isInitialLoad = page === 1 && isInitialMount.current;
try {
if (isInitialLoad) {
setLoading(true);
} else {
setLoadingPage(true);
}
setError(null);
// Validate threshold before fetching
const thresholdValue = parseInt(threshold, 10);
if (isNaN(thresholdValue) || thresholdValue < 1) {
setError('Please enter a valid threshold (minimum 1 day)');
return;
}
const result = await dashboardService.getWorkflowAgingReport(
thresholdValue,
page,
RECORDS_PER_PAGE,
dateRange,
customStartDate,
customEndDate
);
const mapped = result.agingData.map((req: any) => {
return {
id: req.requestNumber || req.requestId,
requestId: req.requestId,
title: req.title,
initiator: req.initiatorName || 'Unknown',
startDate: req.submissionDate ? new Date(req.submissionDate).toLocaleDateString() : 'N/A',
daysOpen: req.daysOpen,
currentStage: req.currentStageName || `Level ${req.currentLevel}`,
assignedTo: req.currentApproverName || 'N/A',
priority: req.priority || 'medium',
status: req.status || 'pending',
};
});
setAgingWorkflows(mapped);
setPagination({
page: result.pagination.currentPage,
totalPages: result.pagination.totalPages,
totalRecords: result.pagination.totalRecords,
});
} catch (error: any) {
console.error('Failed to fetch aging data:', error);
setError(error?.message || 'Failed to load aging data');
} finally {
if (isInitialLoad) {
setLoading(false);
} else {
setLoadingPage(false);
}
}
if (isInitialLoad) {
isInitialMount.current = false;
}
},
[threshold, dateRange, customStartDate, customEndDate]
);
// Filter aging workflows based on search (client-side filtering for current page only)
const filteredAgingWorkflows = useMemo(() => {
return agingWorkflows.filter((workflow) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
workflow.id?.toLowerCase().includes(query) ||
workflow.title?.toLowerCase().includes(query) ||
workflow.initiator?.toLowerCase().includes(query)
);
});
}, [agingWorkflows, searchQuery]);
// Fetch data when threshold or date range changes
useEffect(() => {
if (dateRange === 'custom' && (!customStartDate || !customEndDate)) {
return;
}
fetchData(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threshold, dateRange, customStartDate, customEndDate]);
return {
agingWorkflows: filteredAgingWorkflows,
loading,
loadingPage,
error,
pagination,
fetchData,
};
}

View File

@ -0,0 +1,117 @@
/**
* Hook for managing date filter state
*/
import { useState, useCallback } from 'react';
import { DateRange } from '@/services/dashboard.service';
import { format } from 'date-fns';
export function useDateFilter(initialRange: DateRange = 'month') {
const [dateRange, setDateRange] = useState<DateRange>(initialRange);
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
const [showCustomDatePicker, setShowCustomDatePicker] = useState(false);
const [tempCustomStartDate, setTempCustomStartDate] = useState<Date | undefined>(undefined);
const [tempCustomEndDate, setTempCustomEndDate] = useState<Date | undefined>(undefined);
const handleDateRangeChange = useCallback((value: string) => {
const newRange = value as DateRange;
setDateRange(newRange);
if (newRange !== 'custom') {
setCustomStartDate(undefined);
setCustomEndDate(undefined);
setTempCustomStartDate(undefined);
setTempCustomEndDate(undefined);
setShowCustomDatePicker(false);
} else {
// Initialize temp state from actual state when opening
setTempCustomStartDate(customStartDate);
setTempCustomEndDate(customEndDate);
setShowCustomDatePicker(true);
}
}, [customStartDate, customEndDate]);
const handleApplyCustomDate = useCallback(() => {
if (tempCustomStartDate && tempCustomEndDate) {
// Swap dates if start > end
if (tempCustomStartDate > tempCustomEndDate) {
const temp = tempCustomStartDate;
setCustomStartDate(tempCustomEndDate);
setCustomEndDate(temp);
setTempCustomStartDate(tempCustomEndDate);
setTempCustomEndDate(temp);
} else {
setCustomStartDate(tempCustomStartDate);
setCustomEndDate(tempCustomEndDate);
}
setShowCustomDatePicker(false);
}
}, [tempCustomStartDate, tempCustomEndDate]);
const handleCancelCustomDate = useCallback(() => {
setShowCustomDatePicker(false);
setTempCustomStartDate(customStartDate);
setTempCustomEndDate(customEndDate);
if (!customStartDate || !customEndDate) {
setCustomStartDate(undefined);
setCustomEndDate(undefined);
setDateRange('month');
}
}, [customStartDate, customEndDate]);
const handleStartDateChange = useCallback((date: Date | undefined) => {
if (date) {
setTempCustomStartDate(date);
if (tempCustomEndDate && date > tempCustomEndDate) {
setTempCustomEndDate(date);
}
} else {
setTempCustomStartDate(undefined);
}
}, [tempCustomEndDate]);
const handleEndDateChange = useCallback((date: Date | undefined) => {
if (date) {
setTempCustomEndDate(date);
if (tempCustomStartDate && date < tempCustomStartDate) {
setTempCustomStartDate(date);
}
} else {
setTempCustomEndDate(undefined);
}
}, [tempCustomStartDate]);
const getDisplayDateRange = useCallback((): string => {
if (customStartDate && customEndDate) {
return `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`;
}
return 'Select dates';
}, [customStartDate, customEndDate]);
const reset = useCallback(() => {
setDateRange('month');
setCustomStartDate(undefined);
setCustomEndDate(undefined);
setShowCustomDatePicker(false);
setTempCustomStartDate(undefined);
setTempCustomEndDate(undefined);
}, []);
return {
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
tempCustomStartDate,
tempCustomEndDate,
setShowCustomDatePicker,
handleDateRangeChange,
handleApplyCustomDate,
handleCancelCustomDate,
handleStartDateChange,
handleEndDateChange,
getDisplayDateRange,
reset,
};
}

View File

@ -0,0 +1,74 @@
/**
* Hook for managing CSV export functionality
*/
import { useState, useCallback } from 'react';
import { DateRange } from '@/services/dashboard.service';
import { exportLifecycleToCSV, exportActivityToCSV, exportAgingToCSV } from '../utils/csvExports';
export function useDetailedReportsExports() {
const [exportingLifecycle, setExportingLifecycle] = useState(false);
const [exportingActivity, setExportingActivity] = useState(false);
const [exportingAging, setExportingAging] = useState(false);
const handleExportLifecycle = useCallback(
async (dateRange: DateRange, customStartDate?: Date, customEndDate?: Date) => {
try {
setExportingLifecycle(true);
await exportLifecycleToCSV(dateRange, customStartDate, customEndDate);
} catch (error: any) {
console.error('Failed to export lifecycle data:', error);
alert('Failed to export lifecycle data. Please try again.');
} finally {
setExportingLifecycle(false);
}
},
[]
);
const handleExportActivity = useCallback(
async (
dateRange: DateRange,
customStartDate?: Date,
customEndDate?: Date,
filterCategory?: string,
filterSeverity?: string
) => {
try {
setExportingActivity(true);
await exportActivityToCSV(dateRange, customStartDate, customEndDate, filterCategory, filterSeverity);
} catch (error: any) {
console.error('Failed to export activity data:', error);
alert('Failed to export activity data. Please try again.');
} finally {
setExportingActivity(false);
}
},
[]
);
const handleExportAging = useCallback(
async (threshold: number, dateRange: DateRange, customStartDate?: Date, customEndDate?: Date) => {
try {
setExportingAging(true);
await exportAgingToCSV(threshold, dateRange, customStartDate, customEndDate);
} catch (error: any) {
console.error('Failed to export aging data:', error);
alert('Failed to export aging data. Please try again.');
} finally {
setExportingAging(false);
}
},
[]
);
return {
exportingLifecycle,
exportingActivity,
exportingAging,
handleExportLifecycle,
handleExportActivity,
handleExportAging,
};
}

View File

@ -0,0 +1,110 @@
/**
* Hook for fetching and managing Request Lifecycle Report data
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { dashboardService, type DateRange } from '@/services/dashboard.service';
import { LifecycleRequest, PaginationState } from '../types/detailedReports.types';
import { formatTAT, formatDate } from '../utils/formatting';
const LIFECYCLE_RECORDS_PER_PAGE = 10;
interface UseLifecycleReportOptions {
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
}
export function useLifecycleReport({ dateRange, customStartDate, customEndDate }: UseLifecycleReportOptions) {
const [lifecycleRequests, setLifecycleRequests] = useState<LifecycleRequest[]>([]);
const [loading, setLoading] = useState(true);
const [loadingPage, setLoadingPage] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState<PaginationState>({
page: 1,
totalPages: 1,
totalRecords: 0,
});
const isInitialMount = useRef(true);
const fetchData = useCallback(
async (page: number = 1) => {
const isInitialLoad = page === 1 && isInitialMount.current;
try {
if (isInitialLoad) {
setLoading(true);
} else {
setLoadingPage(true);
}
setError(null);
const result = await dashboardService.getLifecycleReport(
page,
LIFECYCLE_RECORDS_PER_PAGE,
dateRange,
customStartDate,
customEndDate
);
const mapped = result.lifecycleData.map((req: any) => {
const overallTAT = formatTAT(req.overallTATHours);
return {
id: req.requestNumber,
requestId: req.requestId,
title: req.title,
priority: req.priority || 'medium',
status: req.status,
initiator: req.initiatorName || 'Unknown',
initDate: formatDate(req.submissionDate),
currentStage: req.currentStageName || `Level ${req.currentLevel}`,
overallTAT,
currentLevel: req.currentLevel,
totalLevels: req.totalLevels,
breachCount: req.breachCount || 0,
};
});
setLifecycleRequests(mapped);
setPagination({
page: result.pagination.currentPage,
totalPages: result.pagination.totalPages,
totalRecords: result.pagination.totalRecords,
});
} catch (error: any) {
console.error('Failed to fetch lifecycle data:', error);
setError(error?.message || 'Failed to load lifecycle data');
} finally {
if (isInitialLoad) {
setLoading(false);
} else {
setLoadingPage(false);
}
}
if (isInitialLoad) {
isInitialMount.current = false;
}
},
[dateRange, customStartDate, customEndDate]
);
// Fetch data when filters change
useEffect(() => {
if (dateRange === 'custom' && (!customStartDate || !customEndDate)) {
return;
}
fetchData(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dateRange, customStartDate, customEndDate]);
return {
lifecycleRequests,
loading,
loadingPage,
error,
pagination,
fetchData,
};
}

View File

@ -0,0 +1,46 @@
/**
* Hook for managing threshold input state and validation
*/
import { useState, useCallback } from 'react';
export function useThreshold(initialValue: string = '7') {
const [threshold, setThreshold] = useState(initialValue);
const [thresholdError, setThresholdError] = useState<string | null>(null);
const handleThresholdChange = useCallback((value: string) => {
// Allow empty string while typing
if (value === '') {
setThreshold('');
setThresholdError(null);
return;
}
// Parse as integer
const numValue = parseInt(value, 10);
// Validate: must be a positive integer >= 1
if (isNaN(numValue) || numValue < 1) {
setThresholdError('Minimum threshold is 1 day');
setThreshold(value); // Keep the invalid value for user to see
} else {
setThresholdError(null);
setThreshold(value);
}
}, []);
const handleThresholdBlur = useCallback(() => {
// If empty or invalid on blur, reset to minimum (1)
if (threshold === '' || isNaN(parseInt(threshold, 10)) || parseInt(threshold, 10) < 1) {
setThreshold('1');
setThresholdError(null);
}
}, [threshold]);
return {
threshold,
thresholdError,
setThreshold,
handleThresholdChange,
handleThresholdBlur,
};
}

View File

@ -0,0 +1,60 @@
/**
* Detailed Reports TypeScript interfaces
*/
import { DateRange } from '@/services/dashboard.service';
export interface LifecycleRequest {
id: string;
requestId: string;
title: string;
priority: string;
status: string;
initiator: string;
initDate: string;
currentStage: string;
overallTAT: string;
currentLevel: number;
totalLevels: number;
breachCount: number;
}
export interface ActivityLogEntry {
timestamp: string;
user: string;
action: string;
details: string;
ip: string;
userAgent: string | null;
requestId: string;
userId?: string;
}
export interface AgingWorkflow {
id: string;
requestId: string;
title: string;
initiator: string;
startDate: string;
daysOpen: number;
currentStage: string;
assignedTo: string;
priority: string;
status: string;
}
export interface PaginationState {
page: number;
totalPages: number;
totalRecords: number;
}
export interface DateFilterState {
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
tempCustomStartDate?: Date;
tempCustomEndDate?: Date;
}

View File

@ -0,0 +1,69 @@
/**
* Color mapping utility functions for badges and status indicators
*/
/**
* Get badge color class for activity action
*/
export function getActionBadgeColor(action: string): string {
switch (action) {
case 'Login':
return 'text-blue-700 bg-blue-100 border-blue-200';
case 'Created Request':
return 'text-purple-700 bg-purple-100 border-purple-200';
case 'Approved Request':
return 'text-emerald-700 bg-emerald-100 border-emerald-200';
case 'Rejected Request':
return 'text-red-700 bg-red-100 border-red-200';
case 'Added Comment':
return 'text-amber-700 bg-amber-100 border-amber-200';
case 'Viewed Request':
return 'text-gray-700 bg-gray-100 border-gray-200';
case 'Uploaded Document':
return 'text-indigo-700 bg-indigo-100 border-indigo-200';
default:
return 'text-gray-700 bg-gray-100 border-gray-200';
}
}
/**
* Get badge color class for priority
*/
export function getPriorityColor(priority: string): string {
switch (priority) {
case 'high':
return 'bg-red-100 text-red-800 border-red-200';
case 'medium':
return 'bg-orange-100 text-orange-800 border-orange-200';
case 'low':
return 'bg-green-100 text-green-800 border-green-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
}
/**
* Get badge color class for status
*/
export function getStatusColor(status: string): string {
switch (status) {
case 'Delayed':
return 'text-red-700 bg-red-100 border-red-200';
case 'On Time':
return 'text-emerald-700 bg-emerald-100 border-emerald-200';
case 'Pending':
return 'bg-orange-100 text-orange-800 border-orange-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
}
/**
* Get background color class for days open indicator
*/
export function getDaysOpenColor(days: number): string {
if (days >= 20) return 'bg-red-600';
if (days >= 10) return 'bg-orange-500';
return 'bg-amber-500';
}

View File

@ -0,0 +1,168 @@
/**
* CSV Export utility functions for Detailed Reports
*/
import { dashboardService, type DateRange } from '@/services/dashboard.service';
import { formatTAT, formatDate, formatDateForCSV, mapActivityType } from './formatting';
import { LifecycleRequest, ActivityLogEntry, AgingWorkflow } from '../types/detailedReports.types';
/**
* Export Lifecycle Report to CSV
*/
export async function exportLifecycleToCSV(
dateRange: DateRange,
customStartDate?: Date,
customEndDate?: Date
): Promise<void> {
// Fetch all data with a very large limit
const result = await dashboardService.getLifecycleReport(1, 10000, dateRange, customStartDate, customEndDate);
const csvRows = [
[
'Request Number',
'Title',
'Priority',
'Status',
'Initiator',
'Submission Date',
'Current Stage',
'Overall TAT',
'Current Level',
'Total Levels',
'Breach Count',
].join(','),
];
result.lifecycleData.forEach((req: any) => {
const overallTAT = formatTAT(req.overallTATHours);
const submissionDateCSV = req.submissionDate ? formatDateForCSV(req.submissionDate) : 'N/A';
const row = [
req.requestNumber || '',
`"${(req.title || '').replace(/"/g, '""')}"`,
req.priority || 'medium',
req.status || '',
`"${(req.initiatorName || 'Unknown').replace(/"/g, '""')}"`,
submissionDateCSV,
`"${(req.currentStageName || `Level ${req.currentLevel}`).replace(/"/g, '""')}"`,
overallTAT,
(req.currentLevel || '').toString(),
(req.totalLevels || '').toString(),
(req.breachCount || 0).toString(),
];
csvRows.push(row.join(','));
});
downloadCSV(csvRows, `lifecycle-report-${new Date().toISOString().split('T')[0]}.csv`);
}
/**
* Export Activity Log Report to CSV
*/
export async function exportActivityToCSV(
dateRange: DateRange,
customStartDate?: Date,
customEndDate?: Date,
filterCategory?: string,
filterSeverity?: string
): Promise<void> {
const result = await dashboardService.getActivityLogReport(
1,
10000,
dateRange,
undefined, // filterUserId
undefined, // filterType
filterCategory && filterCategory !== 'all' ? filterCategory : undefined,
filterSeverity && filterSeverity !== 'all' ? filterSeverity : undefined,
customStartDate,
customEndDate
);
const csvRows = [
['Timestamp', 'User', 'Action', 'Details', 'IP Address', 'User Agent', 'Request ID'].join(','),
];
result.activities.forEach((activity: any) => {
const action = mapActivityType(activity.type || '', activity.details as string);
const timestampCSV = activity.timestamp ? formatDateForCSV(activity.timestamp) : 'N/A';
const row = [
timestampCSV,
`"${(activity.userName || 'Unknown').replace(/"/g, '""')}"`,
`"${action.replace(/"/g, '""')}"`,
`"${(activity.requestTitle || activity.requestNumber || activity.details || 'N/A').replace(/"/g, '""')}"`,
activity.ipAddress || 'N/A',
`"${(activity.userAgent || '').replace(/"/g, '""')}"`,
activity.requestNumber || activity.requestId || '-',
];
csvRows.push(row.join(','));
});
downloadCSV(csvRows, `activity-log-report-${new Date().toISOString().split('T')[0]}.csv`);
}
/**
* Export Workflow Aging Report to CSV
*/
export async function exportAgingToCSV(
threshold: number,
dateRange: DateRange,
customStartDate?: Date,
customEndDate?: Date
): Promise<void> {
const result = await dashboardService.getWorkflowAgingReport(
threshold,
1,
10000,
dateRange,
customStartDate,
customEndDate
);
const csvRows = [
[
'Request ID',
'Title',
'Initiator',
'Start Date',
'Days Open (Business)',
'Current Stage',
'Assigned To',
'Priority',
'Status',
].join(','),
];
result.agingData.forEach((req: any) => {
const startDateCSV = req.submissionDate ? formatDateForCSV(req.submissionDate) : 'N/A';
const row = [
req.requestNumber || req.requestId || '',
`"${(req.title || '').replace(/"/g, '""')}"`,
`"${(req.initiatorName || 'Unknown').replace(/"/g, '""')}"`,
startDateCSV,
(req.daysOpen || 0).toString(),
`"${(req.currentStageName || `Level ${req.currentLevel}`).replace(/"/g, '""')}"`,
`"${(req.currentApproverName || 'N/A').replace(/"/g, '""')}"`,
req.priority || 'medium',
req.status || 'pending',
];
csvRows.push(row.join(','));
});
downloadCSV(csvRows, `workflow-aging-report-${new Date().toISOString().split('T')[0]}.csv`);
}
/**
* Helper function to download CSV file
*/
function downloadCSV(csvRows: string[], filename: string): void {
const csvContent = csvRows.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}

View File

@ -0,0 +1,72 @@
/**
* Formatting utility functions for Detailed Reports
*/
import { formatHoursMinutes } from '@/utils/slaTracker';
/**
* Format TAT hours (working hours to working days)
* Backend returns working hours, so we divide by 8 (working hours per day) not 24
*/
export function formatTAT(hours: number | null | undefined): string {
if (!hours && hours !== 0) return 'N/A';
const WORKING_HOURS_PER_DAY = 8;
if (hours < WORKING_HOURS_PER_DAY) return formatHoursMinutes(hours);
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
const remainingHours = hours % WORKING_HOURS_PER_DAY;
return remainingHours > 0 ? `${days}d ${formatHoursMinutes(remainingHours)}` : `${days}d`;
}
/**
* Format date for display
*/
export function formatDate(date: string | null | undefined): string {
if (!date) return 'N/A';
try {
const d = new Date(date);
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return date;
}
}
/**
* Format date for CSV export (no commas, consistent format)
*/
export function formatDateForCSV(date: string | null | undefined): string {
if (!date) return 'N/A';
try {
const d = new Date(date);
// Format: YYYY-MM-DD HH:MM (no commas, Excel-friendly)
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
} catch {
return date;
}
}
/**
* Map activity type to display label
*/
export function mapActivityType(type: string, _activityDescription?: string): string {
const typeLower = (type || '').toLowerCase();
if (typeLower.includes('created') || typeLower.includes('create')) return 'Created Request';
if (typeLower.includes('approval') || typeLower.includes('approved')) return 'Approved Request';
if (typeLower.includes('rejection') || typeLower.includes('rejected')) return 'Rejected Request';
if (typeLower.includes('comment')) return 'Added Comment';
if (typeLower.includes('view') || typeLower.includes('viewed')) return 'Viewed Request';
if (typeLower.includes('upload') || typeLower.includes('document')) return 'Uploaded Document';
if (typeLower.includes('login')) return 'Login';
return type || 'Activity';
}

View File

@ -1,231 +1,74 @@
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 { useCallback, useRef } from 'react';
import { FileText } from 'lucide-react';
import { PageHeader } from '@/components/common/PageHeader';
import { StatsCard } from '@/components/dashboard/StatsCard';
import { Pagination } from '@/components/common/Pagination';
// Components
import { MyRequestsStatsSection } from './components/MyRequestsStats';
import { MyRequestsFilters as MyRequestsFiltersComponent } from './components/MyRequestsFilters';
import { MyRequestsList } from './components/MyRequestsList';
// Hooks
import { useMyRequests } from './hooks/useMyRequests';
import { useMyRequestsFilters } from './hooks/useMyRequestsFilters';
import { useMyRequestsStats } from './hooks/useMyRequestsStats';
// Utils
import { transformRequests } from './utils/requestTransformers';
// Types
import type { MyRequestsFilters } from './types/myRequests.types';
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);
// Data fetching hook
const myRequests = useMyRequests({ itemsPerPage: 10 });
// Pagination states
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalRecords, setTotalRecords] = useState(0);
const [itemsPerPage] = useState(10);
// Filters hook - use ref to avoid circular dependency
const fetchRef = useRef(myRequests.fetchMyRequests);
fetchRef.current = myRequests.fetchMyRequests;
const fetchMyRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string }) => {
try {
if (page === 1) {
setLoading(true);
setApiRequests([]);
}
const filters = useMyRequestsFilters({
onFiltersChange: useCallback(
(filters: MyRequestsFilters) => {
// Reset to page 1 when filters change
fetchRef.current(1, {
search: filters.search || undefined,
status: filters.status !== 'all' ? filters.status : undefined,
priority: filters.priority !== 'all' ? filters.priority : undefined,
});
},
[]
),
});
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
// Handle dynamic requests (fallback until API loads)
const convertedDynamicRequests = transformRequests(dynamicRequests);
const sourceRequests = myRequests.hasFetchedFromApi ? myRequests.requests : convertedDynamicRequests;
// Extract data - workflowApi now returns { data: [], pagination: {} }
const items = Array.isArray((result as any)?.data)
? (result as any).data
: [];
// Stats calculation
const stats = useMyRequestsStats({
requests: sourceRequests,
totalRecords: myRequests.pagination.totalRecords,
});
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
// Page change handler
const handlePageChange = useCallback(
(newPage: number) => {
if (newPage >= 1 && newPage <= myRequests.pagination.totalPages) {
myRequests.fetchMyRequests(newPage, {
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.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
};
},
[myRequests, filters]
);
return (
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="my-requests-page">
@ -235,255 +78,44 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
title="My Requests"
description="Track and manage all your submitted requests"
badge={{
value: `${totalRecords || allRequests.length} total`,
value: `${myRequests.pagination.totalRecords || sourceRequests.length} total`,
label: 'requests',
loading
loading: myRequests.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>
<MyRequestsStatsSection stats={stats} />
{/* 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>
<MyRequestsFiltersComponent
searchTerm={filters.searchTerm}
statusFilter={filters.statusFilter}
priorityFilter={filters.priorityFilter}
onSearchChange={filters.setSearchTerm}
onStatusChange={filters.setStatusFilter}
onPriorityChange={filters.setPriorityFilter}
/>
{/* 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>
<MyRequestsList
requests={sourceRequests}
loading={myRequests.loading}
searchTerm={filters.searchTerm}
statusFilter={filters.statusFilter}
priorityFilter={filters.priorityFilter}
onViewRequest={onViewRequest}
/>
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalRecords={totalRecords}
itemsPerPage={itemsPerPage}
currentPage={myRequests.pagination.currentPage}
totalPages={myRequests.pagination.totalPages}
totalRecords={myRequests.pagination.totalRecords}
itemsPerPage={myRequests.pagination.itemsPerPage}
onPageChange={handlePageChange}
loading={loading}
loading={myRequests.loading}
itemLabel="requests"
testIdPrefix="my-requests-pagination"
/>

View File

@ -0,0 +1,79 @@
/**
* My Requests Filters Section Component
*/
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search } from 'lucide-react';
interface MyRequestsFiltersProps {
searchTerm: string;
statusFilter: string;
priorityFilter: string;
onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void;
onPriorityChange: (value: string) => void;
}
export function MyRequestsFilters({
searchTerm,
statusFilter,
priorityFilter,
onSearchChange,
onStatusChange,
onPriorityChange,
}: MyRequestsFiltersProps) {
return (
<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) => onSearchChange(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={onStatusChange}>
<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={onPriorityChange}>
<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>
);
}

View File

@ -0,0 +1,58 @@
/**
* My Requests List Section Component
*/
import { Card, CardContent } from '@/components/ui/card';
import { FileText } from 'lucide-react';
import { MyRequest } from '../types/myRequests.types';
import { RequestCard } from './RequestCard';
interface MyRequestsListProps {
requests: MyRequest[];
loading: boolean;
searchTerm: string;
statusFilter: string;
priorityFilter: string;
onViewRequest: (requestId: string, requestTitle?: string, status?: string) => void;
}
export function MyRequestsList({
requests,
loading,
searchTerm,
statusFilter,
priorityFilter,
onViewRequest,
}: MyRequestsListProps) {
if (loading) {
return (
<Card data-testid="loading-state">
<CardContent className="p-6 text-sm text-gray-600">Loading your requests</CardContent>
</Card>
);
}
if (requests.length === 0) {
const hasActiveFilters = searchTerm || statusFilter !== 'all' || priorityFilter !== 'all';
return (
<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">
{hasActiveFilters ? 'Try adjusting your search or filters' : "You haven't created any requests yet"}
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4" data-testid="my-requests-list">
{requests.map((request, index) => (
<RequestCard key={request.id} request={request} index={index} onViewRequest={onViewRequest} />
))}
</div>
);
}

View File

@ -0,0 +1,73 @@
/**
* My Requests Stats Section Component
*/
import { FileText, Clock, CheckCircle, XCircle, Edit } from 'lucide-react';
import { StatsCard } from '@/components/dashboard/StatsCard';
import { MyRequestsStats } from '../types/myRequests.types';
interface MyRequestsStatsProps {
stats: MyRequestsStats;
}
export function MyRequestsStatsSection({ stats }: MyRequestsStatsProps) {
return (
<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>
);
}

Some files were not shown because too many files have changed in this diff Show More