585 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|