Re_Figma_Code/src/components/participant/AddSpectatorModal/AddSpectatorModal.tsx

585 lines
21 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 { 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> | 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<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);
const searchContainerRef = useRef<HTMLDivElement>(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<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 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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md min-h-[60vh] 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-purple-100 rounded-lg flex items-center justify-center">
<Eye className="w-5 h-5 text-purple-600" />
</div>
<DialogTitle className="text-xl font-bold text-gray-900">Add Spectator</DialogTitle>
</div>
</DialogHeader>
<div ref={searchContainerRef} className="space-y-4 px-6 py-4 pb-8 overflow-y-auto flex-1">
{/* Description */}
<p className="text-sm text-gray-600 leading-relaxed">
Add a spectator to this request. They will receive notifications but cannot approve or reject.
</p>
{/* 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-purple-100 text-purple-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()}
>
<Eye className="w-4 h-4 mr-2" />
{isSubmitting ? 'Adding...' : 'Add Spectator'}
</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>
{/* Policy Violation Modal */}
<PolicyViolationModal
open={policyViolationModal.open}
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
violations={policyViolationModal.violations}
policyDetails={{
maxApprovalLevels: systemPolicy.maxApprovalLevels,
maxParticipants: systemPolicy.maxParticipants,
allowSpectators: systemPolicy.allowSpectators,
maxSpectators: systemPolicy.maxSpectators
}}
/>
</Dialog>
);
}