628 lines
23 KiB
TypeScript
628 lines
23 KiB
TypeScript
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>
|
||
);
|
||
}
|
||
|