1385 lines
63 KiB
TypeScript
1385 lines
63 KiB
TypeScript
/**
|
||
* ClaimApproverSelectionStep Component
|
||
* Step 2: Manual approver selection for all 5 steps in dealer claim workflow
|
||
* Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only, not approval steps
|
||
* Similar to ApprovalWorkflowStep but fixed to 5 steps with predefined step names
|
||
*/
|
||
|
||
import { motion } from 'framer-motion';
|
||
import { useEffect, useState, useRef } from 'react';
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||
import { Users, Shield, CheckCircle, Info, Clock, User, Plus, X, AtSign } from 'lucide-react';
|
||
import { useMultiUserSearch } from '@/hooks/useUserSearch';
|
||
import { ensureUserExists, searchUsers, type UserSummary } from '@/services/userApi';
|
||
import { toast } from 'sonner';
|
||
|
||
// Fixed 5-step workflow for dealer claims
|
||
// Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only, not approval steps
|
||
const CLAIM_STEPS = [
|
||
{ level: 1, name: 'Dealer Proposal Submission', description: 'Dealer submits proposal documents', defaultTat: 72, isAuto: false, approverType: 'dealer' },
|
||
{ level: 2, name: 'Requestor Evaluation', description: 'Initiator evaluates dealer proposal', defaultTat: 48, isAuto: false, approverType: 'initiator' },
|
||
{ level: 3, name: 'Department Lead Approval', description: 'Department lead approves and blocks IO budget', defaultTat: 72, isAuto: false, approverType: 'manual' },
|
||
{ level: 4, name: 'Dealer Completion Documents', description: 'Dealer submits completion documents', defaultTat: 120, isAuto: false, approverType: 'dealer' },
|
||
{ level: 5, name: 'Requestor Claim Approval', description: 'Initiator approves completion', defaultTat: 48, isAuto: false, approverType: 'initiator' },
|
||
];
|
||
|
||
interface ClaimApprover {
|
||
email: string;
|
||
name?: string;
|
||
userId?: string;
|
||
level: number;
|
||
tat?: number | string;
|
||
tatType?: 'hours' | 'days';
|
||
isAdditional?: boolean; // Flag to identify additional approvers added between steps
|
||
insertAfterLevel?: number; // Original level after which this was inserted
|
||
stepName?: string; // Step name/title for additional approvers
|
||
originalStepLevel?: number; // Original step level for fixed steps (to track which step this approver belongs to)
|
||
}
|
||
|
||
interface ClaimApproverSelectionStepProps {
|
||
formData: {
|
||
dealerEmail?: string;
|
||
dealerName?: string;
|
||
approvers?: ClaimApprover[];
|
||
};
|
||
updateFormData: (field: string, value: any) => void;
|
||
onValidationError?: (error: { type: string; email: string; message: string }) => void;
|
||
currentUserEmail?: string;
|
||
currentUserId?: string;
|
||
currentUserName?: string;
|
||
onValidate?: (isValid: boolean) => void;
|
||
maxApprovalLevels?: number;
|
||
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
||
}
|
||
|
||
export function ClaimApproverSelectionStep({
|
||
formData,
|
||
updateFormData,
|
||
onValidationError,
|
||
currentUserEmail = '',
|
||
currentUserId = '',
|
||
currentUserName = '',
|
||
onValidate,
|
||
maxApprovalLevels,
|
||
onPolicyViolation,
|
||
}: ClaimApproverSelectionStepProps) {
|
||
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
|
||
|
||
// State for add approver modal
|
||
const [showAddApproverModal, setShowAddApproverModal] = useState(false);
|
||
const [addApproverEmail, setAddApproverEmail] = useState('');
|
||
const [addApproverTat, setAddApproverTat] = useState<number | string>(24);
|
||
const [addApproverTatType, setAddApproverTatType] = useState<'hours' | 'days'>('hours');
|
||
const [addApproverInsertAfter, setAddApproverInsertAfter] = useState<number>(3);
|
||
const [addApproverSearchResults, setAddApproverSearchResults] = useState<UserSummary[]>([]);
|
||
const [isSearchingApprover, setIsSearchingApprover] = useState(false);
|
||
const [selectedAddApproverUser, setSelectedAddApproverUser] = useState<UserSummary | null>(null);
|
||
const addApproverSearchTimer = useRef<any>(null);
|
||
|
||
// Validation function to check for missing approvers
|
||
const validateApprovers = (): { isValid: boolean; missingSteps: string[] } => {
|
||
const approvers = formData.approvers || [];
|
||
const missingSteps: string[] = [];
|
||
|
||
CLAIM_STEPS.forEach((step) => {
|
||
// Skip auto steps (system steps) and pre-filled steps (dealer, initiator)
|
||
// Step 8 is now a system step, so it should be skipped from validation
|
||
if (step.isAuto || step.approverType === 'dealer' || step.approverType === 'initiator') {
|
||
return;
|
||
}
|
||
|
||
// For manual steps (3 and 8), check if approver is assigned, verified, and has TAT
|
||
const approver = approvers.find((a: ClaimApprover) => a.level === step.level);
|
||
|
||
if (!approver || !approver.email || !approver.userId || !approver.tat) {
|
||
missingSteps.push(`${step.name}`);
|
||
}
|
||
});
|
||
|
||
return {
|
||
isValid: missingSteps.length === 0,
|
||
missingSteps,
|
||
};
|
||
};
|
||
|
||
// Expose validation to parent component
|
||
useEffect(() => {
|
||
if (onValidate) {
|
||
const validation = validateApprovers();
|
||
onValidate(validation.isValid);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [formData.approvers]);
|
||
|
||
// Initialize approvers array for all 8 steps
|
||
useEffect(() => {
|
||
const currentApprovers = formData.approvers || [];
|
||
|
||
// If we already have approvers (including additional ones), don't reinitialize
|
||
// This prevents creating duplicates when approvers have been shifted
|
||
if (currentApprovers.length > 0) {
|
||
// Just ensure all fixed steps have their approvers, but don't recreate shifted ones
|
||
const newApprovers: ClaimApprover[] = [];
|
||
const additionalApprovers = currentApprovers.filter((a: ClaimApprover) => a.isAdditional);
|
||
|
||
CLAIM_STEPS.forEach((step) => {
|
||
// Find existing approver by originalStepLevel (handles shifted levels)
|
||
const existing = currentApprovers.find((a: ClaimApprover) =>
|
||
a.originalStepLevel === step.level || (!a.originalStepLevel && !a.isAdditional && a.level === step.level)
|
||
);
|
||
|
||
if (existing) {
|
||
// Use existing approver (preserves shifted level)
|
||
newApprovers.push(existing);
|
||
} else {
|
||
// Create new approver only if it doesn't exist
|
||
if (step.isAuto) {
|
||
// System steps
|
||
const systemEmail = step.level === 8 ? 'finance@{{API_DOMAIN}}' : 'system@{{API_DOMAIN}}';
|
||
const systemName = step.level === 8 ? 'System/Finance' : 'System';
|
||
newApprovers.push({
|
||
email: systemEmail,
|
||
name: systemName,
|
||
level: step.level,
|
||
tat: step.defaultTat,
|
||
tatType: 'hours',
|
||
originalStepLevel: step.level,
|
||
});
|
||
} else if (step.approverType === 'dealer') {
|
||
newApprovers.push({
|
||
email: formData.dealerEmail || '',
|
||
name: formData.dealerName || '',
|
||
level: step.level,
|
||
tat: step.defaultTat,
|
||
tatType: 'hours',
|
||
originalStepLevel: step.level,
|
||
});
|
||
} else if (step.approverType === 'initiator') {
|
||
newApprovers.push({
|
||
email: currentUserEmail || '',
|
||
name: currentUserName || currentUserEmail || 'User',
|
||
userId: currentUserId,
|
||
level: step.level,
|
||
tat: step.defaultTat,
|
||
tatType: 'hours',
|
||
originalStepLevel: step.level,
|
||
});
|
||
} else {
|
||
newApprovers.push({
|
||
email: '',
|
||
name: '',
|
||
level: step.level,
|
||
tat: step.defaultTat,
|
||
tatType: 'hours',
|
||
originalStepLevel: step.level,
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// Add back all additional approvers
|
||
additionalApprovers.forEach((addApprover: ClaimApprover) => {
|
||
newApprovers.push(addApprover);
|
||
});
|
||
|
||
// Sort by level
|
||
newApprovers.sort((a, b) => a.level - b.level);
|
||
|
||
// Only update if there are actual changes (to avoid infinite loops)
|
||
const hasChanges = JSON.stringify(currentApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel }))) !==
|
||
JSON.stringify(newApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel })));
|
||
|
||
if (hasChanges) {
|
||
updateFormData('approvers', newApprovers);
|
||
}
|
||
} else {
|
||
// Initial setup - create approvers for all 8 steps
|
||
const newApprovers: ClaimApprover[] = [];
|
||
|
||
CLAIM_STEPS.forEach((step) => {
|
||
// System steps (Activity Creation, E-Invoice Generation, Credit Note Confirmation) are no longer approval steps
|
||
// They are handled as activity logs only, so skip them
|
||
if (step.isAuto) {
|
||
// Skip system steps - they are now activity logs only
|
||
return;
|
||
} else if (step.approverType === 'dealer') {
|
||
newApprovers.push({
|
||
email: formData.dealerEmail || '',
|
||
name: formData.dealerName || '',
|
||
level: step.level,
|
||
tat: step.defaultTat,
|
||
tatType: 'hours',
|
||
originalStepLevel: step.level,
|
||
});
|
||
} else if (step.approverType === 'initiator') {
|
||
newApprovers.push({
|
||
email: currentUserEmail || '',
|
||
name: currentUserName || currentUserEmail || 'User',
|
||
userId: currentUserId,
|
||
level: step.level,
|
||
tat: step.defaultTat,
|
||
tatType: 'hours',
|
||
originalStepLevel: step.level,
|
||
});
|
||
} else {
|
||
newApprovers.push({
|
||
email: '',
|
||
name: '',
|
||
level: step.level,
|
||
tat: step.defaultTat,
|
||
tatType: 'hours',
|
||
originalStepLevel: step.level,
|
||
});
|
||
}
|
||
});
|
||
|
||
updateFormData('approvers', newApprovers);
|
||
}
|
||
}, [formData.dealerEmail, formData.dealerName, currentUserEmail, currentUserId, currentUserName]);
|
||
|
||
const handleApproverEmailChange = (level: number, value: string) => {
|
||
const approvers = [...(formData.approvers || [])];
|
||
// Find by originalStepLevel first, then fallback to level for backwards compatibility
|
||
const index = approvers.findIndex((a: ClaimApprover) =>
|
||
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
|
||
);
|
||
|
||
if (index === -1) {
|
||
// Create new approver entry
|
||
const step = CLAIM_STEPS.find(s => s.level === level);
|
||
approvers.push({
|
||
email: value,
|
||
name: '',
|
||
level: level,
|
||
tat: step?.defaultTat || 48,
|
||
tatType: 'hours',
|
||
originalStepLevel: level, // Track original step
|
||
});
|
||
} else {
|
||
// Update existing approver
|
||
const existingApprover = approvers[index];
|
||
if (existingApprover) {
|
||
const previousEmail = existingApprover.email;
|
||
approvers[index] = {
|
||
...existingApprover,
|
||
email: value,
|
||
// Clear name and userId if email changed
|
||
name: value !== previousEmail ? '' : existingApprover.name,
|
||
userId: value !== previousEmail ? undefined : existingApprover.userId,
|
||
};
|
||
}
|
||
}
|
||
|
||
updateFormData('approvers', approvers);
|
||
|
||
if (!value || !value.startsWith('@') || value.length < 2) {
|
||
clearSearchForIndex(level - 1);
|
||
return;
|
||
}
|
||
searchUsersForIndex(level - 1, value, 10);
|
||
};
|
||
|
||
const handleUserSelect = async (level: number, selectedUser: any) => {
|
||
try {
|
||
// Check if user is trying to select themselves for non-initiator steps
|
||
const step = CLAIM_STEPS.find(s => s.level === level);
|
||
if (step && !step.isAuto && step.approverType !== 'initiator' && selectedUser.email?.toLowerCase() === currentUserEmail?.toLowerCase()) {
|
||
toast.error(`You cannot assign yourself as ${step.name} approver.`);
|
||
if (onValidationError) {
|
||
onValidationError({
|
||
type: 'self-assign',
|
||
email: selectedUser.email,
|
||
message: `You cannot assign yourself as ${step.name} approver.`
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Check for duplicates across other steps
|
||
const approvers = formData.approvers || [];
|
||
const isDuplicate = approvers.some(
|
||
(a: ClaimApprover) =>
|
||
a.level !== level &&
|
||
(a.userId === selectedUser.userId || a.email?.toLowerCase() === selectedUser.email?.toLowerCase())
|
||
);
|
||
|
||
if (isDuplicate) {
|
||
toast.error('This user is already assigned to another step.');
|
||
if (onValidationError) {
|
||
onValidationError({
|
||
type: 'error',
|
||
email: selectedUser.email,
|
||
message: 'This user is already assigned to another step.'
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Ensure user exists in database (create from Okta if needed)
|
||
const dbUser = await ensureUserExists({
|
||
userId: selectedUser.userId,
|
||
email: selectedUser.email,
|
||
displayName: selectedUser.displayName,
|
||
firstName: selectedUser.firstName,
|
||
lastName: selectedUser.lastName,
|
||
department: selectedUser.department,
|
||
phone: selectedUser.phone,
|
||
mobilePhone: selectedUser.mobilePhone,
|
||
designation: selectedUser.designation,
|
||
jobTitle: selectedUser.jobTitle,
|
||
manager: selectedUser.manager,
|
||
employeeId: selectedUser.employeeId,
|
||
employeeNumber: selectedUser.employeeNumber,
|
||
secondEmail: selectedUser.secondEmail,
|
||
location: selectedUser.location
|
||
});
|
||
|
||
// Update approver in array
|
||
const updatedApprovers = [...(formData.approvers || [])];
|
||
// Find by originalStepLevel first, then fallback to level for backwards compatibility
|
||
const approverIndex = updatedApprovers.findIndex((a: ClaimApprover) =>
|
||
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
|
||
);
|
||
|
||
if (approverIndex === -1) {
|
||
const step = CLAIM_STEPS.find(s => s.level === level);
|
||
updatedApprovers.push({
|
||
email: selectedUser.email,
|
||
name: selectedUser.displayName || [selectedUser.firstName, selectedUser.lastName].filter(Boolean).join(' '),
|
||
userId: dbUser.userId,
|
||
level: level,
|
||
tat: step?.defaultTat || 48,
|
||
tatType: 'hours' as const,
|
||
originalStepLevel: level, // Track original step
|
||
});
|
||
} else {
|
||
const existingApprover = updatedApprovers[approverIndex];
|
||
if (existingApprover) {
|
||
updatedApprovers[approverIndex] = {
|
||
...existingApprover,
|
||
email: selectedUser.email,
|
||
name: selectedUser.displayName || [selectedUser.firstName, selectedUser.lastName].filter(Boolean).join(' '),
|
||
userId: dbUser.userId,
|
||
// Preserve originalStepLevel if it exists
|
||
originalStepLevel: existingApprover.originalStepLevel || level,
|
||
};
|
||
}
|
||
}
|
||
|
||
updateFormData('approvers', updatedApprovers);
|
||
clearSearchForIndex(level - 1);
|
||
toast.success(`Approver for ${CLAIM_STEPS.find(s => s.level === level)?.name} selected successfully.`);
|
||
} catch (err) {
|
||
console.error('Failed to ensure user exists:', err);
|
||
toast.error('Failed to validate user. Please try again.');
|
||
if (onValidationError) {
|
||
onValidationError({
|
||
type: 'error',
|
||
email: selectedUser.email,
|
||
message: 'Failed to validate user. Please try again.'
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleTatChange = (level: number, tat: number | string) => {
|
||
const approvers = [...(formData.approvers || [])];
|
||
// Find by originalStepLevel first, then fallback to level for backwards compatibility
|
||
const index = approvers.findIndex((a: ClaimApprover) =>
|
||
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
|
||
);
|
||
|
||
if (index !== -1) {
|
||
const existingApprover = approvers[index];
|
||
if (existingApprover) {
|
||
approvers[index] = {
|
||
...existingApprover,
|
||
tat: tat,
|
||
};
|
||
updateFormData('approvers', approvers);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleTatTypeChange = (level: number, tatType: 'hours' | 'days') => {
|
||
const approvers = [...(formData.approvers || [])];
|
||
// Find by originalStepLevel first, then fallback to level for backwards compatibility
|
||
const index = approvers.findIndex((a: ClaimApprover) =>
|
||
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
|
||
);
|
||
|
||
if (index !== -1) {
|
||
const existingApprover = approvers[index];
|
||
if (existingApprover) {
|
||
approvers[index] = {
|
||
...existingApprover,
|
||
tatType: tatType,
|
||
tat: '', // Clear TAT when changing type
|
||
};
|
||
updateFormData('approvers', approvers);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Handle adding additional approver between steps
|
||
const handleAddApproverEmailChange = (value: string) => {
|
||
setAddApproverEmail(value);
|
||
|
||
// Clear selectedUser when manually editing
|
||
if (selectedAddApproverUser && selectedAddApproverUser.email.toLowerCase() !== value.toLowerCase()) {
|
||
setSelectedAddApproverUser(null);
|
||
}
|
||
|
||
// Clear existing timer
|
||
if (addApproverSearchTimer.current) {
|
||
clearTimeout(addApproverSearchTimer.current);
|
||
}
|
||
|
||
// Only trigger search when using @ sign
|
||
if (!value || !value.startsWith('@') || value.length < 2) {
|
||
setAddApproverSearchResults([]);
|
||
setIsSearchingApprover(false);
|
||
return;
|
||
}
|
||
|
||
// Start search with debounce
|
||
setIsSearchingApprover(true);
|
||
addApproverSearchTimer.current = setTimeout(async () => {
|
||
try {
|
||
const term = value.slice(1); // Remove @ prefix
|
||
const response = await searchUsers(term, 10);
|
||
const results = response.data?.data || [];
|
||
setAddApproverSearchResults(results);
|
||
} catch (error) {
|
||
console.error('Search failed:', error);
|
||
setAddApproverSearchResults([]);
|
||
} finally {
|
||
setIsSearchingApprover(false);
|
||
}
|
||
}, 300);
|
||
};
|
||
|
||
const handleSelectAddApproverUser = async (user: UserSummary) => {
|
||
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
|
||
});
|
||
|
||
setAddApproverEmail(user.email);
|
||
setSelectedAddApproverUser(user);
|
||
setAddApproverSearchResults([]);
|
||
setIsSearchingApprover(false);
|
||
} catch (error) {
|
||
console.error('Failed to ensure user exists:', error);
|
||
toast.error('Failed to verify user. Please try again.');
|
||
}
|
||
};
|
||
|
||
const handleConfirmAddApprover = async () => {
|
||
const emailToAdd = addApproverEmail.trim().toLowerCase();
|
||
|
||
if (!emailToAdd) {
|
||
toast.error('Please enter an email address');
|
||
return;
|
||
}
|
||
|
||
// Basic email validation
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
if (!emailRegex.test(emailToAdd)) {
|
||
toast.error('Please enter a valid email address');
|
||
return;
|
||
}
|
||
|
||
// Validate TAT
|
||
const tatNumber = typeof addApproverTat === 'string' ? Number(addApproverTat) : addApproverTat;
|
||
if (!tatNumber || tatNumber <= 0 || isNaN(tatNumber)) {
|
||
toast.error('Please enter valid TAT (minimum 1)');
|
||
return;
|
||
}
|
||
|
||
const maxTat = addApproverTatType === 'days' ? 30 : 720;
|
||
const tatValue = addApproverTatType === 'days' ? tatNumber * 24 : tatNumber;
|
||
if (tatValue > 720) {
|
||
toast.error(`TAT cannot exceed ${maxTat} ${addApproverTatType === 'days' ? 'days' : 'hours'}`);
|
||
return;
|
||
}
|
||
|
||
// Validate insert after level - don't allow after "Requestor Claim Approval"
|
||
const requestorClaimApprovalStep = CLAIM_STEPS.find(s => s.name === 'Requestor Claim Approval');
|
||
if (requestorClaimApprovalStep && addApproverInsertAfter >= requestorClaimApprovalStep.level) {
|
||
toast.error('Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.');
|
||
return;
|
||
}
|
||
|
||
// Check if user is trying to add themselves
|
||
if (emailToAdd === currentUserEmail?.toLowerCase()) {
|
||
toast.error('You cannot add yourself as an additional approver.');
|
||
return;
|
||
}
|
||
|
||
// Check for duplicates
|
||
const approvers = formData.approvers || [];
|
||
const isDuplicate = approvers.some(
|
||
(a: ClaimApprover) =>
|
||
(a.userId && selectedAddApproverUser?.userId && a.userId === selectedAddApproverUser.userId) ||
|
||
a.email?.toLowerCase() === emailToAdd
|
||
);
|
||
|
||
if (isDuplicate) {
|
||
toast.error('This user is already assigned as an approver.');
|
||
return;
|
||
}
|
||
|
||
// Find the approver for the selected step by its originalStepLevel
|
||
// This handles cases where steps have been shifted due to previous additional approvers
|
||
const approverAfter = approvers.find((a: ClaimApprover) =>
|
||
a.originalStepLevel === addApproverInsertAfter ||
|
||
(!a.originalStepLevel && !a.isAdditional && a.level === addApproverInsertAfter)
|
||
);
|
||
|
||
// Get the current level of the approver we're inserting after
|
||
// If the step has been shifted, use its current level; otherwise use the original level
|
||
const currentLevelAfter = approverAfter ? approverAfter.level : addApproverInsertAfter;
|
||
|
||
// Calculate insert level based on current shifted level
|
||
const insertLevel = currentLevelAfter + 1;
|
||
|
||
// Validate max approval levels
|
||
if (maxApprovalLevels) {
|
||
// Calculate total levels after adding the new approver
|
||
// After shifting, we'll have the same number of unique levels + 1 (the new approver)
|
||
const currentUniqueLevels = new Set(approvers.map((a: ClaimApprover) => a.level)).size;
|
||
const newTotalLevels = currentUniqueLevels + 1;
|
||
|
||
if (newTotalLevels > maxApprovalLevels) {
|
||
const violations = [{
|
||
type: 'max_approval_levels',
|
||
message: `Adding this approver would create ${newTotalLevels} approval levels, which exceeds the maximum allowed (${maxApprovalLevels}). Please remove some approvers before adding a new one.`,
|
||
currentValue: newTotalLevels,
|
||
maxValue: maxApprovalLevels
|
||
}];
|
||
|
||
if (onPolicyViolation) {
|
||
onPolicyViolation(violations);
|
||
} else {
|
||
toast.error(violations[0]?.message || 'Maximum approval levels exceeded');
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// If user was NOT selected via @ search, validate against Okta
|
||
if (!selectedAddApproverUser || selectedAddApproverUser.email.toLowerCase() !== emailToAdd) {
|
||
try {
|
||
const response = await searchUsers(emailToAdd, 1);
|
||
const searchOktaResults = response.data?.data || [];
|
||
|
||
if (searchOktaResults.length === 0) {
|
||
toast.error('User not found in organization directory. Please use @ to search for users.');
|
||
return;
|
||
}
|
||
|
||
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
|
||
});
|
||
|
||
// Use found user - insert at integer level and shift subsequent approvers
|
||
// insertLevel is already calculated above based on current shifted level
|
||
const newApprover: ClaimApprover = {
|
||
email: foundUser.email,
|
||
name: foundUser.displayName || [foundUser.firstName, foundUser.lastName].filter(Boolean).join(' '),
|
||
userId: foundUser.userId,
|
||
level: insertLevel, // Use current shifted level + 1
|
||
tat: typeof addApproverTat === 'string' ? Number(addApproverTat) : addApproverTat,
|
||
tatType: addApproverTatType,
|
||
isAdditional: true,
|
||
insertAfterLevel: addApproverInsertAfter, // Store original step level for reference
|
||
stepName: `Additional Approver - ${foundUser.displayName || foundUser.email}`,
|
||
};
|
||
|
||
// Shift all approvers with level >= insertLevel up by 1 (both fixed and additional)
|
||
const updatedApprovers = approvers.map((a: ClaimApprover) => {
|
||
if (a.level >= insertLevel) {
|
||
return { ...a, level: a.level + 1 };
|
||
}
|
||
return a;
|
||
});
|
||
|
||
// Insert the new approver
|
||
updatedApprovers.push(newApprover);
|
||
|
||
// Sort by level to maintain order
|
||
updatedApprovers.sort((a, b) => a.level - b.level);
|
||
|
||
updateFormData('approvers', updatedApprovers);
|
||
toast.success(`Additional approver added and subsequent steps shifted`);
|
||
} catch (error) {
|
||
console.error('Failed to validate approver:', error);
|
||
toast.error('Failed to validate user. Please try again.');
|
||
return;
|
||
}
|
||
} else {
|
||
// User was selected via @ search - insert at integer level and shift subsequent approvers
|
||
// insertLevel is already calculated above based on current shifted level
|
||
const newApprover: ClaimApprover = {
|
||
email: selectedAddApproverUser.email,
|
||
name: selectedAddApproverUser.displayName || [selectedAddApproverUser.firstName, selectedAddApproverUser.lastName].filter(Boolean).join(' '),
|
||
userId: selectedAddApproverUser.userId,
|
||
level: insertLevel, // Use current shifted level + 1
|
||
tat: addApproverTat,
|
||
tatType: addApproverTatType,
|
||
isAdditional: true,
|
||
insertAfterLevel: addApproverInsertAfter, // Store original step level for reference
|
||
stepName: `Additional Approver - ${selectedAddApproverUser.displayName || selectedAddApproverUser.email}`,
|
||
};
|
||
|
||
// Shift all approvers with level >= insertLevel up by 1 (both fixed and additional)
|
||
const updatedApprovers = approvers.map((a: ClaimApprover) => {
|
||
if (a.level >= insertLevel) {
|
||
return { ...a, level: a.level + 1 };
|
||
}
|
||
return a;
|
||
});
|
||
|
||
// Insert the new approver
|
||
updatedApprovers.push(newApprover);
|
||
|
||
// Sort by level to maintain order
|
||
updatedApprovers.sort((a, b) => a.level - b.level);
|
||
|
||
updateFormData('approvers', updatedApprovers);
|
||
toast.success(`Additional approver added and subsequent steps shifted`);
|
||
}
|
||
|
||
// Reset modal state
|
||
setAddApproverEmail('');
|
||
setAddApproverTat(24);
|
||
setAddApproverTatType('hours');
|
||
setAddApproverInsertAfter(3);
|
||
setSelectedAddApproverUser(null);
|
||
setAddApproverSearchResults([]);
|
||
setShowAddApproverModal(false);
|
||
};
|
||
|
||
const handleRemoveAdditionalApprover = (level: number) => {
|
||
const approvers = [...(formData.approvers || [])];
|
||
const approverToRemove = approvers.find((a: ClaimApprover) => a.level === level);
|
||
|
||
if (!approverToRemove) return;
|
||
|
||
// Remove the additional approver
|
||
const filtered = approvers.filter((a: ClaimApprover) => a.level !== level);
|
||
|
||
// Shift all approvers with level > removed level down by 1
|
||
const updatedApprovers = filtered.map((a: ClaimApprover) => {
|
||
if (a.level > level && !a.isAdditional) {
|
||
return { ...a, level: a.level - 1 };
|
||
}
|
||
return a;
|
||
});
|
||
|
||
// Sort by level to maintain order
|
||
updatedApprovers.sort((a, b) => a.level - b.level);
|
||
|
||
updateFormData('approvers', updatedApprovers);
|
||
toast.success('Additional approver removed and subsequent steps shifted back');
|
||
};
|
||
|
||
// Get all approvers sorted by level (including additional ones)
|
||
const getAllApproversSorted = () => {
|
||
const approvers = formData.approvers || [];
|
||
return [...approvers].sort((a, b) => a.level - b.level);
|
||
};
|
||
|
||
const approvers = formData.approvers || [];
|
||
const sortedApprovers = getAllApproversSorted();
|
||
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -20 }}
|
||
className="space-y-6"
|
||
>
|
||
<div className="text-center mb-8">
|
||
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||
<Users className="w-8 h-8 text-white" />
|
||
</div>
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Approver Selection</h2>
|
||
<p className="text-gray-600">
|
||
Assign approvers for workflow steps with TAT (Turn Around Time)
|
||
</p>
|
||
</div>
|
||
|
||
<div className="max-w-4xl mx-auto space-y-6">
|
||
{/* Info Card */}
|
||
<Card className="border-2 border-blue-200 bg-blue-50">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2 text-blue-900">
|
||
<Info className="w-5 h-5" />
|
||
Workflow Steps Information
|
||
</CardTitle>
|
||
<CardDescription className="text-blue-700">
|
||
Some steps are pre-filled (Dealer, Initiator, System). You need to assign approvers for "Department Lead Approval" only. Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.
|
||
{maxApprovalLevels && (
|
||
<span className="block mt-2 text-gray-600">
|
||
Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
|
||
{(() => {
|
||
const approvers = formData.approvers || [];
|
||
const allLevels = new Set(approvers.map((a: ClaimApprover) => a.level));
|
||
const currentCount = allLevels.size;
|
||
return currentCount > 0 ? (
|
||
<span> ({currentCount}/{maxApprovalLevels})</span>
|
||
) : null;
|
||
})()}
|
||
</span>
|
||
)}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
</Card>
|
||
|
||
{/* Approval Hierarchy */}
|
||
<Card className="border-2">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Shield className="w-5 h-5" />
|
||
Approval Hierarchy
|
||
</CardTitle>
|
||
<CardDescription>
|
||
Define approvers and TAT for each step. Some steps are pre-filled (Dealer, Initiator, System). Only "Department Lead Approval" requires manual assignment.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2 pt-4">
|
||
{/* Add Additional Approver Button */}
|
||
<div className="mb-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||
{maxApprovalLevels && (
|
||
<p className="text-sm text-gray-600">
|
||
Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
|
||
{(() => {
|
||
const approvers = formData.approvers || [];
|
||
const allLevels = new Set(approvers.map((a: ClaimApprover) => a.level));
|
||
const currentCount = allLevels.size;
|
||
return currentCount > 0 ? (
|
||
<span> ({currentCount}/{maxApprovalLevels})</span>
|
||
) : null;
|
||
})()}
|
||
</p>
|
||
)}
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setShowAddApproverModal(true)}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Add Additional Approver
|
||
</Button>
|
||
</div>
|
||
{/* Initiator Card */}
|
||
<div className="p-3 rounded-lg border-2 border-blue-200 bg-blue-50">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0">
|
||
<User className="w-4 h-4 text-white" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-semibold text-blue-900 text-sm">Request Initiator</span>
|
||
<Badge variant="secondary" className="text-xs">YOU</Badge>
|
||
</div>
|
||
<p className="text-xs text-blue-700">Creates and submits the request</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Dynamic Approver Cards - Filter out system steps (auto-processed) */}
|
||
{(() => {
|
||
// Count additional approvers before first step
|
||
const additionalBeforeFirst = sortedApprovers.filter((a: ClaimApprover) =>
|
||
a.isAdditional && a.insertAfterLevel === 0
|
||
);
|
||
|
||
let displayIndex = additionalBeforeFirst.length; // Start index after additional approvers before first step
|
||
|
||
return CLAIM_STEPS.filter((step) => !step.isAuto).map((step, index, filteredSteps) => {
|
||
// Find approver by originalStepLevel first, then fallback to level
|
||
const approver = approvers.find((a: ClaimApprover) =>
|
||
a.originalStepLevel === step.level || (!a.originalStepLevel && a.level === step.level && !a.isAdditional)
|
||
) || {
|
||
email: '',
|
||
name: '',
|
||
level: step.level,
|
||
tat: step.defaultTat,
|
||
tatType: 'hours' as const,
|
||
originalStepLevel: step.level,
|
||
};
|
||
|
||
const isLast = index === filteredSteps.length - 1;
|
||
const isPreFilled = step.isAuto || step.approverType === 'dealer' || step.approverType === 'initiator';
|
||
const isEditable = !step.isAuto;
|
||
|
||
// Find additional approvers that should be shown after this step
|
||
// Additional approvers inserted after this step will have insertAfterLevel === step.level
|
||
// and their level will be step.level + 1 (or higher if multiple are added)
|
||
const additionalApproversAfter = sortedApprovers.filter(
|
||
(a: ClaimApprover) =>
|
||
a.isAdditional &&
|
||
a.insertAfterLevel === step.level
|
||
).sort((a, b) => a.level - b.level);
|
||
|
||
// Calculate current step's display number
|
||
const currentStepDisplayNumber = displayIndex + 1;
|
||
|
||
// Increment display index for this step
|
||
displayIndex++;
|
||
|
||
// Increment display index for each additional approver after this step
|
||
displayIndex += additionalApproversAfter.length;
|
||
|
||
return (
|
||
<div key={step.level} className="space-y-1">
|
||
<div className="flex justify-center">
|
||
<div className="w-px h-3 bg-gray-300"></div>
|
||
</div>
|
||
|
||
{/* Render additional approvers before this step if any */}
|
||
{index === 0 && additionalBeforeFirst.map((addApprover: ClaimApprover, addIndex: number) => {
|
||
const addDisplayNumber = addIndex + 1; // Number from 1 for first additional approvers
|
||
return (
|
||
<div key={`additional-${addApprover.level}`} className="space-y-1">
|
||
<div className="flex justify-center">
|
||
<div className="w-px h-3 bg-gray-300"></div>
|
||
</div>
|
||
<div className="p-3 rounded-lg border-2 border-purple-200 bg-purple-50">
|
||
<div className="flex items-start gap-3">
|
||
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600">
|
||
<span className="text-white font-semibold text-sm">{addDisplayNumber}</span>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||
<span className="font-semibold text-gray-900 text-sm">
|
||
Additional Approver
|
||
</span>
|
||
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
|
||
ADDITIONAL
|
||
</Badge>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleRemoveAdditionalApprover(addApprover.level)}
|
||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
<p className="text-xs text-gray-600 mb-2">
|
||
{addApprover.name || addApprover.email}
|
||
</p>
|
||
<div className="text-xs text-gray-500">
|
||
<div>Email: {addApprover.email}</div>
|
||
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<div className={`p-3 rounded-lg border-2 transition-all ${approver.email && approver.userId
|
||
? 'border-green-200 bg-green-50'
|
||
: isPreFilled
|
||
? 'border-blue-200 bg-blue-50'
|
||
: 'border-gray-200 bg-gray-50'
|
||
}`}>
|
||
<div className="flex items-start gap-3">
|
||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${approver.email && approver.userId
|
||
? 'bg-green-600'
|
||
: isPreFilled
|
||
? 'bg-blue-600'
|
||
: 'bg-gray-400'
|
||
}`}>
|
||
<span className="text-white font-semibold text-sm">{currentStepDisplayNumber}</span>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||
<span className="font-semibold text-gray-900 text-sm">
|
||
{step.name}
|
||
</span>
|
||
{isLast && (
|
||
<Badge variant="destructive" className="text-xs">FINAL</Badge>
|
||
)}
|
||
{isPreFilled && (
|
||
<Badge variant="outline" className="text-xs">PRE-FILLED</Badge>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-gray-600 mb-2">{step.description}</p>
|
||
|
||
{isEditable && (() => {
|
||
const isVerified = !!(approver.email && approver.userId);
|
||
const isEmpty = !approver.email && !isPreFilled;
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
<div>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<Label htmlFor={`approver-${step.level}`} className={`text-xs font-bold ${isEmpty ? 'text-blue-900' : isVerified ? 'text-green-900' : 'text-gray-900'
|
||
}`}>
|
||
Approver Email {!isPreFilled && '*'}
|
||
{isEmpty && <span className="ml-2 text-[10px] font-semibold italic text-blue-600">(Required)</span>}
|
||
</Label>
|
||
{isVerified && (
|
||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
|
||
<CheckCircle className="w-3 h-3 mr-1" />
|
||
Verified
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<div className="relative">
|
||
<Input
|
||
id={`approver-${step.level}`}
|
||
type="text"
|
||
placeholder={isPreFilled ? approver.email : "@username or email..."}
|
||
value={approver.email || ''}
|
||
onChange={(e) => {
|
||
const newValue = e.target.value;
|
||
if (!isPreFilled) {
|
||
handleApproverEmailChange(step.level, newValue);
|
||
}
|
||
}}
|
||
disabled={isPreFilled || step.isAuto}
|
||
className={`h-9 border-2 transition-all mt-1 w-full text-sm ${isPreFilled
|
||
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed font-medium'
|
||
: isVerified
|
||
? 'bg-green-50/50 border-green-600 focus:border-green-700 ring-offset-green-50 focus:ring-1 focus:ring-green-100 font-semibold text-gray-900'
|
||
: 'bg-white border-blue-300 shadow-sm shadow-blue-100/50 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
|
||
}`}
|
||
/>
|
||
{/* Search suggestions dropdown */}
|
||
{!isPreFilled && !step.isAuto && (userSearchLoading[step.level - 1] || (userSearchResults[step.level - 1]?.length || 0) > 0) && (
|
||
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
|
||
{userSearchLoading[step.level - 1] ? (
|
||
<div className="p-2 text-xs text-gray-500">Searching...</div>
|
||
) : (
|
||
<ul className="max-h-56 overflow-auto divide-y">
|
||
{userSearchResults[step.level - 1]?.map((u) => (
|
||
<li
|
||
key={u.userId}
|
||
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
|
||
onClick={() => handleUserSelect(step.level, u)}
|
||
>
|
||
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
|
||
<div className="text-xs text-gray-600">{u.email}</div>
|
||
{u.department && (
|
||
<div className="text-xs text-gray-500">{u.department}</div>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{approver.name && (
|
||
<p className="text-xs text-green-600 mt-1">
|
||
Selected: <span className="font-semibold">{approver.name}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor={`tat-${step.level}`} className={`text-xs font-bold ${isEmpty ? 'text-blue-900' : isVerified ? 'text-green-900' : 'text-gray-900'
|
||
}`}>
|
||
TAT (Turn Around Time) *
|
||
</Label>
|
||
<div className="flex items-center gap-2 mt-1">
|
||
<Input
|
||
id={`tat-${step.level}`}
|
||
type="number"
|
||
placeholder={approver.tatType === 'days' ? '7' : '24'}
|
||
min="1"
|
||
max={approver.tatType === 'days' ? '30' : '720'}
|
||
value={approver.tat || ''}
|
||
onChange={(e) => handleTatChange(step.level, parseInt(e.target.value) || '')}
|
||
disabled={step.isAuto}
|
||
className={`h-9 border-2 transition-all flex-1 text-sm ${isPreFilled
|
||
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed font-medium'
|
||
: isVerified
|
||
? 'bg-green-50/50 border-green-600 focus:border-green-700 focus:ring-1 focus:ring-green-100 font-semibold text-gray-900'
|
||
: 'bg-white border-blue-300 shadow-sm shadow-blue-100/50 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
|
||
}`}
|
||
/>
|
||
<Select
|
||
value={approver.tatType || 'hours'}
|
||
onValueChange={(value) => handleTatTypeChange(step.level, value as 'hours' | 'days')}
|
||
disabled={step.isAuto}
|
||
>
|
||
<SelectTrigger className={`w-20 h-9 border-2 transition-all text-sm ${isPreFilled
|
||
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed'
|
||
: isVerified
|
||
? 'bg-green-50/50 border-green-600 focus:border-green-700 focus:ring-1 focus:ring-green-100 text-gray-900 font-medium'
|
||
: 'bg-white border-blue-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
|
||
}`}>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="hours">Hours</SelectItem>
|
||
<SelectItem value="days">Days</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Render additional approvers after this step */}
|
||
{additionalApproversAfter.map((addApprover: ClaimApprover, addIndex: number) => {
|
||
// Additional approvers come after the current step, so they should be numbered after it
|
||
const addDisplayNumber = currentStepDisplayNumber + addIndex + 1;
|
||
return (
|
||
<div key={`additional-${addApprover.level}`} className="space-y-1">
|
||
<div className="flex justify-center">
|
||
<div className="w-px h-3 bg-gray-300"></div>
|
||
</div>
|
||
<div className="p-3 rounded-lg border-2 border-purple-200 bg-purple-50">
|
||
<div className="flex items-start gap-3">
|
||
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600">
|
||
<span className="text-white font-semibold text-sm">{addDisplayNumber}</span>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||
<span className="font-semibold text-gray-900 text-sm">
|
||
{addApprover.stepName || 'Additional Approver'}
|
||
</span>
|
||
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
|
||
ADDITIONAL
|
||
</Badge>
|
||
{addApprover.email && addApprover.userId && (
|
||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
|
||
<CheckCircle className="w-3 h-3 mr-1" />
|
||
Verified
|
||
</Badge>
|
||
)}
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleRemoveAdditionalApprover(addApprover.level)}
|
||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
<p className="text-xs text-gray-600 mb-2">
|
||
{addApprover.name || addApprover.email || 'No approver assigned'}
|
||
</p>
|
||
{addApprover.email && (
|
||
<div className="text-xs text-gray-500 space-y-1">
|
||
<div>Email: {addApprover.email}</div>
|
||
{addApprover.tat && (
|
||
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
});
|
||
})()}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* TAT Summary */}
|
||
<Card className="border-2">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Clock className="w-5 h-5" />
|
||
TAT Summary
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{sortedApprovers.map((approver: ClaimApprover) => {
|
||
// Skip system/auto steps
|
||
// Find step by originalStepLevel first, then fallback to level
|
||
const step = approver.originalStepLevel
|
||
? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel)
|
||
: CLAIM_STEPS.find(s => s.level === approver.level && !approver.isAdditional);
|
||
|
||
if (step?.isAuto) return null;
|
||
|
||
const tat = Number(approver.tat || 0);
|
||
const tatType = approver.tatType || 'hours';
|
||
const hours = tatType === 'days' ? tat * 24 : tat;
|
||
if (!tat) return null;
|
||
|
||
// Handle additional approvers
|
||
if (approver.isAdditional) {
|
||
const afterStep = CLAIM_STEPS.find(s => s.level === approver.insertAfterLevel);
|
||
return (
|
||
<div key={approver.level} className="flex items-center justify-between p-2 bg-purple-50 rounded border border-purple-200">
|
||
<span className="text-sm font-medium">
|
||
{approver.stepName || `Additional Approver (after "${afterStep?.name || 'Unknown'}")`}
|
||
</span>
|
||
<span className="text-sm text-gray-600">{hours} hours</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div key={approver.level} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||
<span className="text-sm font-medium">{step?.name || 'Unknown'}</span>
|
||
<span className="text-sm text-gray-600">{hours} hours</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Add Additional Approver Modal */}
|
||
<Dialog open={showAddApproverModal} onOpenChange={setShowAddApproverModal}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Plus className="w-5 h-5 text-blue-600" />
|
||
Add Additional Approver
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
Add an additional approver between workflow steps. The approver will be inserted after the selected step. Additional approvers cannot be added after "Requestor Claim Approval".
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4 py-4">
|
||
{/* Insert After Level Selection */}
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">Insert After Step *</Label>
|
||
<Select
|
||
value={addApproverInsertAfter.toString()}
|
||
onValueChange={(value) => setAddApproverInsertAfter(Number(value))}
|
||
>
|
||
<SelectTrigger className="h-11 border-gray-300">
|
||
<SelectValue placeholder="Select step" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{CLAIM_STEPS.filter((step) => {
|
||
// Filter out auto steps and don't allow after "Requestor Claim Approval"
|
||
if (step.isAuto) return false;
|
||
// Find the "Requestor Claim Approval" step
|
||
const requestorClaimApprovalStep = CLAIM_STEPS.find(s => s.name === 'Requestor Claim Approval');
|
||
if (requestorClaimApprovalStep && step.level >= requestorClaimApprovalStep.level) {
|
||
return false; // Don't show Requestor Claim Approval or steps after it
|
||
}
|
||
return true;
|
||
}).map((step) => (
|
||
<SelectItem key={step.level} value={step.level.toString()}>
|
||
<div className="flex items-center gap-2">
|
||
<Shield className="w-4 h-4 text-blue-600" />
|
||
<span>{step.name}</span>
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-xs text-gray-500">
|
||
The new approver will be inserted after the selected step.
|
||
</p>
|
||
<p className="text-xs text-amber-600 font-medium">
|
||
⚠️ Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.
|
||
</p>
|
||
|
||
{/* Max Approval Levels Note */}
|
||
{maxApprovalLevels && (
|
||
<p className="text-xs text-gray-600 mt-2">
|
||
Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
|
||
{(() => {
|
||
const approvers = formData.approvers || [];
|
||
const allLevels = new Set(approvers.map((a: ClaimApprover) => a.level));
|
||
const currentCount = allLevels.size;
|
||
return currentCount > 0 ? (
|
||
<span> ({currentCount}/{maxApprovalLevels})</span>
|
||
) : null;
|
||
})()}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* TAT Input */}
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">TAT (Turn Around Time) *</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
type="number"
|
||
min="1"
|
||
max={addApproverTatType === 'days' ? '30' : '720'}
|
||
value={addApproverTat}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
// Allow empty input while typing
|
||
if (value === '') {
|
||
setAddApproverTat('');
|
||
} else {
|
||
const numValue = Number(value);
|
||
if (!isNaN(numValue) && numValue >= 0) {
|
||
setAddApproverTat(numValue);
|
||
}
|
||
}
|
||
}}
|
||
onBlur={(e) => {
|
||
// If empty on blur, reset to default
|
||
const value = e.target.value;
|
||
if (!value || value === '' || Number(value) <= 0) {
|
||
setAddApproverTat(24);
|
||
}
|
||
}}
|
||
className="h-11 border-gray-300 flex-1"
|
||
placeholder="24"
|
||
/>
|
||
<Select
|
||
value={addApproverTatType}
|
||
onValueChange={(value) => setAddApproverTatType(value as 'hours' | 'days')}
|
||
>
|
||
<SelectTrigger className="w-24 h-11 border-gray-300">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="hours">Hours</SelectItem>
|
||
<SelectItem value="days">Days</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<p className="text-xs text-gray-500">
|
||
Maximum time for this approver to respond (1-{addApproverTatType === 'days' ? '30 days' : '720 hours'})
|
||
</p>
|
||
</div>
|
||
|
||
{/* Email Input with @ Search */}
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">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={addApproverEmail}
|
||
onChange={(e) => handleAddApproverEmailChange(e.target.value)}
|
||
className="pl-10 h-11 border-gray-300"
|
||
autoFocus
|
||
/>
|
||
|
||
{/* Search Results Dropdown */}
|
||
{(isSearchingApprover || addApproverSearchResults.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">
|
||
{isSearchingApprover ? (
|
||
<div className="p-3 text-sm text-gray-500">Searching users...</div>
|
||
) : addApproverSearchResults.length > 0 ? (
|
||
<ul className="divide-y">
|
||
{addApproverSearchResults.map((user) => (
|
||
<li
|
||
key={user.userId}
|
||
className="p-3 cursor-pointer hover:bg-gray-50 transition-colors"
|
||
onClick={() => handleSelectAddApproverUser(user)}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<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>
|
||
|
||
<DialogFooter>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => {
|
||
setShowAddApproverModal(false);
|
||
setAddApproverEmail('');
|
||
setAddApproverTat(24);
|
||
setAddApproverTatType('hours');
|
||
setAddApproverInsertAfter(3); // Default to after Step 3 (Department Lead)
|
||
setSelectedAddApproverUser(null);
|
||
setAddApproverSearchResults([]);
|
||
}}
|
||
className="flex-1"
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={handleConfirmAddApprover}
|
||
className="flex-1 bg-[#1a472a] hover:bg-[#152e1f] text-white"
|
||
disabled={!addApproverEmail.trim() || !addApproverTat}
|
||
>
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
Add Approver
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|