Re_Figma_Code/src/components/participant/AddApproverModal/AddApproverModal.tsx
2025-11-10 16:13:38 +05:30

628 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useRef, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
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 { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Users, X, AtSign, Clock, Shield, CheckCircle, XCircle, AlertCircle, Lightbulb } from 'lucide-react';
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
interface ApprovalLevelInfo {
levelNumber: number;
approverName: string;
status: string;
tatHours: number;
}
interface AddApproverModalProps {
open: boolean;
onClose: () => void;
onConfirm: (email: string, tatHours: number, level: number) => Promise<void> | void;
requestIdDisplay?: string;
requestTitle?: string;
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
currentLevels?: ApprovalLevelInfo[]; // Current approval levels
}
export function AddApproverModal({
open,
onClose,
onConfirm,
requestIdDisplay,
requestTitle,
existingParticipants = [],
currentLevels = []
}: AddApproverModalProps) {
const [email, setEmail] = useState('');
const [tatHours, setTatHours] = useState<number>(24);
const [selectedLevel, setSelectedLevel] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null); // Track if user was selected via @ search
const searchTimer = useRef<any>(null);
// Validation modal state
const [validationModal, setValidationModal] = useState<{
open: boolean;
type: 'error' | 'not-found';
email: string;
message: string;
}>({
open: false,
type: 'error',
email: '',
message: ''
});
// Calculate available levels (after completed levels)
const completedLevels = currentLevels.filter(l =>
l && (l.status === 'approved' || l.status === 'rejected' || l.status === 'skipped')
);
const minLevel = Math.max(1, completedLevels.length + 1);
const maxLevel = Math.max(1, currentLevels.length + 1);
const availableLevels = maxLevel >= minLevel
? Array.from({ length: maxLevel - minLevel + 1 }, (_, i) => minLevel + i)
: [minLevel];
// Auto-select first available level
useEffect(() => {
if (availableLevels.length > 0 && selectedLevel === null) {
setSelectedLevel(availableLevels[0] || null);
}
}, [availableLevels.length, selectedLevel]);
const handleConfirm = async () => {
const emailToAdd = email.trim().toLowerCase();
if (!emailToAdd) {
setValidationModal({
open: true,
type: 'error',
email: '',
message: 'Please enter an email address'
});
return;
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(emailToAdd)) {
setValidationModal({
open: true,
type: 'error',
email: emailToAdd,
message: 'Please enter a valid email address'
});
return;
}
// Validate TAT hours
if (!tatHours || tatHours <= 0) {
setValidationModal({
open: true,
type: 'error',
email: '',
message: 'Please enter valid TAT hours (minimum 1 hour)'
});
return;
}
if (tatHours > 720) {
setValidationModal({
open: true,
type: 'error',
email: '',
message: 'TAT hours cannot exceed 720 hours (30 days)'
});
return;
}
// Validate level
if (!selectedLevel) {
setValidationModal({
open: true,
type: 'error',
email: '',
message: 'Please select an approval level'
});
return;
}
if (selectedLevel < minLevel) {
setValidationModal({
open: true,
type: 'error',
email: '',
message: `Cannot add approver at level ${selectedLevel}. Minimum allowed level is ${minLevel} (after completed levels)`
});
return;
}
// Check if user is already a participant
const existingParticipant = existingParticipants.find(
p => (p.email || '').toLowerCase() === emailToAdd
);
if (existingParticipant) {
const participantType = existingParticipant.participantType?.toUpperCase() || 'PARTICIPANT';
const userName = existingParticipant.name || emailToAdd;
if (participantType === 'INITIATOR') {
setValidationModal({
open: true,
type: 'error',
email: emailToAdd,
message: `${userName} is the request initiator and cannot be added as an approver.`
});
return;
} else if (participantType === 'APPROVER') {
setValidationModal({
open: true,
type: 'error',
email: emailToAdd,
message: `${userName} is already an approver on this request.`
});
return;
} else if (participantType === 'SPECTATOR') {
setValidationModal({
open: true,
type: 'error',
email: emailToAdd,
message: `${userName} is currently a spectator on this request and cannot be added as an approver. Please remove them as spectator first.`
});
return;
} else {
setValidationModal({
open: true,
type: 'error',
email: emailToAdd,
message: `${userName} is already a participant on this request.`
});
return;
}
}
// If user was NOT selected via @ search, validate against Okta
if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) {
try {
const searchOktaResults = await searchUsers(emailToAdd, 1);
if (searchOktaResults.length === 0) {
// User not found in Okta
setValidationModal({
open: true,
type: 'not-found',
email: emailToAdd,
message: ''
});
return;
}
// User found - ensure they exist in DB
const foundUser = searchOktaResults[0];
await ensureUserExists({
userId: foundUser.userId,
email: foundUser.email,
displayName: foundUser.displayName,
firstName: foundUser.firstName,
lastName: foundUser.lastName,
department: foundUser.department
});
console.log(`✅ Validated approver: ${foundUser.displayName} (${foundUser.email})`);
} catch (error) {
console.error('Failed to validate approver:', error);
setValidationModal({
open: true,
type: 'error',
email: emailToAdd,
message: 'Failed to validate user. Please try again.'
});
return;
}
}
try {
setIsSubmitting(true);
await onConfirm(emailToAdd, tatHours, selectedLevel);
setEmail('');
setTatHours(24);
setSelectedLevel(null);
setSelectedUser(null);
onClose();
} catch (error) {
console.error('Failed to add approver:', error);
// Error handling is done in the parent component
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
setEmail('');
setTatHours(24);
setSelectedLevel(null);
setSelectedUser(null);
setSearchResults([]);
setIsSearching(false);
onClose();
}
};
// Get status icon
const getStatusIcon = (status: string) => {
const statusLower = status.toLowerCase();
if (statusLower === 'approved') return <CheckCircle className="w-4 h-4 text-green-600" />;
if (statusLower === 'rejected') return <XCircle className="w-4 h-4 text-red-600" />;
if (statusLower === 'skipped') return <AlertCircle className="w-4 h-4 text-orange-600" />;
if (statusLower === 'in-review' || statusLower === 'pending') return <Clock className="w-4 h-4 text-blue-600" />;
return <Clock className="w-4 h-4 text-gray-400" />;
};
// Cleanup search timer on unmount
useEffect(() => {
return () => {
if (searchTimer.current) {
clearTimeout(searchTimer.current);
}
};
}, []);
// Handle user search with @ mention
const handleEmailChange = (value: string) => {
setEmail(value);
// Clear selectedUser when manually editing (forces revalidation)
if (selectedUser && selectedUser.email.toLowerCase() !== value.toLowerCase()) {
setSelectedUser(null);
}
// Clear existing timer
if (searchTimer.current) {
clearTimeout(searchTimer.current);
}
// Only trigger search when using @ sign
if (!value || !value.startsWith('@') || value.length < 2) {
setSearchResults([]);
setIsSearching(false);
return;
}
// Start search with debounce
setIsSearching(true);
searchTimer.current = setTimeout(async () => {
try {
const term = value.slice(1); // Remove @ prefix
const results = await searchUsers(term, 10);
setSearchResults(results);
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, 300);
};
// Select user from search results
const handleSelectUser = async (user: UserSummary) => {
// Ensure user exists in DB when selected via @ search
try {
await ensureUserExists({
userId: user.userId,
email: user.email,
displayName: user.displayName,
firstName: user.firstName,
lastName: user.lastName,
department: user.department
});
setEmail(user.email);
setSelectedUser(user); // Track that user was selected via @ search
setSearchResults([]);
setIsSearching(false);
console.log(`✅ User selected and verified: ${user.displayName} (${user.email})`);
} catch (error) {
console.error('Failed to ensure user exists:', error);
setValidationModal({
open: true,
type: 'error',
email: user.email,
message: 'Failed to verify user in database. Please try again.'
});
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md max-h-[90vh] flex flex-col p-0">
<button
onClick={handleClose}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none z-50"
disabled={isSubmitting}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-blue-600" />
</div>
<DialogTitle className="text-xl font-bold text-gray-900">Add Approver</DialogTitle>
</div>
</DialogHeader>
<div className="space-y-4 px-6 py-4 overflow-y-auto flex-1">
{/* Description */}
<p className="text-sm text-gray-600 leading-relaxed">
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
</p>
{/* Current Levels Display */}
{currentLevels.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-semibold text-gray-700">Current Approval Levels</Label>
<div className="max-h-40 overflow-y-auto space-y-2 border rounded-lg p-3 bg-gray-50">
{currentLevels.map((level) => (
<div
key={level.levelNumber}
className={`flex items-center justify-between p-2 rounded-md ${
level.status === 'approved' ? 'bg-green-100 border border-green-200' :
level.status === 'rejected' ? 'bg-red-100 border border-red-200' :
level.status === 'skipped' ? 'bg-orange-100 border border-orange-200' :
'bg-white border border-gray-200'
}`}
>
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-blue-600 text-white text-xs font-semibold flex items-center justify-center">
{level.levelNumber}
</div>
<div>
<p className="text-sm font-medium text-gray-900">{level.approverName}</p>
<p className="text-xs text-gray-500">{level.tatHours}h TAT</p>
</div>
</div>
<div className="flex items-center gap-2">
{getStatusIcon(level.status)}
<Badge
variant="outline"
className={`text-xs ${
level.status === 'approved' ? 'bg-green-50 text-green-700 border-green-300' :
level.status === 'rejected' ? 'bg-red-50 text-red-700 border-red-300' :
level.status === 'skipped' ? 'bg-orange-50 text-orange-700 border-orange-300' :
'bg-blue-50 text-blue-700 border-blue-300'
}`}
>
{level.status}
</Badge>
</div>
</div>
))}
</div>
<p className="text-xs text-gray-500">
New approver can only be added at level {minLevel} or higher (after completed levels)
</p>
</div>
)}
{/* Level Selection */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">Approval Level *</Label>
<Select
value={selectedLevel?.toString() || ''}
onValueChange={(value) => setSelectedLevel(Number(value))}
disabled={isSubmitting}
>
<SelectTrigger className="h-11 border-gray-300">
<SelectValue placeholder="Select level" />
</SelectTrigger>
<SelectContent>
{availableLevels.map((level) => (
<SelectItem key={level} value={level.toString()}>
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-600" />
<span>Level {level}</span>
{level <= currentLevels.length && (
<span className="text-xs text-gray-500">
(will shift existing Level {level})
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
Choose where to insert the new approver. Existing levels will be automatically shifted.
</p>
</div>
{/* TAT Hours Input */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">TAT (Turn Around Time) *</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
max="720"
value={tatHours}
onChange={(e) => setTatHours(Number(e.target.value))}
className="h-11 border-gray-300 flex-1"
disabled={isSubmitting}
placeholder="24"
/>
<div className="flex items-center gap-1 text-sm text-gray-600 bg-gray-100 px-3 h-11 rounded-md border border-gray-300">
<Clock className="w-4 h-4" />
hours
</div>
</div>
<p className="text-xs text-gray-500">
Maximum time for this approver to respond (1-720 hours)
</p>
</div>
{/* Email Input with @ Search */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Email Address *</label>
<div className="relative">
<AtSign className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4 z-10" />
<Input
type="text"
placeholder="@username or user@example.com"
value={email}
onChange={(e) => handleEmailChange(e.target.value)}
className="pl-10 h-11 border-gray-300"
disabled={isSubmitting}
autoFocus
/>
{/* Search Results Dropdown */}
{(isSearching || searchResults.length > 0) && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg max-h-60 overflow-auto">
{isSearching ? (
<div className="p-3 text-sm text-gray-500">Searching users...</div>
) : searchResults.length > 0 ? (
<ul className="divide-y">
{searchResults.map((user) => (
<li
key={user.userId}
className="p-3 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => handleSelectUser(user)}
>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-blue-100 text-blue-800 text-xs font-semibold">
{(user.displayName || user.email)
.split(' ')
.map(s => s[0])
.join('')
.slice(0, 2)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">
{user.displayName || [user.firstName, user.lastName].filter(Boolean).join(' ') || user.email}
</p>
<p className="text-xs text-gray-600 truncate">{user.email}</p>
{user.designation && (
<p className="text-xs text-gray-500">{user.designation}</p>
)}
</div>
</div>
</li>
))}
</ul>
) : null}
</div>
)}
</div>
<p className="text-xs text-gray-500">
Type <span className="font-semibold">@username</span> to search for users, or enter email directly.
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-3 px-6 py-4 border-t flex-shrink-0 bg-white">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="flex-1 h-11 border-gray-300"
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="button"
onClick={handleConfirm}
className="flex-1 h-11 bg-[#1a472a] hover:bg-[#152e1f] text-white"
disabled={isSubmitting || !email.trim() || !selectedLevel || !tatHours}
>
<Users className="w-4 h-4 mr-2" />
{isSubmitting ? 'Adding...' : `Add at Level ${selectedLevel || '?'}`}
</Button>
</div>
</DialogContent>
{/* Validation Error Modal */}
<Dialog open={validationModal.open} onOpenChange={(isOpen) => setValidationModal(prev => ({ ...prev, open: isOpen }))}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{validationModal.type === 'not-found' ? (
<>
<AlertCircle className="w-5 h-5 text-red-600" />
User Not Found
</>
) : (
<>
<AlertCircle className="w-5 h-5 text-red-600" />
Validation Error
</>
)}
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
{validationModal.type === 'not-found' && (
<>
<p className="text-gray-700">
User <strong>{validationModal.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 users from the directory.
</p>
</div>
</>
)}
{validationModal.type === 'error' && (
<>
{validationModal.email && (
<p className="text-gray-700">
Failed to validate <strong>{validationModal.email}</strong>.
</p>
)}
{validationModal.message && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<p className="text-sm text-gray-700">{validationModal.message}</p>
</div>
)}
</>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={() => setValidationModal(prev => ({ ...prev, open: false }))}
className="w-full sm:w-auto"
>
OK
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Dialog>
);
}