code rafactor done for few screns
This commit is contained in:
parent
12d1094f45
commit
00f0b786f6
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
359
src/components/workflow/CreateRequest/ApprovalWorkflowStep.tsx
Normal file
359
src/components/workflow/CreateRequest/ApprovalWorkflowStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
225
src/components/workflow/CreateRequest/BasicInformationStep.tsx
Normal file
225
src/components/workflow/CreateRequest/BasicInformationStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
307
src/components/workflow/CreateRequest/DocumentsStep.tsx
Normal file
307
src/components/workflow/CreateRequest/DocumentsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
227
src/components/workflow/CreateRequest/ParticipantsStep.tsx
Normal file
227
src/components/workflow/CreateRequest/ParticipantsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
283
src/components/workflow/CreateRequest/ReviewSubmitStep.tsx
Normal file
283
src/components/workflow/CreateRequest/ReviewSubmitStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
205
src/components/workflow/CreateRequest/TemplateSelectionStep.tsx
Normal file
205
src/components/workflow/CreateRequest/TemplateSelectionStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
117
src/components/workflow/CreateRequest/WizardFooter.tsx
Normal file
117
src/components/workflow/CreateRequest/WizardFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
119
src/components/workflow/CreateRequest/WizardStepper.tsx
Normal file
119
src/components/workflow/CreateRequest/WizardStepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
9
src/components/workflow/CreateRequest/index.ts
Normal file
9
src/components/workflow/CreateRequest/index.ts
Normal 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';
|
||||
|
||||
315
src/hooks/useCreateRequestForm.ts
Normal file
315
src/hooks/useCreateRequestForm.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
189
src/hooks/useDocumentManagement.ts
Normal file
189
src/hooks/useDocumentManagement.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
150
src/hooks/usePolicyValidation.ts
Normal file
150
src/hooks/usePolicyValidation.ts
Normal 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
142
src/hooks/useUserSearch.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
108
src/hooks/useWizardNavigation.ts
Normal file
108
src/hooks/useWizardNavigation.ts
Normal 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
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
101
src/pages/ApproverPerformance/utils/configMappers.ts
Normal file
101
src/pages/ApproverPerformance/utils/configMappers.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
24
src/pages/ApproverPerformance/utils/formatters.ts
Normal file
24
src/pages/ApproverPerformance/utils/formatters.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
||||
85
src/pages/ApproverPerformance/utils/statsCalculations.ts
Normal file
85
src/pages/ApproverPerformance/utils/statsCalculations.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
112
src/pages/ClosedRequests/components/ClosedRequestCard.tsx
Normal file
112
src/pages/ClosedRequests/components/ClosedRequestCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/pages/ClosedRequests/components/ClosedRequestsEmpty.tsx
Normal file
47
src/pages/ClosedRequests/components/ClosedRequestsEmpty.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
161
src/pages/ClosedRequests/components/ClosedRequestsFilters.tsx
Normal file
161
src/pages/ClosedRequests/components/ClosedRequestsFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
56
src/pages/ClosedRequests/components/ClosedRequestsHeader.tsx
Normal file
56
src/pages/ClosedRequests/components/ClosedRequestsHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
41
src/pages/ClosedRequests/components/ClosedRequestsList.tsx
Normal file
41
src/pages/ClosedRequests/components/ClosedRequestsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
107
src/pages/ClosedRequests/components/ClosedRequestsPagination.tsx
Normal file
107
src/pages/ClosedRequests/components/ClosedRequestsPagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
106
src/pages/ClosedRequests/hooks/useClosedRequests.ts
Normal file
106
src/pages/ClosedRequests/hooks/useClosedRequests.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
87
src/pages/ClosedRequests/hooks/useClosedRequestsFilters.ts
Normal file
87
src/pages/ClosedRequests/hooks/useClosedRequestsFilters.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
54
src/pages/ClosedRequests/types/closedRequests.types.ts
Normal file
54
src/pages/ClosedRequests/types/closedRequests.types.ts
Normal 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;
|
||||
}
|
||||
|
||||
67
src/pages/ClosedRequests/utils/configMappers.ts
Normal file
67
src/pages/ClosedRequests/utils/configMappers.ts
Normal 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: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
21
src/pages/ClosedRequests/utils/paginationHelpers.ts
Normal file
21
src/pages/ClosedRequests/utils/paginationHelpers.ts
Normal 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;
|
||||
}
|
||||
|
||||
37
src/pages/ClosedRequests/utils/requestTransformers.ts
Normal file
37
src/pages/ClosedRequests/utils/requestTransformers.ts
Normal 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
24
src/pages/CreateRequest/components/CreateRequestContent.tsx
Normal file
24
src/pages/CreateRequest/components/CreateRequestContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
71
src/pages/CreateRequest/components/CreateRequestHeader.tsx
Normal file
71
src/pages/CreateRequest/components/CreateRequestHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
36
src/pages/CreateRequest/constants/requestTemplates.ts
Normal file
36
src/pages/CreateRequest/constants/requestTemplates.ts
Normal 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 },
|
||||
},
|
||||
];
|
||||
|
||||
183
src/pages/CreateRequest/hooks/useApproverValidation.ts
Normal file
183
src/pages/CreateRequest/hooks/useApproverValidation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
157
src/pages/CreateRequest/hooks/useCreateRequestHandlers.ts
Normal file
157
src/pages/CreateRequest/hooks/useCreateRequestHandlers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
167
src/pages/CreateRequest/hooks/useCreateRequestSubmission.ts
Normal file
167
src/pages/CreateRequest/hooks/useCreateRequestSubmission.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
92
src/pages/CreateRequest/hooks/useRequestModals.ts
Normal file
92
src/pages/CreateRequest/hooks/useRequestModals.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
86
src/pages/CreateRequest/services/createRequestService.ts
Normal file
86
src/pages/CreateRequest/services/createRequestService.ts
Normal 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);
|
||||
}
|
||||
|
||||
124
src/pages/CreateRequest/types/createRequest.types.ts
Normal file
124
src/pages/CreateRequest/types/createRequest.types.ts
Normal 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;
|
||||
}
|
||||
|
||||
51
src/pages/CreateRequest/utils/approvalLevelBuilders.ts
Normal file
51
src/pages/CreateRequest/utils/approvalLevelBuilders.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
||||
110
src/pages/CreateRequest/utils/participantMappers.ts
Normal file
110
src/pages/CreateRequest/utils/participantMappers.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
|
||||
114
src/pages/CreateRequest/utils/payloadBuilders.ts
Normal file
114
src/pages/CreateRequest/utils/payloadBuilders.ts
Normal 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
202
src/pages/Dashboard/components/DashboardFiltersBar.tsx
Normal file
202
src/pages/Dashboard/components/DashboardFiltersBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
70
src/pages/Dashboard/components/DashboardHero.tsx
Normal file
70
src/pages/Dashboard/components/DashboardHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
191
src/pages/Dashboard/components/sections/AdminKPICards.tsx
Normal file
191
src/pages/Dashboard/components/sections/AdminKPICards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
192
src/pages/Dashboard/components/sections/TATBreachReport.tsx
Normal file
192
src/pages/Dashboard/components/sections/TATBreachReport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
193
src/pages/Dashboard/components/sections/UserKPICards.tsx
Normal file
193
src/pages/Dashboard/components/sections/UserKPICards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
16
src/pages/Dashboard/components/types/dashboard.types.ts
Normal file
16
src/pages/Dashboard/components/types/dashboard.types.ts
Normal 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;
|
||||
}
|
||||
|
||||
203
src/pages/Dashboard/hooks/useDashboardData.ts
Normal file
203
src/pages/Dashboard/hooks/useDashboardData.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
52
src/pages/Dashboard/hooks/useDashboardExports.ts
Normal file
52
src/pages/Dashboard/hooks/useDashboardExports.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
62
src/pages/Dashboard/hooks/useDashboardFilters.ts
Normal file
62
src/pages/Dashboard/hooks/useDashboardFilters.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
97
src/pages/Dashboard/hooks/useDashboardPagination.ts
Normal file
97
src/pages/Dashboard/hooks/useDashboardPagination.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
62
src/pages/Dashboard/types/dashboard.types.ts
Normal file
62
src/pages/Dashboard/types/dashboard.types.ts
Normal 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;
|
||||
}
|
||||
|
||||
91
src/pages/Dashboard/utils/dashboardCalculations.ts
Normal file
91
src/pages/Dashboard/utils/dashboardCalculations.ts
Normal 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;
|
||||
}
|
||||
|
||||
110
src/pages/Dashboard/utils/dashboardExports.ts
Normal file
110
src/pages/Dashboard/utils/dashboardExports.ts
Normal 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);
|
||||
}
|
||||
|
||||
59
src/pages/Dashboard/utils/dashboardNavigation.ts
Normal file
59
src/pages/Dashboard/utils/dashboardNavigation.ts
Normal 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
139
src/pages/DetailedReports/components/DateRangeFilter.tsx
Normal file
139
src/pages/DetailedReports/components/DateRangeFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
129
src/pages/DetailedReports/hooks/useActivityLogReport.ts
Normal file
129
src/pages/DetailedReports/hooks/useActivityLogReport.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
134
src/pages/DetailedReports/hooks/useAgingReport.ts
Normal file
134
src/pages/DetailedReports/hooks/useAgingReport.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
117
src/pages/DetailedReports/hooks/useDateFilter.ts
Normal file
117
src/pages/DetailedReports/hooks/useDateFilter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
74
src/pages/DetailedReports/hooks/useDetailedReportsExports.ts
Normal file
74
src/pages/DetailedReports/hooks/useDetailedReportsExports.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
110
src/pages/DetailedReports/hooks/useLifecycleReport.ts
Normal file
110
src/pages/DetailedReports/hooks/useLifecycleReport.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
46
src/pages/DetailedReports/hooks/useThreshold.ts
Normal file
46
src/pages/DetailedReports/hooks/useThreshold.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
60
src/pages/DetailedReports/types/detailedReports.types.ts
Normal file
60
src/pages/DetailedReports/types/detailedReports.types.ts
Normal 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;
|
||||
}
|
||||
|
||||
69
src/pages/DetailedReports/utils/colorMappers.ts
Normal file
69
src/pages/DetailedReports/utils/colorMappers.ts
Normal 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';
|
||||
}
|
||||
|
||||
168
src/pages/DetailedReports/utils/csvExports.ts
Normal file
168
src/pages/DetailedReports/utils/csvExports.ts
Normal 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);
|
||||
}
|
||||
|
||||
72
src/pages/DetailedReports/utils/formatting.ts
Normal file
72
src/pages/DetailedReports/utils/formatting.ts
Normal 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';
|
||||
}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
79
src/pages/MyRequests/components/MyRequestsFilters.tsx
Normal file
79
src/pages/MyRequests/components/MyRequestsFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
58
src/pages/MyRequests/components/MyRequestsList.tsx
Normal file
58
src/pages/MyRequests/components/MyRequestsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
73
src/pages/MyRequests/components/MyRequestsStats.tsx
Normal file
73
src/pages/MyRequests/components/MyRequestsStats.tsx
Normal 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
Loading…
Reference in New Issue
Block a user