Re_Figma_Code/src/dealer-claim/components/request-creation/ClaimApproverSelectionStep.tsx

1385 lines
63 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
}