536 lines
25 KiB
TypeScript
536 lines
25 KiB
TypeScript
import { motion } from 'framer-motion';
|
|
import { useEffect } from 'react';
|
|
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, Minus, Plus, Info, Clock } from 'lucide-react';
|
|
import { FormData, SystemPolicy } from '@/hooks/useCreateRequestForm';
|
|
import { useMultiUserSearch } from '@/hooks/useUserSearch';
|
|
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;
|
|
systemPolicy: SystemPolicy;
|
|
onPolicyViolation: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => 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,
|
|
systemPolicy,
|
|
onPolicyViolation
|
|
}: ApprovalWorkflowStepProps) {
|
|
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
|
|
|
|
// Initialize approvers array when approverCount changes - moved from render to useEffect
|
|
useEffect(() => {
|
|
const approverCount = formData.approverCount || 1;
|
|
const currentApprovers = formData.approvers || [];
|
|
|
|
// Ensure we have the correct number of approvers
|
|
if (currentApprovers.length < approverCount) {
|
|
const newApprovers = [...currentApprovers];
|
|
// Fill missing approver slots
|
|
for (let i = currentApprovers.length; i < approverCount; i++) {
|
|
if (!newApprovers[i]) {
|
|
newApprovers[i] = {
|
|
email: '',
|
|
name: '',
|
|
level: i + 1,
|
|
tat: '' as any
|
|
};
|
|
}
|
|
}
|
|
updateFormData('approvers', newApprovers);
|
|
} else if (currentApprovers.length > approverCount) {
|
|
// Trim excess approvers if count was reduced
|
|
updateFormData('approvers', currentApprovers.slice(0, approverCount));
|
|
}
|
|
}, [formData.approverCount, updateFormData]);
|
|
|
|
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 {
|
|
// Check for duplicates in other approver slots (excluding current index)
|
|
const isDuplicateApprover = formData.approvers?.some(
|
|
(approver: any, idx: number) =>
|
|
idx !== index &&
|
|
(approver.userId === selectedUser.userId || approver.email?.toLowerCase() === selectedUser.email?.toLowerCase())
|
|
);
|
|
|
|
if (isDuplicateApprover) {
|
|
onValidationError({
|
|
type: 'error',
|
|
email: selectedUser.email,
|
|
message: 'This user is already added as an approver in another level.'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check for duplicates in spectators
|
|
const isDuplicateSpectator = formData.spectators?.some(
|
|
(spectator: any) => spectator.userId === selectedUser.userId || spectator.email?.toLowerCase() === selectedUser.email?.toLowerCase()
|
|
);
|
|
|
|
if (isDuplicateSpectator) {
|
|
onValidationError({
|
|
type: 'error',
|
|
email: selectedUser.email,
|
|
message: 'This user is already added as a spectator. A user cannot be both an approver and a spectator.'
|
|
});
|
|
return;
|
|
}
|
|
|
|
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 = currentCount + 1;
|
|
|
|
// Validate against system policy
|
|
if (newCount > systemPolicy.maxApprovalLevels) {
|
|
onPolicyViolation([{
|
|
type: 'Maximum Approval Levels Exceeded',
|
|
message: `Cannot add more than ${systemPolicy.maxApprovalLevels} approval levels. Please remove an approver level or contact your administrator.`,
|
|
currentValue: newCount,
|
|
maxValue: systemPolicy.maxApprovalLevels
|
|
}]);
|
|
return;
|
|
}
|
|
|
|
updateFormData('approverCount', newCount);
|
|
}}
|
|
disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels}
|
|
data-testid="approval-workflow-increase-count"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
<p className="text-sm text-gray-600 mt-2">
|
|
Maximum {systemPolicy.maxApprovalLevels} approver{systemPolicy.maxApprovalLevels !== 1 ? 's' : ''} 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);
|
|
|
|
// Ensure approver exists (should be initialized by useEffect, but provide fallback)
|
|
const approver = formData.approvers[index] || {
|
|
email: '',
|
|
name: '',
|
|
level: level,
|
|
tat: '' as any
|
|
};
|
|
|
|
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 ${
|
|
approver.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 ${
|
|
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-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>
|
|
{approver.email && approver.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={approver.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={approver.tatType === 'days' ? '7' : '24'}
|
|
min="1"
|
|
max={approver.tatType === 'days' ? '30' : '720'}
|
|
value={approver.tat || ''}
|
|
onChange={(e) => {
|
|
const newApprovers = [...formData.approvers];
|
|
newApprovers[index] = {
|
|
...newApprovers[index],
|
|
tat: parseInt(e.target.value) || '',
|
|
level: level,
|
|
tatType: approver.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={approver.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>
|
|
|
|
{/* TAT Summary Section */}
|
|
<div className="mt-6 space-y-4">
|
|
{/* Approval Flow Summary */}
|
|
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
|
<div className="flex items-start gap-3">
|
|
<Info className="w-5 h-5 text-blue-600 mt-0.5" />
|
|
<div>
|
|
<h4 className="font-semibold text-blue-900 mb-1">Approval Flow Summary</h4>
|
|
<p className="text-sm text-blue-700">
|
|
Your request will follow this sequence: <strong>You (Initiator)</strong> → {Array.from({ length: formData.approverCount || 1 }, (_, i) => `Level ${i + 1} Approver`).join(' → ')}. The final approver can close the request.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* TAT Summary */}
|
|
<div className="p-4 bg-gradient-to-r from-emerald-50 to-teal-50 rounded-lg border border-emerald-200">
|
|
<div className="flex items-start gap-3">
|
|
<Clock className="w-5 h-5 text-emerald-600 mt-0.5" />
|
|
<div className="flex-1">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h4 className="font-semibold text-emerald-900">TAT Summary</h4>
|
|
<div className="text-right">
|
|
{(() => {
|
|
// Calculate total calendar days (for display)
|
|
// Days: count as calendar days
|
|
// Hours: convert to calendar days (hours / 24)
|
|
const totalCalendarDays = formData.approvers?.reduce((sum: number, a: any) => {
|
|
const tat = Number(a.tat || 0);
|
|
const tatType = a.tatType || 'hours';
|
|
if (tatType === 'days') {
|
|
return sum + tat; // Calendar days
|
|
} else {
|
|
return sum + (tat / 24); // Convert hours to calendar days
|
|
}
|
|
}, 0) || 0;
|
|
const displayDays = Math.ceil(totalCalendarDays);
|
|
return (
|
|
<>
|
|
<div className="text-lg font-bold text-emerald-800">{displayDays} {displayDays === 1 ? 'Day' : 'Days'}</div>
|
|
<div className="text-xs text-emerald-600">Total Duration</div>
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{formData.approvers?.map((approver: any, idx: number) => {
|
|
const tat = Number(approver.tat || 0);
|
|
const tatType = approver.tatType || 'hours';
|
|
// Convert days to hours: 1 day = 24 hours
|
|
const hours = tatType === 'days' ? tat * 24 : tat;
|
|
if (!tat) return null;
|
|
return (
|
|
<div key={idx} className="bg-white/60 p-2 rounded border border-emerald-100">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-emerald-900">Level {idx + 1}</span>
|
|
<span className="text-sm text-emerald-700">{hours} {hours === 1 ? 'hour' : 'hours'}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{(() => {
|
|
// Convert all TAT to hours first
|
|
// Days: 1 day = 24 hours
|
|
// Hours: already in hours
|
|
const totalHours = formData.approvers?.reduce((sum: number, a: any) => {
|
|
const tat = Number(a.tat || 0);
|
|
const tatType = a.tatType || 'hours';
|
|
if (tatType === 'days') {
|
|
// 1 day = 24 hours
|
|
return sum + (tat * 24);
|
|
} else {
|
|
return sum + tat;
|
|
}
|
|
}, 0) || 0;
|
|
// Convert total hours to working days (8 hours per working day)
|
|
const workingDays = Math.ceil(totalHours / 8);
|
|
if (totalHours === 0) return null;
|
|
return (
|
|
<div className="bg-white/80 p-3 rounded border border-emerald-200">
|
|
<div className="grid grid-cols-2 gap-4 text-center">
|
|
<div>
|
|
<div className="text-lg font-bold text-emerald-800">{totalHours}{totalHours === 1 ? 'h' : 'h'}</div>
|
|
<div className="text-xs text-emerald-600">Total Hours</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-lg font-bold text-emerald-800">{workingDays}</div>
|
|
<div className="text-xs text-emerald-600">Working Days*</div>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-emerald-600 mt-2 text-center">*Based on 8-hour working days</p>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|