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 { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Eye, X, AtSign, AlertCircle, Lightbulb } from 'lucide-react'; import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi'; import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi'; import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal'; interface AddSpectatorModalProps { open: boolean; onClose: () => void; onConfirm: (email: string) => Promise | void; requestIdDisplay?: string; requestTitle?: string; existingParticipants?: Array<{ email: string; participantType: string; name?: string }>; } export function AddSpectatorModal({ open, onClose, onConfirm, existingParticipants = [] }: AddSpectatorModalProps) { const [email, setEmail] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [selectedUser, setSelectedUser] = useState(null); // Track if user was selected via @ search const searchTimer = useRef(null); const searchContainerRef = useRef(null); // Ref for auto-scroll // Validation modal state const [validationModal, setValidationModal] = useState<{ open: boolean; type: 'error' | 'not-found'; email: string; message: string; }>({ open: false, type: 'error', email: '', message: '' }); // Policy violation modal state const [policyViolationModal, setPolicyViolationModal] = useState<{ open: boolean; violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>; }>({ open: false, violations: [] }); // System policy configuration state const [systemPolicy, setSystemPolicy] = useState<{ maxApprovalLevels: number; maxParticipants: number; allowSpectators: boolean; maxSpectators: number; }>({ maxApprovalLevels: 10, maxParticipants: 50, allowSpectators: true, maxSpectators: 20 }); // Fetch system policy on mount useEffect(() => { const loadSystemPolicy = async () => { try { const workflowConfigs = await getAllConfigurations('WORKFLOW_SHARING'); const tatConfigs = await getAllConfigurations('TAT_SETTINGS'); const allConfigs = [...workflowConfigs, ...tatConfigs]; const configMap: Record = {}; 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 system policy:', error); // Use defaults if loading fails } }; if (open) { loadSystemPolicy(); } }, [open]); 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; } // 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 a spectator.` }); return; } else if (participantType === 'APPROVER') { setValidationModal({ open: true, type: 'error', email: emailToAdd, message: `${userName} is already an approver on this request and cannot be added as a spectator.` }); return; } else if (participantType === 'SPECTATOR') { setValidationModal({ open: true, type: 'error', email: emailToAdd, message: `${userName} is already a spectator on this request.` }); return; } else { setValidationModal({ open: true, type: 'error', email: emailToAdd, message: `${userName} is already a participant on this request.` }); return; } } // Policy validation before adding spectator const violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }> = []; // Check if spectators are allowed if (!systemPolicy.allowSpectators) { violations.push({ type: 'Spectators Not Allowed', message: `Adding spectators is not allowed by system policy.`, }); } // Count existing spectators const existingSpectators = existingParticipants.filter( p => (p.participantType || '').toUpperCase() === 'SPECTATOR' ); const currentSpectatorCount = existingSpectators.length; // Check maximum spectators if (currentSpectatorCount >= systemPolicy.maxSpectators) { violations.push({ type: 'Maximum Spectators Exceeded', message: `This request has reached the maximum number of spectators allowed.`, currentValue: currentSpectatorCount, maxValue: systemPolicy.maxSpectators }); } // Count existing participants (initiator + approvers + spectators) const totalParticipants = existingParticipants.length + 1; // +1 for the new spectator // Check maximum participants 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 }); } // If there are policy violations, show modal and return if (violations.length > 0) { setPolicyViolationModal({ open: true, violations }); return; } // If user was NOT selected via @ search, validate against Okta if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) { try { const response = await searchUsers(emailToAdd, 1); // Backend returns { success: true, data: [...users] } const searchOktaResults = response.data?.data || []; 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, 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 }); console.log(`✅ Validated spectator: ${foundUser.displayName} (${foundUser.email})`); } catch (error) { console.error('Failed to validate spectator:', error); setValidationModal({ open: true, type: 'error', email: emailToAdd, message: 'Failed to validate user. Please try again.' }); return; } } try { setIsSubmitting(true); await onConfirm(emailToAdd); setEmail(''); setSelectedUser(null); onClose(); } catch (error) { console.error('Failed to add spectator:', error); // Error handling is done in the parent component } finally { setIsSubmitting(false); } }; const handleClose = () => { if (!isSubmitting) { setEmail(''); setSelectedUser(null); setSearchResults([]); setIsSearching(false); onClose(); } }; // Auto-scroll container when search results appear useEffect(() => { if (searchResults.length > 0 && searchContainerRef.current) { // Scroll to bottom to show the search results dropdown searchContainerRef.current.scrollTo({ top: searchContainerRef.current.scrollHeight, behavior: 'smooth' }); } }, [searchResults.length]); // 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 response = await searchUsers(term, 10); // Backend returns { success: true, data: [...users] } const results = response.data?.data || []; 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, 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 }); 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 (
Add Spectator
{/* Description */}

Add a spectator to this request. They will receive notifications but cannot approve or reject.

{/* Email Input with @ Search */}
handleEmailChange(e.target.value)} className="pl-10 h-11 border-gray-300" disabled={isSubmitting} autoFocus /> {/* Search Results Dropdown */} {(isSearching || searchResults.length > 0) && (
{isSearching ? (
Searching users...
) : searchResults.length > 0 ? (
    {searchResults.map((user) => (
  • handleSelectUser(user)} >
    {(user.displayName || user.email) .split(' ') .map(s => s[0]) .join('') .slice(0, 2) .toUpperCase()}

    {user.displayName || [user.firstName, user.lastName].filter(Boolean).join(' ') || user.email}

    {user.email}

    {user.designation && (

    {user.designation}

    )}
  • ))}
) : null}
)}

Type @username to search for users, or enter email directly.

{/* Action Buttons */}
{/* Validation Error Modal */} setValidationModal(prev => ({ ...prev, open: isOpen }))}> {validationModal.type === 'not-found' ? ( <> User Not Found ) : ( <> Validation Error )}
{validationModal.type === 'not-found' && ( <>

User {validationModal.email} was not found in the organization directory.

Please verify:

  • Email address is spelled correctly
  • User exists in Okta/SSO system
  • User has an active account

Tip: Use @ sign to search users from the directory.

)} {validationModal.type === 'error' && ( <> {validationModal.email && (

Failed to validate {validationModal.email}.

)} {validationModal.message && (

{validationModal.message}

)} )}
{/* Policy Violation Modal */} setPolicyViolationModal({ open: false, violations: [] })} violations={policyViolationModal.violations} policyDetails={{ maxApprovalLevels: systemPolicy.maxApprovalLevels, maxParticipants: systemPolicy.maxParticipants, allowSpectators: systemPolicy.allowSpectators, maxSpectators: systemPolicy.maxSpectators }} />
); }