code rafactor done for few screns
This commit is contained in:
parent
12d1094f45
commit
00f0b786f6
@ -27,41 +27,41 @@ export function KPICard({
|
|||||||
}: KPICardProps) {
|
}: KPICardProps) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<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}
|
onClick={onClick}
|
||||||
data-testid={testId}
|
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
|
<CardTitle
|
||||||
className="text-sm font-medium text-muted-foreground"
|
className="text-sm font-medium text-muted-foreground"
|
||||||
data-testid={`${testId}-title`}
|
data-testid={`${testId}-title`}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</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
|
<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`}
|
data-testid={`${testId}-icon`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex flex-col flex-1 py-3">
|
||||||
<div
|
<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`}
|
data-testid={`${testId}-value`}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<div
|
<div
|
||||||
className="text-xs text-muted-foreground mb-3"
|
className="text-xs text-muted-foreground mb-2"
|
||||||
data-testid={`${testId}-subtitle`}
|
data-testid={`${testId}-subtitle`}
|
||||||
>
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{children && (
|
{children && (
|
||||||
<div data-testid={`${testId}-children`}>
|
<div className="flex-1 flex flex-col" data-testid={`${testId}-children`}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export function StatCard({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className="text-xs text-gray-600 mb-1"
|
className="text-xs text-gray-600 mb-1 leading-tight"
|
||||||
data-testid={`${testId}-label`}
|
data-testid={`${testId}-label`}
|
||||||
>
|
>
|
||||||
{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 { useCallback, useRef } 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';
|
|
||||||
|
|
||||||
interface Request {
|
// Components
|
||||||
id: string;
|
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
|
||||||
title: string;
|
import { ClosedRequestsFilters as ClosedRequestsFiltersComponent } from './components/ClosedRequestsFilters';
|
||||||
description: string;
|
import { ClosedRequestsList } from './components/ClosedRequestsList';
|
||||||
status: 'approved' | 'rejected' | 'closed';
|
import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty';
|
||||||
priority: 'express' | 'standard';
|
import { ClosedRequestsPagination } from './components/ClosedRequestsPagination';
|
||||||
initiator: { name: string; avatar: string };
|
|
||||||
createdAt: string;
|
|
||||||
dueDate?: string;
|
|
||||||
reason?: string;
|
|
||||||
department?: string;
|
|
||||||
totalLevels?: number;
|
|
||||||
completedLevels?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClosedRequestsProps {
|
// Hooks
|
||||||
onViewRequest?: (requestId: string, requestTitle?: string) => void;
|
import { useClosedRequests } from './hooks/useClosedRequests';
|
||||||
}
|
import { useClosedRequestsFilters } from './hooks/useClosedRequestsFilters';
|
||||||
|
|
||||||
// Removed static data; will load from API
|
// Types
|
||||||
|
import type { ClosedRequestsProps } from './types/closedRequests.types';
|
||||||
// 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: ''
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
// Data fetching hook
|
||||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
const closedRequests = useClosedRequests({ itemsPerPage: 10 });
|
||||||
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);
|
|
||||||
|
|
||||||
// Pagination states
|
// Filters hook - use ref to avoid circular dependency
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const fetchRef = useRef(closedRequests.fetchRequests);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
fetchRef.current = closedRequests.fetchRequests;
|
||||||
const [totalRecords, setTotalRecords] = useState(0);
|
|
||||||
const [itemsPerPage] = useState(10);
|
|
||||||
|
|
||||||
// Fetch closed requests for the current user only (user-scoped, not organization-wide)
|
const filters = useClosedRequestsFilters({
|
||||||
// Note: This endpoint returns only requests where the user is:
|
onFiltersChange: useCallback(
|
||||||
// - An approver (for APPROVED, REJECTED, CLOSED requests)
|
(filters) => {
|
||||||
// - A spectator (for APPROVED, REJECTED, CLOSED requests)
|
// Reset to page 1 when filters change
|
||||||
// - An initiator (for REJECTED or CLOSED requests only, not APPROVED - those are in Open Requests)
|
fetchRef.current(1, {
|
||||||
// This applies to ALL users including regular users, MANAGEMENT, and ADMIN roles
|
search: filters.search || undefined,
|
||||||
// For organization-wide view, users should use the "All Requests" screen (/requests)
|
status: filters.status !== 'all' ? filters.status : undefined,
|
||||||
const fetchRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
|
priority: filters.priority !== 'all' ? filters.priority : undefined,
|
||||||
try {
|
sortBy: filters.sortBy,
|
||||||
if (page === 1) {
|
sortOrder: filters.sortOrder,
|
||||||
setLoading(true);
|
|
||||||
setItems([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
// Page change handler
|
||||||
if (newPage >= 1 && newPage <= totalPages) {
|
const handlePageChange = useCallback(
|
||||||
setCurrentPage(newPage);
|
(newPage: number) => {
|
||||||
fetchRequests(newPage, {
|
if (newPage >= 1 && newPage <= closedRequests.pagination.totalPages) {
|
||||||
search: searchTerm || undefined,
|
closedRequests.fetchRequests(newPage, {
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
search: filters.searchTerm || undefined,
|
||||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
sortBy,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
sortOrder
|
sortBy: filters.sortBy,
|
||||||
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[closedRequests, filters]
|
||||||
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;
|
|
||||||
|
|
||||||
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="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>
|
|
||||||
|
|
||||||
{/* 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>
|
// 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,
|
||||||
|
});
|
||||||
|
}, [closedRequests, filters]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Requests List */}
|
||||||
|
<ClosedRequestsList
|
||||||
|
requests={closedRequests.requests}
|
||||||
|
loading={closedRequests.loading}
|
||||||
|
onViewRequest={onViewRequest}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{filteredAndSortedRequests.length === 0 && (
|
{closedRequests.requests.length === 0 && !closedRequests.loading && (
|
||||||
<Card className="shadow-lg border-0">
|
<ClosedRequestsEmpty
|
||||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
searchTerm={filters.searchTerm}
|
||||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
activeFiltersCount={filters.activeFiltersCount}
|
||||||
<FileText className="h-8 w-8 text-gray-400" />
|
onClearFilters={filters.clearFilters}
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination Controls */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && !loading && (
|
{!closedRequests.loading && (
|
||||||
<Card className="shadow-md">
|
<ClosedRequestsPagination
|
||||||
<CardContent className="p-4">
|
pagination={closedRequests.pagination}
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
|
onPageChange={handlePageChange}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { useCallback, useRef } from 'react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { FileText } from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import {
|
|
||||||
FileText,
|
|
||||||
Search,
|
|
||||||
Clock,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
User,
|
|
||||||
ArrowRight,
|
|
||||||
TrendingUp,
|
|
||||||
Edit,
|
|
||||||
Flame,
|
|
||||||
Target,
|
|
||||||
AlertCircle
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import workflowApi from '@/services/workflowApi';
|
|
||||||
import { PageHeader } from '@/components/common/PageHeader';
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
import { StatsCard } from '@/components/dashboard/StatsCard';
|
|
||||||
import { Pagination } from '@/components/common/Pagination';
|
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 {
|
interface MyRequestsProps {
|
||||||
onViewRequest: (requestId: string, requestTitle?: string, status?: string) => void;
|
onViewRequest: (requestId: string, requestTitle?: string, status?: string) => void;
|
||||||
dynamicRequests?: any[];
|
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) {
|
export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
// Data fetching hook
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const myRequests = useMyRequests({ itemsPerPage: 10 });
|
||||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
|
||||||
const [apiRequests, setApiRequests] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [hasFetchedFromApi, setHasFetchedFromApi] = useState(false);
|
|
||||||
|
|
||||||
// Pagination states
|
// Filters hook - use ref to avoid circular dependency
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const fetchRef = useRef(myRequests.fetchMyRequests);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
fetchRef.current = myRequests.fetchMyRequests;
|
||||||
const [totalRecords, setTotalRecords] = useState(0);
|
|
||||||
const [itemsPerPage] = useState(10);
|
|
||||||
|
|
||||||
const fetchMyRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string }) => {
|
const filters = useMyRequestsFilters({
|
||||||
try {
|
onFiltersChange: useCallback(
|
||||||
if (page === 1) {
|
(filters: MyRequestsFilters) => {
|
||||||
setLoading(true);
|
// Reset to page 1 when filters change
|
||||||
setApiRequests([]);
|
fetchRef.current(1, {
|
||||||
}
|
search: filters.search || undefined,
|
||||||
|
status: filters.status !== 'all' ? filters.status : undefined,
|
||||||
const result = await workflowApi.listMyWorkflows({
|
priority: filters.priority !== 'all' ? filters.priority : undefined,
|
||||||
page,
|
});
|
||||||
limit: itemsPerPage,
|
},
|
||||||
search: filters?.search,
|
[]
|
||||||
status: filters?.status,
|
),
|
||||||
priority: filters?.priority
|
|
||||||
});
|
});
|
||||||
console.log('[MyRequests] API Response:', result); // Debug log
|
|
||||||
|
|
||||||
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
// Handle dynamic requests (fallback until API loads)
|
||||||
const items = Array.isArray((result as any)?.data)
|
const convertedDynamicRequests = transformRequests(dynamicRequests);
|
||||||
? (result as any).data
|
const sourceRequests = myRequests.hasFetchedFromApi ? myRequests.requests : convertedDynamicRequests;
|
||||||
: [];
|
|
||||||
|
|
||||||
console.log('[MyRequests] Parsed items:', items); // Debug log
|
// Stats calculation
|
||||||
|
const stats = useMyRequestsStats({
|
||||||
|
requests: sourceRequests,
|
||||||
|
totalRecords: myRequests.pagination.totalRecords,
|
||||||
|
});
|
||||||
|
|
||||||
setApiRequests(items);
|
// Page change handler
|
||||||
setHasFetchedFromApi(true);
|
const handlePageChange = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
// Set pagination data
|
if (newPage >= 1 && newPage <= myRequests.pagination.totalPages) {
|
||||||
const pagination = (result as any)?.pagination;
|
myRequests.fetchMyRequests(newPage, {
|
||||||
if (pagination) {
|
search: filters.searchTerm || undefined,
|
||||||
setCurrentPage(pagination.page || 1);
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
setTotalPages(pagination.totalPages || 1);
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
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
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[myRequests, filters]
|
||||||
// Initial fetch on mount
|
);
|
||||||
useEffect(() => {
|
|
||||||
fetchMyRequests(1, {
|
|
||||||
search: searchTerm || undefined,
|
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
|
||||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined
|
|
||||||
});
|
|
||||||
}, [fetchMyRequests]);
|
|
||||||
|
|
||||||
// Fetch when filters change (with debouncing for search)
|
|
||||||
useEffect(() => {
|
|
||||||
// Debounce search: wait 500ms after user stops typing
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
if (hasFetchedFromApi) { // Only refetch if we've already loaded data once
|
|
||||||
setCurrentPage(1); // Reset to page 1 when filters change
|
|
||||||
fetchMyRequests(1, {
|
|
||||||
search: searchTerm || undefined,
|
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
|
||||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, searchTerm ? 500 : 0); // Debounce only for search, instant for dropdowns
|
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}, [searchTerm, statusFilter, priorityFilter, hasFetchedFromApi, fetchMyRequests]);
|
|
||||||
|
|
||||||
// Convert API/dynamic requests to the format expected by this component
|
|
||||||
// Once API has fetched (even if empty), always use API data, never fall back to props
|
|
||||||
const sourceRequests = hasFetchedFromApi ? apiRequests : dynamicRequests;
|
|
||||||
const convertedDynamicRequests = Array.isArray(sourceRequests) ? sourceRequests.map((req: any) => {
|
|
||||||
const createdAt = req.submittedAt || req.submitted_at || req.createdAt || req.created_at;
|
|
||||||
const priority = (req.priority || '').toString().toLowerCase();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: req.requestNumber || req.request_number || req.requestId || req.id || req.request_id,
|
|
||||||
requestId: req.requestId || req.id || req.request_id,
|
|
||||||
displayId: req.requestNumber || req.request_number || req.id,
|
|
||||||
title: req.title,
|
|
||||||
description: req.description,
|
|
||||||
status: (req.status || '').toString().toLowerCase().replace('_','-'),
|
|
||||||
priority: priority,
|
|
||||||
department: req.department,
|
|
||||||
submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined),
|
|
||||||
createdAt: createdAt,
|
|
||||||
currentApprover: req.currentApprover?.name || req.currentApprover?.email || '—',
|
|
||||||
approverLevel: req.currentLevel && req.totalLevels ? `${req.currentLevel} of ${req.totalLevels}` : (req.currentStep && req.totalSteps ? `${req.currentStep} of ${req.totalSteps}` : '—'),
|
|
||||||
templateType: req.templateType,
|
|
||||||
templateName: req.templateName
|
|
||||||
};
|
|
||||||
}) : [];
|
|
||||||
|
|
||||||
// Use only API/dynamic requests - backend already filtered
|
|
||||||
const allRequests = convertedDynamicRequests;
|
|
||||||
|
|
||||||
// No frontend filtering - backend handles all filtering
|
|
||||||
const filteredRequests = allRequests;
|
|
||||||
|
|
||||||
// Stats calculation - using total from pagination for total count
|
|
||||||
const stats = {
|
|
||||||
total: totalRecords || allRequests.length,
|
|
||||||
pending: allRequests.filter(r => r.status === 'pending').length,
|
|
||||||
approved: allRequests.filter(r => r.status === 'approved').length,
|
|
||||||
rejected: allRequests.filter(r => r.status === 'rejected').length,
|
|
||||||
draft: allRequests.filter(r => r.status === 'draft').length,
|
|
||||||
closed: allRequests.filter(r => r.status === 'closed').length
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="my-requests-page">
|
<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"
|
title="My Requests"
|
||||||
description="Track and manage all your submitted requests"
|
description="Track and manage all your submitted requests"
|
||||||
badge={{
|
badge={{
|
||||||
value: `${totalRecords || allRequests.length} total`,
|
value: `${myRequests.pagination.totalRecords || sourceRequests.length} total`,
|
||||||
label: 'requests',
|
label: 'requests',
|
||||||
loading
|
loading: myRequests.loading,
|
||||||
}}
|
}}
|
||||||
testId="my-requests-header"
|
testId="my-requests-header"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stats Overview */}
|
{/* Stats Overview */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 sm:gap-4" data-testid="my-requests-stats">
|
<MyRequestsStatsSection stats={stats} />
|
||||||
<StatsCard
|
|
||||||
label="Total"
|
|
||||||
value={stats.total}
|
|
||||||
icon={FileText}
|
|
||||||
iconColor="text-blue-600"
|
|
||||||
gradient="bg-gradient-to-br from-blue-50 to-blue-100 border-blue-200"
|
|
||||||
textColor="text-blue-700"
|
|
||||||
valueColor="text-blue-900"
|
|
||||||
testId="stat-total"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatsCard
|
|
||||||
label="Pending"
|
|
||||||
value={stats.pending}
|
|
||||||
icon={Clock}
|
|
||||||
iconColor="text-orange-600"
|
|
||||||
gradient="bg-gradient-to-br from-orange-50 to-orange-100 border-orange-200"
|
|
||||||
textColor="text-orange-700"
|
|
||||||
valueColor="text-orange-900"
|
|
||||||
testId="stat-pending"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatsCard
|
|
||||||
label="Approved"
|
|
||||||
value={stats.approved}
|
|
||||||
icon={CheckCircle}
|
|
||||||
iconColor="text-green-600"
|
|
||||||
gradient="bg-gradient-to-br from-green-50 to-green-100 border-green-200"
|
|
||||||
textColor="text-green-700"
|
|
||||||
valueColor="text-green-900"
|
|
||||||
testId="stat-approved"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatsCard
|
|
||||||
label="Rejected"
|
|
||||||
value={stats.rejected}
|
|
||||||
icon={XCircle}
|
|
||||||
iconColor="text-red-600"
|
|
||||||
gradient="bg-gradient-to-br from-red-50 to-red-100 border-red-200"
|
|
||||||
textColor="text-red-700"
|
|
||||||
valueColor="text-red-900"
|
|
||||||
testId="stat-rejected"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatsCard
|
|
||||||
label="Draft"
|
|
||||||
value={stats.draft}
|
|
||||||
icon={Edit}
|
|
||||||
iconColor="text-gray-600"
|
|
||||||
gradient="bg-gradient-to-br from-gray-50 to-gray-100 border-gray-200"
|
|
||||||
textColor="text-gray-700"
|
|
||||||
valueColor="text-gray-900"
|
|
||||||
testId="stat-draft"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters and Search */}
|
{/* Filters and Search */}
|
||||||
<Card className="border-gray-200" data-testid="my-requests-filters">
|
<MyRequestsFiltersComponent
|
||||||
<CardContent className="p-3 sm:p-4 md:p-6">
|
searchTerm={filters.searchTerm}
|
||||||
<div className="flex flex-col md:flex-row gap-3 sm:gap-4 items-start md:items-center">
|
statusFilter={filters.statusFilter}
|
||||||
<div className="flex-1 relative w-full">
|
priorityFilter={filters.priorityFilter}
|
||||||
<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" />
|
onSearchChange={filters.setSearchTerm}
|
||||||
<Input
|
onStatusChange={filters.setStatusFilter}
|
||||||
placeholder="Search requests by title, description, or ID..."
|
onPriorityChange={filters.setPriorityFilter}
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="pl-9 text-sm sm:text-base bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
|
||||||
data-testid="search-input"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 sm:gap-3 w-full md:w-auto">
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
||||||
<SelectTrigger
|
|
||||||
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
|
||||||
data-testid="status-filter"
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
|
||||||
<SelectItem value="draft">Draft</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
|
||||||
<SelectTrigger
|
|
||||||
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
|
||||||
data-testid="priority-filter"
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Priority" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Priority</SelectItem>
|
|
||||||
<SelectItem value="express">Express</SelectItem>
|
|
||||||
<SelectItem value="standard">Standard</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Requests List */}
|
{/* Requests List */}
|
||||||
<div className="space-y-4" data-testid="my-requests-list">
|
<MyRequestsList
|
||||||
{loading ? (
|
requests={sourceRequests}
|
||||||
<Card data-testid="loading-state">
|
loading={myRequests.loading}
|
||||||
<CardContent className="p-6 text-sm text-gray-600">Loading your requests…</CardContent>
|
searchTerm={filters.searchTerm}
|
||||||
</Card>
|
statusFilter={filters.statusFilter}
|
||||||
) : filteredRequests.length === 0 ? (
|
priorityFilter={filters.priorityFilter}
|
||||||
<Card data-testid="empty-state">
|
onViewRequest={onViewRequest}
|
||||||
<CardContent className="p-12 text-center">
|
/>
|
||||||
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No requests found</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{searchTerm || statusFilter !== 'all' || priorityFilter !== 'all'
|
|
||||||
? 'Try adjusting your search or filters'
|
|
||||||
: 'You haven\'t created any requests yet'}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
filteredRequests.map((request, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={request.id}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: index * 0.1 }}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
className="group hover:shadow-lg transition-all duration-300 cursor-pointer border border-gray-200 shadow-sm hover:shadow-md"
|
|
||||||
onClick={() => onViewRequest(request.id, request.title, request.status)}
|
|
||||||
data-testid={`request-card-${request.id}`}
|
|
||||||
>
|
|
||||||
<CardContent className="p-3 sm:p-6">
|
|
||||||
<div className="space-y-3 sm:space-y-4">
|
|
||||||
{/* Header with Title and Status Badges */}
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h4
|
|
||||||
className="text-base sm:text-lg font-semibold text-gray-900 mb-2 group-hover:text-blue-600 transition-colors line-clamp-2"
|
|
||||||
data-testid="request-title"
|
|
||||||
>
|
|
||||||
{request.title}
|
|
||||||
</h4>
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mb-2">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`${getStatusConfig(request.status).color} border font-medium text-xs shrink-0`}
|
|
||||||
data-testid="status-badge"
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const IconComponent = getStatusConfig(request.status).icon;
|
|
||||||
return <IconComponent className="w-3 h-3 mr-1" />;
|
|
||||||
})()}
|
|
||||||
<span className="capitalize">{request.status}</span>
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`${getPriorityConfig(request.priority).color} border font-medium text-xs capitalize shrink-0`}
|
|
||||||
data-testid="priority-badge"
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const IconComponent = getPriorityConfig(request.priority).icon;
|
|
||||||
return <IconComponent className="w-3 h-3 mr-1" />;
|
|
||||||
})()}
|
|
||||||
{request.priority}
|
|
||||||
</Badge>
|
|
||||||
{(request as any).templateType && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="bg-purple-100 text-purple-700 text-xs shrink-0 hidden sm:inline-flex"
|
|
||||||
data-testid="template-badge"
|
|
||||||
>
|
|
||||||
<FileText className="w-3 h-3 mr-1" />
|
|
||||||
Template: {(request as any).templateName}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3 line-clamp-2"
|
|
||||||
data-testid="request-description"
|
|
||||||
>
|
|
||||||
{request.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4 text-xs sm:text-sm text-gray-500">
|
|
||||||
<span className="truncate" data-testid="request-id-display">
|
|
||||||
<span className="font-medium">ID:</span> {(request as any).displayId || request.id}
|
|
||||||
</span>
|
|
||||||
<span className="truncate" data-testid="submitted-date">
|
|
||||||
<span className="font-medium">Submitted:</span> {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ArrowRight className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 group-hover:text-blue-600 transition-colors flex-shrink-0 mt-1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current Approver and Level Info */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 pt-3 border-t border-gray-100">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<User className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
|
|
||||||
<span className="text-xs sm:text-sm truncate" data-testid="current-approver">
|
|
||||||
<span className="text-gray-500">Current Approver:</span> <span className="text-gray-900 font-medium">{request.currentApprover}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
|
|
||||||
<span className="text-xs sm:text-sm" data-testid="approval-level">
|
|
||||||
<span className="text-gray-500">Approval Level:</span> <span className="text-gray-900 font-medium">{request.approverLevel}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
|
||||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
|
||||||
<span data-testid="submitted-timestamp">
|
|
||||||
Submitted: {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={myRequests.pagination.currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={myRequests.pagination.totalPages}
|
||||||
totalRecords={totalRecords}
|
totalRecords={myRequests.pagination.totalRecords}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={myRequests.pagination.itemsPerPage}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
loading={loading}
|
loading={myRequests.loading}
|
||||||
itemLabel="requests"
|
itemLabel="requests"
|
||||||
testIdPrefix="my-requests-pagination"
|
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