1527 lines
69 KiB
TypeScript
1527 lines
69 KiB
TypeScript
import { ArrowLeft, Check, X, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, Send, AlertCircle, Loader2, Upload, Ban, Mail } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { useState, useEffect, useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { User as UserType } from '@/lib/mock-data';
|
|
import { toast } from 'sonner';
|
|
import { resignationService } from '@/services/resignation.service';
|
|
import { SlaBadge } from '@/components/sla/SlaBadge';
|
|
import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus';
|
|
|
|
import { API } from '@/api/API';
|
|
import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
|
|
import { formatDateTime } from '@/components/ui/utils';
|
|
import {
|
|
formatOffboardingStatusLabel,
|
|
LAST_WORKING_DAY_LABEL
|
|
} from '@/lib/offboardingDisplay';
|
|
import { RESIGNATION_DOCUMENT_TYPES, RESIGNATION_STAGE_OPTIONS } from '@/lib/offboardingDocumentOptions';
|
|
import { WIDE_DIALOG_CLASS } from '@/lib/dialogStyles';
|
|
|
|
|
|
interface ResignationDetailsProps {
|
|
resignationId: string;
|
|
onBack: () => void;
|
|
currentUser: UserType | null;
|
|
}
|
|
|
|
export default ResignationDetails;
|
|
|
|
const STAGE_TO_ROLE_MAP: Record<string, string> = {
|
|
'ASM': 'ASM',
|
|
'RBM': 'RBM',
|
|
'ZBH': 'ZBH',
|
|
'DD Lead': 'DD Lead',
|
|
'DD Head': 'DD Head',
|
|
'NBH': 'NBH',
|
|
'DD Admin': 'DD Admin',
|
|
'Legal': 'Legal Admin'
|
|
};
|
|
|
|
/** Labels stored as currentStage when a request is closed (revoke/reject/withdraw); not in workflow stage order */
|
|
const TERMINAL_STAGE_LABELS = ['REJECTED', 'Rejected', 'REVOKED', 'Revoked', 'WITHDRAWN', 'Withdrawn'];
|
|
|
|
const RESIGNATION_STAGE_ALIASES: Record<string, string[]> = {
|
|
'Request Submitted': ['Submission', 'Submitted', 'Initiation'],
|
|
'ASM': ['ASM', 'ASM Review'],
|
|
'RBM': ['RBM', 'RBM Review', 'Regional Review', 'RBM + DD-ZM Review'],
|
|
'ZBH': ['ZBH', 'ZBH Review', 'ZM Review'],
|
|
'DD Lead': ['DD Lead', 'DD Lead Review', 'DDL Review'],
|
|
'DD Head': ['DD Head', 'DD Head Review', 'DDH Review'],
|
|
'NBH': ['NBH', 'NBH Approval', 'NBH Review'],
|
|
'DD Admin': ['DD Admin', 'DD Admin Review'],
|
|
'Awaiting F&F': ['Awaiting F&F', 'Awaiting F&F — manual initiation'],
|
|
'Legal': ['Legal', 'Legal - Resignation Letter'],
|
|
'F&F Initiated': ['F&F Initiated', 'FNF_INITIATED', 'FNF Initiated'],
|
|
'Completed': ['Completed']
|
|
};
|
|
|
|
export function ResignationDetails({ resignationId, onBack, currentUser }: ResignationDetailsProps) {
|
|
const getDocumentsForStage = (stageName: string, stageKey?: string) => {
|
|
const allDocs = [
|
|
...(resignationData?.documents || []),
|
|
...(resignationData?.uploadedDocuments || [])
|
|
];
|
|
|
|
const baseAliases = [
|
|
stageName,
|
|
stageKey,
|
|
...(stageKey ? (RESIGNATION_STAGE_ALIASES[stageKey] || []) : []),
|
|
...(RESIGNATION_STAGE_ALIASES[stageName] || [])
|
|
]
|
|
.filter((value): value is string => Boolean(value))
|
|
.map((value) => value.trim().toLowerCase());
|
|
|
|
return allDocs.filter((doc: any) => {
|
|
if (!doc?.stage) return false;
|
|
const docStage = String(doc.stage).trim().toLowerCase();
|
|
return baseAliases.includes(docStage);
|
|
});
|
|
};
|
|
|
|
const navigate = useNavigate();
|
|
const [actionDialog, setActionDialog] = useState<{ open: boolean, type: 'approve' | 'reject' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | 'dispatch' | null }>({ open: false, type: null });
|
|
const [remarks, setRemarks] = useState('');
|
|
const [assignToUser, setAssignToUser] = useState<string>('');
|
|
const [userSearchQuery, setUserSearchQuery] = useState('');
|
|
const [selectedSpecificUser, setSelectedSpecificUser] = useState<string>('');
|
|
const [availableUsers, setAvailableUsers] = useState<any[]>([]);
|
|
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
|
|
const [forceTriggerFnF, setForceTriggerFnF] = useState(false);
|
|
const [stageDocumentsDialog, setStageDocumentsDialog] = useState<{ open: boolean; stageName: string; documents: any[] }>({ open: false, stageName: '', documents: [] });
|
|
const [resignationData, setResignationData] = useState<any>(null); // Real data from API
|
|
// URL slug may be the human-readable code (e.g. `RES-...`); SLA expects UUID.
|
|
// Feed the SLA hook the resolved UUID once the request has loaded.
|
|
const slaEntityId: string = resignationData?.id || '';
|
|
const { get: getSla } = useSlaBatchStatus(
|
|
slaEntityId ? [{ entityType: 'resignation', entityId: slaEntityId }] : [],
|
|
Boolean(slaEntityId)
|
|
);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [previewDocument, setPreviewDocument] = useState<any>(null);
|
|
const [showUploadDialog, setShowUploadDialog] = useState(false);
|
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
|
const [uploadDocType, setUploadDocType] = useState<string>(RESIGNATION_DOCUMENT_TYPES[0]);
|
|
const [uploadStage, setUploadStage] = useState('');
|
|
const hasUploadedPPT = useMemo(() => {
|
|
const allDocs = [
|
|
...(resignationData?.documents || []),
|
|
...(resignationData?.uploadedDocuments || [])
|
|
];
|
|
return allDocs.some(doc =>
|
|
(doc.documentType || doc.type) === 'PPT Presentation'
|
|
);
|
|
}, [resignationData]);
|
|
|
|
const fetchResignation = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const data = await resignationService.getResignationById(resignationId);
|
|
setResignationData(data);
|
|
fetchAuditLogs();
|
|
} catch (error) {
|
|
console.error('Error fetching resignation:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchAuditLogs = async () => {
|
|
try {
|
|
const response: any = await API.getAuditLogs('resignation', resignationId);
|
|
if (response.data && response.data.success) {
|
|
setAuditLogs(response.data.data || []);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching audit logs:', error);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchResignation();
|
|
}, [resignationId]);
|
|
|
|
// Progress stages logic based on live data
|
|
const progressStages = [
|
|
{ id: 1, name: 'Request Submitted', key: 'Request Submitted', description: 'Dealer submitted the resignation request' },
|
|
{ id: 2, name: 'ASM Review', key: 'ASM', description: 'Area Sales Manager review' },
|
|
{ id: 3, name: 'RBM + DD-ZM Review', key: 'RBM', description: 'Joint approval by Regional Business Manager and DD-ZM' },
|
|
{ id: 4, name: 'ZBH Review', key: 'ZBH', description: 'Zonal Business Head approval' },
|
|
{ id: 5, name: 'DD Lead Review', key: 'DD Lead', description: 'DD Lead consolidated review' },
|
|
{ id: 6, name: 'DD Head Review', key: 'DD Head', description: 'DD Head final dealer development approval' },
|
|
{ id: 7, name: 'NBH Approval', key: 'NBH', description: 'National Business Head approval' },
|
|
{ id: 8, name: 'Legal - Resignation Letter', key: 'Legal', description: 'Legal team issues resignation approval letter' },
|
|
{ id: 9, name: 'DD Admin Review', key: 'DD Admin', description: 'DD Admin verification and final closure' },
|
|
{ id: 10, name: 'Awaiting F&F', key: 'Awaiting F&F', description: 'Internal review complete — start Full & Final using Push to F&F when ready' },
|
|
{ id: 11, name: 'F&F Settlement', key: 'F&F Initiated', description: 'Full & Final settlement process' },
|
|
{ id: 12, name: 'Completed', key: 'Completed', description: 'Resignation process finalized' }
|
|
];
|
|
|
|
const stagesOrdered = ['Request Submitted', 'ASM', 'RBM', 'ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal', 'DD Admin', 'Awaiting F&F', 'F&F Initiated', 'Completed'];
|
|
|
|
const legalStageApproved = (() => {
|
|
if (!resignationData) return false;
|
|
|
|
const inOrBeyondFnF = ['F&F Initiated', 'Completed', 'Settled', 'FNF_INITIATED'].includes(
|
|
String(resignationData.status || resignationData.currentStage || '')
|
|
);
|
|
if (inOrBeyondFnF) return true;
|
|
|
|
const timeline = Array.isArray(resignationData.timeline) ? resignationData.timeline : [];
|
|
return timeline.some((entry: any) => {
|
|
const stage = String(entry?.stage || '').trim().toLowerCase();
|
|
const targetStage = String(entry?.targetStage || '').trim().toLowerCase();
|
|
const action = String(entry?.action || '').trim().toLowerCase();
|
|
|
|
const atLegal = stage === 'legal' || stage === 'legal - resignation letter';
|
|
const legalApprovedTransition =
|
|
targetStage === 'legal' ||
|
|
targetStage === 'dd admin' ||
|
|
targetStage === 'awaiting f&f' ||
|
|
targetStage === 'f&f initiated' ||
|
|
targetStage === 'fnf_initiated' ||
|
|
action.includes('approved');
|
|
|
|
return atLegal && legalApprovedTransition;
|
|
});
|
|
})();
|
|
|
|
const getResignationPermissions = () => {
|
|
if (!resignationData || !currentUser) {
|
|
return { canApprove: false, canDispatch: false, dispatchMissed: false, canWithdraw: false, canSendBack: false, canPushToFnF: false, canAssign: false };
|
|
}
|
|
|
|
const currentStage = resignationData.currentStage;
|
|
const status = resignationData.status;
|
|
const userRole = currentUser.role;
|
|
|
|
const isZmRbmStage = currentStage === 'RBM' || currentStage === 'RBM Review' || currentStage === 'RBM + DD-ZM Review';
|
|
const userRoleCode = String(currentUser.roleCode || currentUser.role || '').trim().toUpperCase();
|
|
|
|
// Check if current user already partially approved this request at this stage
|
|
const hasAlreadyPartiallyApproved = isZmRbmStage && auditLogs.some(log =>
|
|
log.action === 'PARTIAL_APPROVE' &&
|
|
(log.actor?.id === currentUser.id || log.actorId === currentUser.id || log.actor?.email === currentUser.email || log.userEmail === currentUser.email) &&
|
|
(log.details?.roleCode === userRoleCode || (log.details?.roleCode === 'DD-ZM' && userRoleCode === 'DD ZM'))
|
|
);
|
|
|
|
const isFinalState = ['Completed', 'Rejected', 'Withdrawn', 'Revoked'].includes(status);
|
|
|
|
// Check if it's already in the settlement phase
|
|
const isSettlementPhase = status === 'F&F Initiated' || currentStage === 'F&F Initiated' || status === 'Settled' || status === 'FNF_INITIATED';
|
|
|
|
const stageIndex = stagesOrdered.indexOf(currentStage);
|
|
const nbhIndex = stagesOrdered.indexOf('NBH');
|
|
const isPastNBH = stageIndex !== -1 && nbhIndex !== -1 && stageIndex >= nbhIndex;
|
|
|
|
const isCurrentlyAssigned = userRoleCode === 'SUPER_ADMIN' ||
|
|
(isZmRbmStage && (userRoleCode === 'RBM' || userRoleCode === 'DD-ZM' || userRoleCode === 'DD ZM')) ||
|
|
userRole === STAGE_TO_ROLE_MAP[currentStage];
|
|
|
|
const isDDLeadStage = currentStage === 'DD Lead' || currentStage === 'DD Lead Review';
|
|
const isDDLead = userRoleCode === 'DD_LEAD' || userRoleCode === 'DD LEAD';
|
|
|
|
const isLwdReached = (() => {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const lwdString = resignationData.lastOperationalDateServices || resignationData.lastOperationalDateSales;
|
|
if (!lwdString) return true;
|
|
const lwd = new Date(lwdString);
|
|
lwd.setHours(0, 0, 0, 0);
|
|
return today >= lwd;
|
|
})();
|
|
|
|
const isAwaitingFnfGate = currentStage === 'Awaiting F&F';
|
|
|
|
const resolvedStageKey = (() => {
|
|
const normalized = String(currentStage || '').trim();
|
|
const matched = stagesOrdered.find(
|
|
(key) => key === normalized || (RESIGNATION_STAGE_ALIASES[key] || []).includes(normalized)
|
|
);
|
|
return matched || normalized;
|
|
})();
|
|
|
|
const isNbHApprovalStep = resolvedStageKey === 'NBH';
|
|
const isAwaitingFnfStep = resolvedStageKey === 'Awaiting F&F';
|
|
/** Legacy rows only: acceptance letter at Legal before DD Admin completed Awaiting F&F gate */
|
|
const isLegalLegacyFnfStep = resolvedStageKey === 'Legal';
|
|
|
|
const fnfPushRoles = ['DD Lead', 'DD Head', 'DD Admin', 'Super Admin'];
|
|
const fnfPushLegacyRoles = ['DD Lead', 'DD Head', 'DD Admin', 'Super Admin'];
|
|
|
|
// Dispatch action — Legal uploaded the acceptance letter and DD Admin (or
|
|
// Super Admin) must dispatch a formal copy to the dealer. The button stays
|
|
// visible from the DD Admin step through Awaiting F&F / F&F Initiated so
|
|
// an admin who skipped the step earlier can still send the letter
|
|
// retroactively. Once dispatched it disappears (audit log is the source of
|
|
// truth) so we don't re-send duplicates.
|
|
const isDDAdminStage = currentStage === 'DD Admin' || currentStage === 'DD Admin Review';
|
|
const isDDAdmin = userRoleCode === 'DD_ADMIN' || userRole === 'DD Admin';
|
|
const isSuperAdmin = userRoleCode === 'SUPER_ADMIN' || userRole === 'Super Admin';
|
|
|
|
const allResignationDocs: any[] = [
|
|
...(resignationData.documents || []),
|
|
...(resignationData.uploadedDocuments || [])
|
|
];
|
|
const hasAcceptanceLetter = allResignationDocs.some((doc: any) => {
|
|
const docType = String(doc?.documentType || doc?.type || '').toLowerCase();
|
|
const docStage = String(doc?.stage || '').toLowerCase();
|
|
return docType.includes('acceptance letter') || docStage === 'legal';
|
|
});
|
|
|
|
const hasBeenDispatched =
|
|
auditLogs.some((log: any) => {
|
|
const action = String(log?.action || '').toUpperCase();
|
|
const desc = String(log?.description || log?.details?.action || '').toLowerCase();
|
|
return (
|
|
action === 'RESIGNATION_LETTER_DISPATCHED' ||
|
|
desc.includes('resignation letter dispatched')
|
|
);
|
|
}) ||
|
|
(resignationData.timeline || []).some((entry: any) =>
|
|
String(entry?.action || '').toLowerCase().includes('resignation letter dispatched')
|
|
);
|
|
|
|
const ddAdminIdx = stagesOrdered.indexOf('DD Admin');
|
|
const completedIdx = stagesOrdered.indexOf('Completed');
|
|
const currentStageIdx = stagesOrdered.indexOf(
|
|
stagesOrdered.find(
|
|
(key) => key === currentStage || (RESIGNATION_STAGE_ALIASES[key] || []).includes(currentStage)
|
|
) || currentStage
|
|
);
|
|
const isAtOrAfterDDAdmin =
|
|
ddAdminIdx !== -1 &&
|
|
currentStageIdx !== -1 &&
|
|
currentStageIdx >= ddAdminIdx &&
|
|
(completedIdx === -1 || currentStageIdx < completedIdx);
|
|
|
|
const canDispatch =
|
|
isAtOrAfterDDAdmin &&
|
|
(isDDAdmin || isSuperAdmin) &&
|
|
hasAcceptanceLetter &&
|
|
!hasBeenDispatched &&
|
|
!isFinalState;
|
|
|
|
const dispatchMissed = canDispatch && !isDDAdminStage;
|
|
|
|
const canApprove = isCurrentlyAssigned &&
|
|
!isFinalState &&
|
|
!isSettlementPhase &&
|
|
!hasAlreadyPartiallyApproved &&
|
|
!(currentStage === 'Legal' && legalStageApproved) &&
|
|
!(isDDLead && isDDLeadStage && !hasUploadedPPT) &&
|
|
!(currentStage === 'DD Admin' && !isLwdReached) &&
|
|
// Dispatch replaces Approve at the DD Admin stage so the admin must
|
|
// explicitly send the acceptance letter to the dealer.
|
|
!isDDAdminStage &&
|
|
!isAwaitingFnfGate;
|
|
|
|
return {
|
|
canApprove,
|
|
canDispatch,
|
|
dispatchMissed,
|
|
// SRS §7.3.2: Send Back returns to DD Admin for correction. Legal Admin only drafts/uploads
|
|
// the Resignation Acceptance Letter and cannot send the case back to earlier reviewers.
|
|
canSendBack:
|
|
isCurrentlyAssigned &&
|
|
!isFinalState &&
|
|
!isSettlementPhase &&
|
|
stageIndex > 0 &&
|
|
userRole !== 'Legal Admin' &&
|
|
userRoleCode !== 'LEGAL_ADMIN',
|
|
canWithdraw: userRole === 'Dealer' && !isPastNBH && !isFinalState,
|
|
canRevoke: (userRoleCode === 'SUPER_ADMIN' || userRole === 'DD Admin') && !isFinalState && !isSettlementPhase,
|
|
// Push to F&F: after DD Admin gate only — not during NBH (or any earlier) approval.
|
|
// Roles: DD Lead / DD Head / DD Admin / Super Admin (not NBH).
|
|
canPushToFnF:
|
|
fnfPushRoles.includes(userRole) &&
|
|
!isSettlementPhase &&
|
|
!isFinalState &&
|
|
!isNbHApprovalStep &&
|
|
isLwdReached &&
|
|
(isAwaitingFnfStep || (isLegalLegacyFnfStep && fnfPushLegacyRoles.includes(userRole))),
|
|
canAssign: userRole !== 'Dealer' && !isFinalState
|
|
};
|
|
};
|
|
|
|
const permissions = getResignationPermissions();
|
|
const isNationalLevel = ['Super Admin', 'DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Legal Admin', 'DD-ZM'].includes(currentUser?.role || '');
|
|
|
|
const stageAliases: Record<string, string[]> = {
|
|
'ASM': ['ASM', 'ASM Review', 'Request Initiated'],
|
|
'RBM': ['RBM', 'RBM Review', 'RBM + DD-ZM Review'],
|
|
'ZBH': ['ZBH', 'ZBH Review'],
|
|
'DD Lead': ['DD Lead', 'DD Lead Review', 'Lead Review'],
|
|
'DD Head': ['DD Head', 'DD Head Review', 'Head Review'],
|
|
'NBH': ['NBH', 'NBH Approval', 'NBH Review'],
|
|
'DD Admin': ['DD Admin', 'DD Admin Review'],
|
|
'Awaiting F&F': ['Awaiting F&F', 'Awaiting F&F — manual initiation'],
|
|
'Legal': ['Legal', 'Legal - Resignation Letter', 'Legal Review'],
|
|
'F&F Initiated': ['F&F Initiated', 'FNF_INITIATED', 'F&F Settlement', 'Settled'],
|
|
'Completed': ['Completed', 'Finalized']
|
|
};
|
|
|
|
const resolveStageKey = (label?: string) => {
|
|
if (!label) return '';
|
|
const normalized = String(label).trim();
|
|
const matched = stagesOrdered.find((key) =>
|
|
key === normalized || (stageAliases[key] || []).includes(normalized)
|
|
);
|
|
return matched || normalized;
|
|
};
|
|
|
|
const getStageStatus = (stageKey: string) => {
|
|
if (!resignationData) return 'pending';
|
|
|
|
const isTerminal = ['Rejected', 'Revoked', 'Withdrawn'].includes(resignationData.status);
|
|
let currentStageForProgress = resignationData.currentStage;
|
|
|
|
// For terminal states, resolve the last active workflow stage from timeline.
|
|
// Backend sets currentStage to "Rejected" when status is Revoked — that label is not in stagesOrdered,
|
|
// so we must recover the pre-terminal stage (e.g. NBH) from the timeline or indexes stay -1 and only ASM shows complete.
|
|
if (isTerminal && (!currentStageForProgress || TERMINAL_STAGE_LABELS.includes(String(currentStageForProgress)))) {
|
|
const lastEntry = [...(resignationData.timeline || [])].reverse().find(
|
|
(e: any) => e?.stage && !TERMINAL_STAGE_LABELS.includes(String(e.stage))
|
|
);
|
|
if (lastEntry?.stage) currentStageForProgress = lastEntry.stage;
|
|
}
|
|
|
|
const currentIndex = stagesOrdered.indexOf(resolveStageKey(currentStageForProgress));
|
|
const stageIndex = stagesOrdered.indexOf(stageKey);
|
|
|
|
// Final state override: if the whole request is completed, mark current stage as completed too
|
|
if (resignationData.status === 'Completed' || resignationData.status === 'Settled') {
|
|
if (stageIndex <= currentIndex) return 'completed';
|
|
}
|
|
|
|
const isFailedState = ['Rejected', 'Revoked', 'Withdrawn'].includes(resignationData.status);
|
|
if (stageKey === 'Legal' && legalStageApproved && !isFailedState) return 'completed';
|
|
|
|
if (currentIndex === -1) return stageKey === 'ASM' ? 'completed' : 'pending';
|
|
if (stageIndex < currentIndex) return 'completed';
|
|
if (stageIndex === currentIndex) {
|
|
return isTerminal ? 'completed' : 'active';
|
|
}
|
|
return 'pending';
|
|
};
|
|
|
|
const handleAction = (type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | 'dispatch') => {
|
|
setActionDialog({ open: true, type });
|
|
};
|
|
|
|
const handleViewStageDocuments = (stageName: string, stageKey?: string) => {
|
|
const documents = getDocumentsForStage(stageName, stageKey).map((doc: any, index: number) => ({
|
|
id: doc.id || `${stageName}-${index}`,
|
|
name: doc.name || doc.fileName || 'Document',
|
|
type: doc.type || doc.documentType || 'Document',
|
|
uploadDate: doc.uploadDate || (doc.createdAt ? formatDateTime(doc.createdAt) : 'N/A'),
|
|
uploader: typeof doc.uploader === 'string'
|
|
? doc.uploader
|
|
: (doc.uploader?.fullName || doc.uploadedBy || 'System'),
|
|
filePath: doc.filePath || doc.path
|
|
}));
|
|
setStageDocumentsDialog({ open: true, stageName, documents });
|
|
};
|
|
|
|
const handleSubmitAction = async () => {
|
|
if (!remarks && !['assign', 'pushfnf', 'dispatch'].includes(actionDialog.type || '')) {
|
|
toast.error('Please provide remarks (min 5 characters)');
|
|
return;
|
|
}
|
|
|
|
// Explicitly enforce min length for standardized offboarding actions
|
|
if (['sendBack', 'revoke'].includes(actionDialog.type || '') && remarks.trim().length < 5) {
|
|
toast.error('Remarks are required for this action (min 5 characters).');
|
|
return;
|
|
}
|
|
|
|
if (actionDialog.type === 'assign' && !assignToUser) {
|
|
toast.error('Please select a designation');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSubmitting(true);
|
|
const actionLabel = actionDialog.type === 'sendBack' ? 'sendBack' : actionDialog.type;
|
|
|
|
const payload = {
|
|
action: actionLabel,
|
|
remarks,
|
|
assignTo: selectedSpecificUser || assignToUser, // Use specific user if selected, otherwise fallback to role for auto-resolution
|
|
force: forceTriggerFnF
|
|
};
|
|
|
|
const response: any = await API.updateResignationStatus(resignationId, payload);
|
|
if (response.data?.success) {
|
|
toast.success(response.data?.message || 'Action completed successfully');
|
|
setActionDialog({ open: false, type: null });
|
|
setRemarks('');
|
|
setAssignToUser('');
|
|
setSelectedSpecificUser('');
|
|
setAvailableUsers([]);
|
|
setForceTriggerFnF(false);
|
|
fetchResignation();
|
|
} else {
|
|
const message = response.data?.message || 'Failed to submit action';
|
|
toast.error(message);
|
|
|
|
// When Legal approval bumps into LWD gate for F&F initiation, guide user explicitly.
|
|
if (response.data?.canForce) {
|
|
toast.info(
|
|
`${LAST_WORKING_DAY_LABEL} restriction: use "Push to F&F" and enable "Force Initiate F&F Settlement Immediately" if urgent.`
|
|
);
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Error submitting action:', error);
|
|
toast.error(error.response?.data?.message || 'Failed to submit action');
|
|
|
|
if (error?.response?.data?.canForce) {
|
|
toast.info(
|
|
`${LAST_WORKING_DAY_LABEL} restriction: use "Push to F&F" with the force option if business-approved.`
|
|
);
|
|
}
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleUploadDocument = async () => {
|
|
if (!uploadFile) {
|
|
toast.error('Please select a file to upload');
|
|
return;
|
|
}
|
|
try {
|
|
setIsSubmitting(true);
|
|
const formData = new FormData();
|
|
formData.append('file', uploadFile);
|
|
formData.append('documentType', uploadDocType);
|
|
if (uploadStage) formData.append('stage', uploadStage);
|
|
|
|
await resignationService.uploadDocument(resignationId, formData);
|
|
toast.success('Document uploaded successfully');
|
|
setShowUploadDialog(false);
|
|
setUploadFile(null);
|
|
setUploadDocType(RESIGNATION_DOCUMENT_TYPES[0]);
|
|
setUploadStage('');
|
|
fetchResignation();
|
|
} catch (error: any) {
|
|
toast.error(error?.response?.data?.message || 'Failed to upload document');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const fetchUsers = async () => {
|
|
// Fetch users if designation is selected OR search query is typed
|
|
if (actionDialog.type === 'assign' && (assignToUser || userSearchQuery)) {
|
|
const timeoutId = setTimeout(async () => {
|
|
try {
|
|
setIsLoadingUsers(true);
|
|
const roleMap: Record<string, string> = {
|
|
'asm': 'ASM',
|
|
'rbm': 'RBM',
|
|
'zbh': 'ZBH',
|
|
'nbh': 'NBH',
|
|
'legal': 'Legal Admin'
|
|
};
|
|
|
|
const params: any = {
|
|
limit: 20,
|
|
search: userSearchQuery
|
|
};
|
|
|
|
if (assignToUser) {
|
|
params.roleCode = roleMap[assignToUser] || assignToUser;
|
|
}
|
|
|
|
const res: any = await API.getUsers(params);
|
|
if (res.data?.success) {
|
|
setAvailableUsers(res.data.data);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching users:', error);
|
|
} finally {
|
|
setIsLoadingUsers(false);
|
|
}
|
|
}, 300); // 300ms debounce
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
|
|
fetchUsers();
|
|
}, [assignToUser, userSearchQuery, actionDialog.type]);
|
|
|
|
|
|
if (isLoading && !resignationData) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
<Loader2 className="w-8 h-8 animate-spin text-re-red" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Button variant="outline" size="icon" onClick={onBack} className="hover:bg-slate-100 transition-colors">
|
|
<ArrowLeft className="w-4 h-4" />
|
|
</Button>
|
|
<div>
|
|
<h1 className="text-2xl">{resignationData?.resignationId || resignationId}</h1>
|
|
<p className="text-slate-600">{resignationData?.outlet?.name}</p>
|
|
</div>
|
|
<Badge className={
|
|
resignationData?.status === 'Completed' || resignationData?.status === 'Settled'
|
|
? 'bg-green-100 text-green-700 border-green-300'
|
|
: resignationData?.status === 'Rejected' || resignationData?.status === 'Withdrawn' || resignationData?.status === 'Revoked'
|
|
? 'bg-red-100 text-red-700 border-red-300'
|
|
: 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
|
}>
|
|
{resignationData?.status === 'Settled'
|
|
? 'Completed'
|
|
: formatOffboardingStatusLabel(resignationData?.status || 'Pending')}
|
|
</Badge>
|
|
<SlaBadge status={getSla('resignation', slaEntityId)} />
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs defaultValue="details" className="w-full">
|
|
<TabsList className="bg-slate-100 p-1">
|
|
<TabsTrigger value="details" className="data-[state=active]:bg-white">Details</TabsTrigger>
|
|
<TabsTrigger value="progress" className="data-[state=active]:bg-white">Progress</TabsTrigger>
|
|
<TabsTrigger value="documents" className="data-[state=active]:bg-white">Documents</TabsTrigger>
|
|
<TabsTrigger value="audit" className="data-[state=active]:bg-white">Audit Trail</TabsTrigger>
|
|
{isNationalLevel && (
|
|
<TabsTrigger value="approvals" className="data-[state=active]:bg-white">Approval Summary</TabsTrigger>
|
|
)}
|
|
</TabsList>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
|
|
<div className="lg:col-span-2 space-y-6">
|
|
|
|
{/* Details Tab */}
|
|
<TabsContent value="details" className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Resignation Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label className="text-slate-600">Resignation Type</Label>
|
|
<p>{resignationData?.resignationType}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">Reason</Label>
|
|
<p>{resignationData?.reason}</p>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label className="text-slate-600">Last Operational Date (Sales)</Label>
|
|
<p>{resignationData?.lastOperationalDateSales ? formatDateTime(resignationData.lastOperationalDateSales, 'date') : 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">Last Operational Date (Services)</Label>
|
|
<p>{resignationData?.lastOperationalDateServices ? formatDateTime(resignationData.lastOperationalDateServices, 'date') : 'N/A'}</p>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">Additional Info / Dealer Voice</Label>
|
|
<p>{resignationData?.additionalInfo || 'No additional info provided'}</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label className="text-slate-600">Submitted On</Label>
|
|
<p>{resignationData?.submittedOn ? formatDateTime(resignationData.submittedOn) : 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">Current Stage</Label>
|
|
<p>{resignationData?.currentStage}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Request Information</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
|
|
<div>
|
|
<Label className="text-slate-600">Dealer Name</Label>
|
|
<p>{resignationData?.dealer?.fullName || resignationData?.outlet?.name}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">GST</Label>
|
|
<p>{resignationData?.dealer?.dealerProfile?.gstNumber || resignationData?.outlet?.gstNumber || 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">Dealer Email</Label>
|
|
<p>{resignationData?.dealer?.email || 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">Sales Code</Label>
|
|
<p>{resignationData?.dealer?.dealerProfile?.dealerCode?.salesCode || 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">Service Code</Label>
|
|
<p>{resignationData?.dealer?.dealerProfile?.dealerCode?.serviceCode || 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">GMA Code</Label>
|
|
<p>{resignationData?.dealer?.dealerProfile?.dealerCode?.gmaCode || 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">Gear Code</Label>
|
|
<p>{resignationData?.dealer?.dealerProfile?.dealerCode?.gearCode || 'N/A'}</p>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<Label className="text-slate-600">Address</Label>
|
|
<p>{resignationData?.dealer?.dealerProfile?.registeredAddress || resignationData?.outlet?.address}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Operational Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
|
|
<div>
|
|
<Label className="text-slate-600">Inauguration</Label>
|
|
<p>{resignationData?.dealer?.dealerProfile?.onboardedAt ? formatDateTime(resignationData.dealer.dealerProfile.onboardedAt, 'date') : (resignationData?.outlet?.inaugurationDate ? formatDateTime(resignationData.outlet.inaugurationDate, 'date') : 'N/A')}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">LOA Date</Label>
|
|
<p>{resignationData?.dealer?.dealerProfile?.loaDate ? formatDateTime(resignationData.dealer.dealerProfile.loaDate, 'date') : 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">LOI Date</Label>
|
|
<p>{resignationData?.dealer?.dealerProfile?.loiDate ? formatDateTime(resignationData.dealer.dealerProfile.loiDate, 'date') : 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">Dealership Type</Label>
|
|
<p>{resignationData?.dealer?.dealerProfile?.application?.businessType || resignationData?.outlet?.type || 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-slate-600">City Category</Label>
|
|
<p>{resignationData?.outlet?.cityCategory || 'N/A'}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Progress Tab */}
|
|
<TabsContent value="progress">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Progress Timeline</CardTitle>
|
|
<CardDescription>Track the resignation request approval process</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{progressStages.map((stage, index) => {
|
|
const status = getStageStatus(stage.key);
|
|
const stageDocumentCount = getDocumentsForStage(stage.name, stage.key).length;
|
|
const stageTimelineEntries = (resignationData?.timeline || []).filter(
|
|
(t: any) => t.stage === stage.key || t.stage === stage.name
|
|
);
|
|
const timelineEntry = stageTimelineEntries.length > 0
|
|
? stageTimelineEntries[stageTimelineEntries.length - 1]
|
|
: null;
|
|
|
|
return (
|
|
<div key={stage.id} className="flex gap-4">
|
|
<div className="flex flex-col items-center">
|
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
|
status === 'completed' ? 'bg-green-100 text-green-600' :
|
|
status === 'active' ? 'bg-blue-100 text-re-red' :
|
|
'bg-slate-100 text-slate-400'
|
|
}`}>
|
|
{status === 'completed' ? (
|
|
<Check className="w-5 h-5" />
|
|
) : (
|
|
<span>{stage.id}</span>
|
|
)}
|
|
</div>
|
|
{index < progressStages.length - 1 && (
|
|
<div className={`w-0.5 h-16 ${
|
|
status === 'completed' ? 'bg-green-300' : 'bg-slate-200'
|
|
}`} />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 pb-8">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className={
|
|
status === 'completed' ? 'text-green-600' :
|
|
status === 'active' ? 'text-re-red' :
|
|
'text-slate-400'
|
|
}>{stage.name}</h3>
|
|
{stageDocumentCount > 0 && (
|
|
<button
|
|
onClick={() => handleViewStageDocuments(stage.name, stage.key)}
|
|
className="flex items-center gap-1 px-2 py-1 rounded-full bg-red-50 hover:bg-red-100 text-re-red-hover text-xs transition-colors cursor-pointer"
|
|
>
|
|
<FileText className="w-3 h-3" />
|
|
<span>{stageDocumentCount} {stageDocumentCount === 1 ? 'doc' : 'docs'}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
{timelineEntry && (
|
|
<div className="flex items-center gap-1 text-sm text-slate-600">
|
|
<Calendar className="w-4 h-4" />
|
|
<span>{formatDateTime(timelineEntry.timestamp || timelineEntry.createdAt)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="text-slate-600 text-sm mb-1">{stage.description}</p>
|
|
|
|
|
|
{stageTimelineEntries.length > 0 && (
|
|
<div className="space-y-4 mt-3">
|
|
{stageTimelineEntries.map((entry: any, i: number) => (
|
|
<div key={i} className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="secondary" className="bg-slate-100 text-[10px] font-bold uppercase">
|
|
{entry.user || 'System'}
|
|
</Badge>
|
|
<span className="text-[10px] text-slate-500 italic">
|
|
{entry.action}
|
|
</span>
|
|
<span className="text-[10px] text-slate-400 ml-auto">
|
|
{formatDateTime(entry.timestamp || entry.createdAt)}
|
|
</span>
|
|
</div>
|
|
<div className="bg-slate-50 p-3 rounded-lg border border-slate-100 text-sm text-slate-700 shadow-sm">
|
|
{entry.comments || entry.remarks || 'No remarks provided.'}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Documents Tab */}
|
|
<TabsContent value="documents">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<div>
|
|
<CardTitle>Documents</CardTitle>
|
|
<CardDescription>View and manage resignation documents</CardDescription>
|
|
</div>
|
|
<Button size="sm" onClick={() => setShowUploadDialog(true)} className="bg-re-red hover:bg-re-red-hover">
|
|
<Upload className="w-4 h-4 mr-2" />
|
|
Upload Document
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Document Name</TableHead>
|
|
<TableHead>Type</TableHead>
|
|
<TableHead>Upload Date</TableHead>
|
|
<TableHead>Uploader</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(() => {
|
|
const allDocs = [
|
|
...(resignationData?.documents || []),
|
|
...(resignationData?.uploadedDocuments || [])
|
|
];
|
|
|
|
// Add clearance documents from legacy JSON field
|
|
if (resignationData?.departmentalClearances) {
|
|
Object.entries(resignationData.departmentalClearances).forEach(([dept, data]: [string, any]) => {
|
|
if (data.supportingDocument) {
|
|
allDocs.push({
|
|
name: `${dept} Clearance Proof`,
|
|
type: 'Clearance NOC',
|
|
path: data.supportingDocument,
|
|
createdAt: data.updatedAt,
|
|
uploadedBy: data.updatedBy || 'Department Admin'
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add live clearance documents from F&F model
|
|
if (resignationData?.settlement?.clearances) {
|
|
resignationData.settlement.clearances.forEach((c: any) => {
|
|
if (c.supportingDocument) {
|
|
allDocs.push({
|
|
name: `${c.department} Clearance NOC`,
|
|
type: 'Live NOC',
|
|
path: c.supportingDocument,
|
|
createdAt: c.clearedAt || c.updatedAt,
|
|
uploadedBy: 'Department Admin'
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
if (allDocs.length === 0) return (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-4 text-slate-500">
|
|
No documents found
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
|
|
return allDocs.map((doc: any, index: number) => (
|
|
<TableRow key={index}>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<FileText className="w-4 h-4 text-slate-500" />
|
|
<span>{doc.name || doc.fileName}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>{doc.documentType || doc.type || 'Document'}</TableCell>
|
|
<TableCell>{doc.createdAt ? formatDateTime(doc.createdAt) : 'N/A'}</TableCell>
|
|
<TableCell>{doc.uploader?.fullName || doc.uploadedBy || 'Dealer'}</TableCell>
|
|
<TableCell>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
const path = doc.filePath || doc.path;
|
|
const fullPath = path?.startsWith('/uploads/') && !path.startsWith('/uploads/documents/')
|
|
? path.replace('/uploads/', '/uploads/documents/')
|
|
: path;
|
|
|
|
setPreviewDocument({
|
|
fileName: doc.fileName || doc.name,
|
|
filePath: fullPath,
|
|
documentType: doc.documentType || doc.type
|
|
});
|
|
}}
|
|
>
|
|
View
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
})()
|
|
}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Audit Trail Tab */}
|
|
<TabsContent value="audit">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Audit Trail</CardTitle>
|
|
<CardDescription>Complete history of actions on this resignation request</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{auditLogs.length > 0 ? (
|
|
auditLogs.map((log: any, index: number) => (
|
|
<div
|
|
key={index}
|
|
className="flex gap-3 pb-6 border-b border-slate-100 last:border-0 relative"
|
|
>
|
|
<div className="w-2 h-2 rounded-full bg-slate-300 mt-2 z-10" />
|
|
<div className="flex-1">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Badge className={`
|
|
${(log.description || log.action || log.details?.action || '').toLowerCase().includes('reject') || (log.description || log.action || log.details?.action || '').toLowerCase().includes('revok')
|
|
? 'bg-red-100 text-red-700 border-red-200'
|
|
: (log.description || log.action || log.details?.action || '').toLowerCase().includes('sent back') || (log.description || log.action || log.details?.action || '').toLowerCase().includes('send back')
|
|
? 'bg-red-50 text-re-red-hover border-red-200'
|
|
: (log.description || log.action || log.details?.action || '').toLowerCase().includes('approv') || (log.description || log.action || log.details?.action || '').toLowerCase().includes('initi')
|
|
? 'bg-emerald-100 text-emerald-700 border-emerald-200'
|
|
: 'bg-slate-100 text-slate-700 border-slate-200'}
|
|
`}>
|
|
{log.description || log.action}
|
|
</Badge>
|
|
<span className="text-xs text-slate-500 font-medium italic">by {log.actor?.name || log.userName || 'System'}</span>
|
|
</div>
|
|
<span className="text-xs text-slate-500">
|
|
{formatDateTime(log.timestamp || log.createdAt)}
|
|
</span>
|
|
</div>
|
|
|
|
{(log.remarks || log.newData?.remarks || log.details?.remarks) && (
|
|
<div className="p-3 bg-slate-50 border border-slate-100 rounded-lg text-sm text-slate-700 shadow-sm ml-1">
|
|
{log.remarks || log.newData?.remarks || log.details?.remarks}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-center py-8 text-slate-500">
|
|
<p>No activity logs found for this case.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Approval Summary Tab */}
|
|
{isNationalLevel && (
|
|
<TabsContent value="approvals">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<div>
|
|
<CardTitle>Approval Summary</CardTitle>
|
|
<CardDescription>Comprehensive view of all approvals and remarks</CardDescription>
|
|
</div>
|
|
{permissions.canApprove && (
|
|
<Button onClick={() => handleAction('approve')} className="bg-green-600 hover:bg-green-700">
|
|
<Check className="w-4 h-4 mr-2" />
|
|
Approve Request
|
|
</Button>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table className="w-full border-collapse">
|
|
<TableHeader>
|
|
<TableRow className="bg-slate-50/50">
|
|
<TableHead className="min-w-[120px]">Stage</TableHead>
|
|
<TableHead className="min-w-[120px]">Approver</TableHead>
|
|
<TableHead className="min-w-[200px]">Action</TableHead>
|
|
<TableHead className="w-full min-w-[300px]">Remarks</TableHead>
|
|
<TableHead className="min-w-[180px] text-right">Date</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(resignationData?.timeline || []).length > 0 ? (
|
|
resignationData.timeline.map((entry: any, index: number) => (
|
|
<TableRow key={index}>
|
|
<TableCell className="font-medium">{entry.stage}</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline">{entry.user || 'System'}</Badge>
|
|
</TableCell>
|
|
<TableCell className="whitespace-normal break-words">{entry.action}</TableCell>
|
|
<TableCell className="whitespace-normal break-words">
|
|
{entry.remarks || entry.comments || '-'}
|
|
</TableCell>
|
|
<TableCell className="text-slate-500 whitespace-nowrap text-right">
|
|
{formatDateTime(entry.timestamp || entry.createdAt)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-6 text-slate-500">
|
|
No approval records found
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Actions</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{(() => {
|
|
const roleNormalized = String(currentUser?.roleCode || currentUser?.role || '').trim().toUpperCase();
|
|
const isDDLeadUser = roleNormalized === 'DD LEAD' || roleNormalized === 'DD_LEAD';
|
|
const isDDLeadStageCurrent = ['DD Lead', 'DD Lead Review', 'DDL Review'].includes(resignationData?.currentStage);
|
|
|
|
if (isDDLeadUser && isDDLeadStageCurrent) {
|
|
return (
|
|
<Button
|
|
variant="outline"
|
|
className="w-full text-re-red-hover border-red-300 hover:bg-red-50"
|
|
onClick={() => {
|
|
setUploadDocType('PPT Presentation');
|
|
setUploadStage('DD Lead');
|
|
setShowUploadDialog(true);
|
|
}}
|
|
>
|
|
<Upload className="w-4 h-4 mr-2" />
|
|
Upload PPT
|
|
</Button>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
|
|
{permissions.canApprove && (
|
|
<Button
|
|
disabled={isSubmitting}
|
|
className="w-full bg-green-600 hover:bg-green-700 font-bold"
|
|
onClick={() => handleAction('approve')}
|
|
>
|
|
{isSubmitting && actionDialog.type === 'approve' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Check className="w-4 h-4 mr-2" />}
|
|
Approve
|
|
</Button>
|
|
)}
|
|
|
|
{permissions.canDispatch && (
|
|
<>
|
|
{permissions.dispatchMissed && (
|
|
<div className="rounded-md border border-amber-300 bg-amber-50 text-amber-800 px-3 py-2 text-xs flex items-start gap-2">
|
|
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
|
<span>
|
|
Resignation acceptance letter was not dispatched at the DD Admin step.
|
|
Please send it to the dealer now.
|
|
</span>
|
|
</div>
|
|
)}
|
|
<Button
|
|
disabled={isSubmitting}
|
|
className={`w-full font-bold ${
|
|
permissions.dispatchMissed
|
|
? 'bg-amber-600 hover:bg-amber-700'
|
|
: 'bg-re-red hover:bg-re-red-hover'
|
|
}`}
|
|
onClick={() => handleAction('dispatch')}
|
|
>
|
|
{isSubmitting && actionDialog.type === 'dispatch' ? (
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Mail className="w-4 h-4 mr-2" />
|
|
)}
|
|
{permissions.dispatchMissed ? 'Dispatch Resignation Letter (Pending)' : 'Dispatch Resignation Letter'}
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{permissions.canSendBack && (
|
|
<Button
|
|
variant="outline"
|
|
disabled={isSubmitting}
|
|
className="w-full font-bold"
|
|
onClick={() => handleAction('sendBack')}
|
|
>
|
|
{isSubmitting && actionDialog.type === 'sendBack' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RotateCcw className="w-4 h-4 mr-2" />}
|
|
Send Back
|
|
</Button>
|
|
)}
|
|
|
|
{permissions.canWithdraw && (
|
|
<Button
|
|
variant="outline"
|
|
disabled={isSubmitting}
|
|
className="w-full text-red-600 border-red-300 hover:bg-red-50 font-bold"
|
|
onClick={() => handleAction('withdrawal')}
|
|
>
|
|
{isSubmitting && actionDialog.type === 'withdrawal' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <X className="w-4 h-4 mr-2" />}
|
|
Withdrawal
|
|
</Button>
|
|
)}
|
|
|
|
{permissions.canRevoke && (
|
|
<Button
|
|
variant="outline"
|
|
disabled={isSubmitting}
|
|
className="w-full text-orange-600 border-orange-300 hover:bg-orange-50 font-bold"
|
|
onClick={() => handleAction('revoke')}
|
|
>
|
|
{isSubmitting && actionDialog.type === 'revoke' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Ban className="w-4 h-4 mr-2" />}
|
|
Revoke
|
|
</Button>
|
|
)}
|
|
|
|
{permissions.canPushToFnF && (
|
|
<Button
|
|
variant="outline"
|
|
disabled={isSubmitting}
|
|
className="w-full text-re-red-hover border-red-300 hover:bg-red-50"
|
|
onClick={() => handleAction('pushfnf')}
|
|
>
|
|
{isSubmitting && actionDialog.type === 'pushfnf' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Send className="w-4 h-4 mr-2" />}
|
|
Push to F&F
|
|
</Button>
|
|
)}
|
|
|
|
{permissions.canAssign && (
|
|
<Button
|
|
variant="outline"
|
|
disabled={isSubmitting}
|
|
className="w-full"
|
|
onClick={() => handleAction('assign')}
|
|
>
|
|
{isSubmitting && actionDialog.type === 'assign' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <UserPlus className="w-4 h-4 mr-2" />}
|
|
Assign User
|
|
</Button>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={() => navigate(`/worknotes/resignation/${resignationId}`, {
|
|
state: {
|
|
applicationName: resignationData?.outlet?.name || 'Resignation',
|
|
registrationNumber: resignationData?.resignationId || '',
|
|
participants: resignationData?.participants || []
|
|
}
|
|
})}
|
|
>
|
|
<MessageSquare className="w-4 h-4 mr-2" />
|
|
View Work Notes
|
|
{resignationData?.worknotes?.length > 0 && (
|
|
<Badge className="ml-auto bg-re-red hover:bg-re-red-hover text-white h-5 px-2">
|
|
{resignationData.worknotes.length}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</Tabs>
|
|
|
|
{/* Action Dialogs */}
|
|
<Dialog open={actionDialog.open} onOpenChange={(open) => setActionDialog({ open, type: null })}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{actionDialog.type === 'approve' && 'Approve Resignation Request'}
|
|
{actionDialog.type === 'withdrawal' && 'Withdraw Resignation Request'}
|
|
{actionDialog.type === 'sendBack' && 'Send Back for Clarification'}
|
|
{actionDialog.type === 'revoke' && 'Revoke Resignation Request'}
|
|
{actionDialog.type === 'assign' && 'Assign to User'}
|
|
{actionDialog.type === 'pushfnf' && 'Push to Full & Final Settlement'}
|
|
{actionDialog.type === 'dispatch' && 'Dispatch Resignation Letter'}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{actionDialog.type === 'assign'
|
|
? 'Select a user to assign this request to'
|
|
: actionDialog.type === 'pushfnf'
|
|
? 'This will move the resignation request to F&F for dues clearance'
|
|
: actionDialog.type === 'dispatch'
|
|
? 'The Legal-issued acceptance letter will be emailed to the dealer and the request will advance to Awaiting F&F. Remarks are optional.'
|
|
: 'Please provide remarks for this action'
|
|
}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{actionDialog.type === 'assign' ? (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Designation Filter</Label>
|
|
<Select value={assignToUser} onValueChange={(val) => {
|
|
setAssignToUser(val);
|
|
setSelectedSpecificUser('');
|
|
}}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="All Roles" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Roles</SelectItem>
|
|
<SelectItem value="asm">ASM</SelectItem>
|
|
<SelectItem value="rbm">RBM</SelectItem>
|
|
<SelectItem value="zbh">ZBH</SelectItem>
|
|
<SelectItem value="nbh">NBH</SelectItem>
|
|
<SelectItem value="legal">Legal</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Search Name/Email</Label>
|
|
<div className="relative">
|
|
<Input
|
|
placeholder="Search..."
|
|
value={userSearchQuery}
|
|
onChange={(e) => setUserSearchQuery(e.target.value)}
|
|
className="pr-8"
|
|
/>
|
|
{isLoadingUsers && <Loader2 className="w-4 h-4 animate-spin absolute right-2 top-2.5 text-slate-400" />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Select Specific Person *</Label>
|
|
<Select value={selectedSpecificUser} onValueChange={setSelectedSpecificUser}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={availableUsers.length > 0 ? "Choose a user" : "No users found"} />
|
|
</SelectTrigger>
|
|
<SelectContent className="max-h-60">
|
|
{availableUsers.map(user => (
|
|
<SelectItem key={user.id} value={user.id}>
|
|
<div className="flex flex-col text-left">
|
|
<span className="font-medium">{user.fullName}</span>
|
|
<span className="text-[10px] text-slate-500">{user.roleCode} • {user.email}</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Assignment Remarks *</Label>
|
|
<Textarea
|
|
value={remarks}
|
|
onChange={(e) => setRemarks(e.target.value)}
|
|
placeholder="Why are you assigning this user?"
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : actionDialog.type === 'pushfnf' ? (
|
|
<div className="space-y-4">
|
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
|
<AlertCircle className="w-5 h-5 text-re-red mt-0.5" />
|
|
<div className="text-sm text-red-800">
|
|
<p className="font-bold">Manual Trigger Notice</p>
|
|
<p>
|
|
Normally F&F is triggered after the {LAST_WORKING_DAY_LABEL.toLowerCase()}. Use manual
|
|
trigger only if urgent clearance is required.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="forceFnF"
|
|
checked={forceTriggerFnF}
|
|
onChange={(e) => setForceTriggerFnF(e.target.checked)}
|
|
className="w-4 h-4 rounded border-slate-300"
|
|
/>
|
|
<Label htmlFor="forceFnF" className="font-medium text-slate-900 cursor-pointer">
|
|
Force Initiate F&F Settlement Immediately
|
|
</Label>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Remarks (Optional)</Label>
|
|
<Textarea
|
|
value={remarks}
|
|
onChange={(e) => setRemarks(e.target.value)}
|
|
placeholder="Add any additional notes..."
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<Label>Remarks *</Label>
|
|
<Textarea
|
|
value={remarks}
|
|
onChange={(e) => setRemarks(e.target.value)}
|
|
placeholder="Enter your remarks here..."
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setActionDialog({ open: false, type: null })} disabled={isSubmitting}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmitAction}
|
|
disabled={isSubmitting}
|
|
className={
|
|
actionDialog.type === 'approve' ? 'bg-green-600 hover:bg-green-700' :
|
|
actionDialog.type === 'withdrawal' ? 'bg-red-600 hover:bg-red-700' :
|
|
'bg-re-red hover:bg-re-red-hover'
|
|
}
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
{actionDialog.type === 'dispatch' ? 'Dispatching...' : 'Processing...'}
|
|
</>
|
|
) : (
|
|
<>
|
|
{actionDialog.type === 'approve' && 'Approve'}
|
|
{actionDialog.type === 'withdrawal' && 'Withdraw'}
|
|
{actionDialog.type === 'sendBack' && 'Send Back'}
|
|
{actionDialog.type === 'revoke' && 'Revoke'}
|
|
{actionDialog.type === 'assign' && 'Assign'}
|
|
{actionDialog.type === 'pushfnf' && 'Push to F&F'}
|
|
{actionDialog.type === 'dispatch' && 'Send to Dealer'}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Stage Documents Dialog */}
|
|
<Dialog open={stageDocumentsDialog.open} onOpenChange={(open) => setStageDocumentsDialog({ open, stageName: '', documents: [] })}>
|
|
<DialogContent className={WIDE_DIALOG_CLASS}>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<FileText className="w-5 h-5 text-re-red" />
|
|
Documents - {stageDocumentsDialog.stageName}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Documents uploaded for this stage ({stageDocumentsDialog.documents.length} {stageDocumentsDialog.documents.length === 1 ? 'document' : 'documents'})
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="max-h-96 overflow-y-auto">
|
|
{stageDocumentsDialog.documents.length > 0 ? (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Document Name</TableHead>
|
|
<TableHead>Type</TableHead>
|
|
<TableHead>Upload Date</TableHead>
|
|
<TableHead>Uploader</TableHead>
|
|
<TableHead>Action</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{stageDocumentsDialog.documents.map((doc) => (
|
|
<TableRow key={doc.id}>
|
|
<TableCell>{doc.name}</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline">{doc.type}</Badge>
|
|
</TableCell>
|
|
<TableCell>{doc.uploadDate}</TableCell>
|
|
<TableCell>{doc.uploader}</TableCell>
|
|
<TableCell>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="text-re-red hover:text-blue-700"
|
|
onClick={() => {
|
|
if (!doc.filePath) return;
|
|
const fullPath = doc.filePath.startsWith('/uploads/') && !doc.filePath.startsWith('/uploads/documents/')
|
|
? doc.filePath.replace('/uploads/', '/uploads/documents/')
|
|
: doc.filePath;
|
|
setPreviewDocument({
|
|
fileName: doc.name,
|
|
filePath: fullPath,
|
|
documentType: doc.type
|
|
});
|
|
}}
|
|
>
|
|
<FileText className="w-4 h-4 mr-1" />
|
|
View
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
) : (
|
|
<div className="text-center py-8 text-slate-500">
|
|
No documents uploaded for this stage yet
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setStageDocumentsDialog({ open: false, stageName: '', documents: [] })}>
|
|
Close
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={showUploadDialog} onOpenChange={setShowUploadDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Upload Resignation Document</DialogTitle>
|
|
<DialogDescription>Add a document and map it to a stage (optional).</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Document Type</Label>
|
|
<Select value={uploadDocType} onValueChange={setUploadDocType}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select document type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{RESIGNATION_DOCUMENT_TYPES.map((docType) => (
|
|
<SelectItem key={docType} value={docType}>
|
|
{docType}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Stage (Optional)</Label>
|
|
<Select value={uploadStage || 'none'} onValueChange={(value) => setUploadStage(value === 'none' ? '' : value)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select stage" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">No Stage Mapping</SelectItem>
|
|
{RESIGNATION_STAGE_OPTIONS.map((stage) => (
|
|
<SelectItem key={stage} value={stage}>
|
|
{stage}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>File</Label>
|
|
<Input type="file" onChange={(e) => setUploadFile(e.target.files?.[0] || null)} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowUploadDialog(false)} disabled={isSubmitting}>Cancel</Button>
|
|
<Button onClick={handleUploadDocument} disabled={isSubmitting}>
|
|
{isSubmitting ? 'Uploading...' : 'Upload'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
|
|
|
|
<DocumentPreviewModal
|
|
isOpen={!!previewDocument}
|
|
onClose={() => setPreviewDocument(null)}
|
|
document={previewDocument}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|