diff --git a/src/api/API.ts b/src/api/API.ts index 18cc426..1dcabca 100644 --- a/src/api/API.ts +++ b/src/api/API.ts @@ -158,11 +158,17 @@ export const API = { getResignations: (params?: any) => client.get('/resignation', params), createResignation: (data: any) => client.post('/resignation', data), approveResignation: (id: string, data?: any) => client.post(`/resignation/${id}/approve`, data), + dispatchResignation: (id: string, data?: any) => client.post(`/resignation/${id}/dispatch`, data), rejectResignation: (id: string, data: any) => client.post(`/resignation/${id}/reject`, data), withdrawResignation: (id: string, reason: string) => client.post(`/resignation/${id}/withdraw`, { reason }), getTerminations: (params?: any) => client.get('/termination', params), - createTermination: (data: any) => client.post('/termination', data), + createTermination: (data: any) => { + const isFormData = typeof FormData !== 'undefined' && data instanceof FormData; + return client.post('/termination', data, isFormData ? { + headers: { 'Content-Type': 'multipart/form-data' } + } : undefined); + }, updateTermination: (id: string, data: any) => client.post(`/termination/${id}/status`, data), getOnboardingPayments: () => client.get('/settlement/onboarding'), diff --git a/src/features/fnf/pages/FinanceFnFDetailsPage.tsx b/src/features/fnf/pages/FinanceFnFDetailsPage.tsx index 28ac5ab..3299f84 100644 --- a/src/features/fnf/pages/FinanceFnFDetailsPage.tsx +++ b/src/features/fnf/pages/FinanceFnFDetailsPage.tsx @@ -33,8 +33,10 @@ import { Trash2, Save, Paperclip, - FileDown + FileDown, + MessageSquare } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal'; import { formatDateTime } from '@/lib/dateUtils'; @@ -80,6 +82,7 @@ interface FinancialLineItem { } export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPageProps) { + const navigate = useNavigate(); const [fnfCase, setFnfCase] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState('overview'); @@ -801,14 +804,31 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr return (
{/* Header */} -
- -
-

F&F Settlement Review

-

Full & Final Settlement for {fnfCase.dealerName}

+
+
+ +
+

F&F Settlement Review

+

Full & Final Settlement for {fnfCase.dealerName}

+
+
{/* Status Banner */} diff --git a/src/features/fnf/pages/FnFDetails.tsx b/src/features/fnf/pages/FnFDetails.tsx index f657ee9..d3aa132 100644 --- a/src/features/fnf/pages/FnFDetails.tsx +++ b/src/features/fnf/pages/FnFDetails.tsx @@ -10,6 +10,7 @@ import { CheckCircle2, AlertCircle, Loader2, + Upload, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -40,7 +41,7 @@ import { TableRow, } from "@/components/ui/table"; import { Progress } from "@/components/ui/progress"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { toast } from "sonner"; import { API } from "@/api/API"; @@ -86,6 +87,12 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) { type: "Receivable", }); const [clearanceFile, setClearanceFile] = useState(null); + const [isUploadingDocs, setIsUploadingDocs] = useState(false); + const documentInputRef = useRef(null); + const [uploadDialogOpen, setUploadDialogOpen] = useState(false); + const [uploadDocName, setUploadDocName] = useState(""); + const [uploadDocType, setUploadDocType] = useState(""); + const [uploadFile, setUploadFile] = useState(null); useEffect(() => { @@ -300,6 +307,16 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) { status: "Finance", url: doc.filePath, })), + ...(s.clearanceDocuments || []) + .filter((d: any) => d?.supportingDocument) + .map((d: any, idx: number) => ({ + id: d.id || `fnf-doc-${idx}`, + name: d.name || (d.supportingDocument || "").split("/").pop(), + type: d.documentType || d.department || "F&F Document", + uploadDate: d.clearedAt ? formatDateTime(d.clearedAt) : "-", + status: "Attached", + url: d.supportingDocument, + })), ], participants: s.participants || [] }; @@ -423,9 +440,13 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) { const fnfAge = calculateAge(fnfCase.submittedOn); + const currentUserRole = String(currentUser?.role || "").toLowerCase(); + const canViewDocuments = + currentUserRole.includes("super admin") || currentUserRole.includes("dd admin"); + const canRespondToDepartment = (dept: any) => { if (!fnfCase || !dept) return false; - const role = String(currentUser?.role || "").toLowerCase(); + const role = currentUserRole; if (!role) return false; // 1. If any user (including Admin) has already responded, hide the button to prevent double-submission @@ -486,6 +507,61 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) { } }; + const handlePickDocumentFile = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] || null; + setUploadFile(file); + }; + + const resetUploadDialog = () => { + setUploadDialogOpen(false); + setUploadDocName(""); + setUploadDocType(""); + setUploadFile(null); + if (documentInputRef.current) documentInputRef.current.value = ""; + }; + + const handleSubmitDocumentUpload = async () => { + const trimmedName = uploadDocName.trim(); + if (!trimmedName) { + toast.error("Please enter a document name."); + return; + } + if (!uploadFile) { + toast.error("Please choose a file to upload."); + return; + } + if (!fnfId) { + toast.error("Cannot upload — settlement id is missing."); + return; + } + + setIsUploadingDocs(true); + try { + const formData = new FormData(); + formData.append("file", uploadFile); + formData.append("documentName", trimmedName); + if (uploadDocType.trim()) { + formData.append("documentType", uploadDocType.trim()); + } + + const response: any = await API.uploadFnFDocument(fnfId, formData); + if (response.data?.success) { + toast.success("Document uploaded successfully"); + resetUploadDialog(); + fetchFnFDetails(false); + } else { + toast.error(response.data?.message || "Failed to upload document"); + } + } catch (error: any) { + console.error("Upload F&F document error:", error); + toast.error( + error?.response?.data?.message || "Failed to upload document", + ); + } finally { + setIsUploadingDocs(false); + } + }; + const handleSendToStakeholders = () => { toast.success("Notifications sent to all 16 departments"); setSendStakeholdersDialog(false); @@ -691,7 +767,9 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) { Progress Case Details Department Responses - Documents + {canViewDocuments && ( + Documents + )} {/* Bank Details tab hidden temporarily */} {/* Bank Details */} Audit Trail @@ -1590,14 +1668,32 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) { - {/* Documents Tab */} + {/* Documents Tab — admin / super admin only */} + {canViewDocuments && ( - - Documents - - All NOC documents and due statements from departments - + +
+ Documents + + All NOC documents and due statements from departments + +
+
+ +
@@ -1611,7 +1707,17 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) { - {fnfCase.documents.map((doc: any) => ( + {(fnfCase.documents || []).length === 0 && ( + + + No documents uploaded yet. Click Upload Documents to add files. + + + )} + {(fnfCase.documents || []).map((doc: any) => (
@@ -1661,6 +1767,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) { + )} {/* Bank Details Tab */} @@ -1961,6 +2068,96 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) { + {/* Upload Document Dialog */} + { + if (!open) resetUploadDialog(); + else setUploadDialogOpen(true); + }} + > + + + Upload Document + + Provide a name for this document and attach a file. The name is required. + + + +
+
+ + setUploadDocName(e.target.value)} + data-testid="fnf-upload-doc-name" + /> +
+ +
+ + setUploadDocType(e.target.value)} + data-testid="fnf-upload-doc-type" + /> +
+ +
+ + + {uploadFile && ( +

+ Selected: {uploadFile.name} +

+ )} +
+
+ + + + + +
+
+ {/* Bank Details Modal */} = { 'RBM': 'RBM', 'ZBH': 'ZBH', 'DD Lead': 'DD Lead', + 'DD Head': 'DD Head', 'NBH': 'NBH', 'DD Admin': 'DD Admin', 'Legal': 'Legal Admin' @@ -56,6 +57,7 @@ const RESIGNATION_STAGE_ALIASES: Record = { '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'], @@ -92,7 +94,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig }; const navigate = useNavigate(); - const [actionDialog, setActionDialog] = useState<{ open: boolean, type: 'approve' | 'reject' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | null }>({ open: false, type: null }); + 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(''); const [userSearchQuery, setUserSearchQuery] = useState(''); @@ -154,16 +156,17 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig { 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 final review' }, - { id: 6, name: 'NBH Approval', key: 'NBH', description: 'National Business Head approval' }, - { id: 7, name: 'Legal - Resignation Letter', key: 'Legal', description: 'Legal team issues resignation approval letter' }, - { id: 8, name: 'DD Admin Review', key: 'DD Admin', description: 'DD Admin verification and final closure' }, - { id: 9, name: 'Awaiting F&F', key: 'Awaiting F&F', description: 'Internal review complete — start Full & Final using Push to F&F when ready' }, - { id: 10, name: 'F&F Settlement', key: 'F&F Initiated', description: 'Full & Final settlement process' }, - { id: 11, name: 'Completed', key: 'Completed', description: 'Resignation process finalized' } + { 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', 'NBH', 'Legal', 'DD Admin', 'Awaiting F&F', 'F&F Initiated', 'Completed']; + 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; @@ -194,7 +197,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig const getResignationPermissions = () => { if (!resignationData || !currentUser) { - return { canApprove: false, canWithdraw: false, canSendBack: false, canPushToFnF: false, canAssign: false }; + return { canApprove: false, canDispatch: false, dispatchMissed: false, canWithdraw: false, canSendBack: false, canPushToFnF: false, canAssign: false }; } const currentStage = resignationData.currentStage; @@ -255,6 +258,61 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig 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 && @@ -262,10 +320,15 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig !(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: @@ -298,6 +361,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig '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'], @@ -350,7 +414,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig return 'pending'; }; - const handleAction = (type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke') => { + const handleAction = (type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | 'dispatch') => { setActionDialog({ open: true, type }); }; @@ -369,7 +433,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig }; const handleSubmitAction = async () => { - if (!remarks && !['assign', 'pushfnf'].includes(actionDialog.type || '')) { + if (!remarks && !['assign', 'pushfnf', 'dispatch'].includes(actionDialog.type || '')) { toast.error('Please provide remarks (min 5 characters)'); return; } @@ -1032,6 +1096,36 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig )} + {permissions.canDispatch && ( + <> + {permissions.dispatchMissed && ( +
+ + + Resignation acceptance letter was not dispatched at the DD Admin step. + Please send it to the dealer now. + +
+ )} + + + )} + {permissions.canSendBack && ( diff --git a/src/features/termination/pages/TerminationDetails.tsx b/src/features/termination/pages/TerminationDetails.tsx index 810fa4c..2d248ce 100644 --- a/src/features/termination/pages/TerminationDetails.tsx +++ b/src/features/termination/pages/TerminationDetails.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Check, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, AlertTriangle, Send, ShieldCheck, Loader2, Upload, PauseCircle } from 'lucide-react'; +import { ArrowLeft, Check, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, AlertTriangle, Send, ShieldCheck, Loader2, Upload, PauseCircle, FastForward, Info } 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'; @@ -263,7 +263,10 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi return aliases.includes(currentStage); }; - const isCurrentlyAssigned = userRole === 'Super Admin' || userRole === 'DD Admin' || ( + // Only Super Admin can bypass workflow steps. All other roles must match + // the stage they are assigned to. DD Admin is an administrative role and + // intentionally has no approve/reject privilege over any stage. + const isCurrentlyAssigned = userRole === 'Super Admin' || ( (isStage('RBM + DD-ZM Review') && (userRole === 'RBM' || userRole === 'DD-ZM') && !userHasApprovedJointly) || (isStage('ZBH Review') && userRole === 'ZBH') || (isStage('DD Lead Review') && userRole === 'DD Lead') || @@ -323,6 +326,16 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi return matched || normalized; }; + const hasTimelineActivity = (stageName: string) => { + const aliases = stageAliases[stageName] || [stageName]; + return (request.timeline || []).some((entry: any) => { + if (!entry?.stage) return false; + if (aliases.includes(entry.stage)) return true; + if (stageName === 'Submitted' && (entry.stage === 'Submitted' || entry.stage === 'Request Initiated')) return true; + return false; + }); + }; + const getProgressStatus = (stageName: string) => { const isTerminal = ['Rejected', 'Revoked', 'Withdrawn'].includes(request.status); const isSuccessFinal = [ @@ -348,15 +361,23 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi // If workflow finished successfully or entered F&F, ALL reached stages (including Terminated itself) turn completed if (isSuccessFinal && stageIndex <= currentIndex) { + if (stageIndex < currentIndex && stageName !== 'Submitted' && !hasTimelineActivity(stageName)) { + return 'skipped'; + } return 'completed'; } if (stageIndex === -1) return 'pending'; if (currentIndex === -1) return stageName === 'Submitted' ? 'completed' : 'pending'; - if (stageIndex < currentIndex) return 'completed'; + if (stageIndex < currentIndex) { + if (stageName !== 'Submitted' && !hasTimelineActivity(stageName)) { + return 'skipped'; + } + return 'completed'; + } if (stageIndex === currentIndex) { - if (isTerminal) return 'completed'; // Or 'rejected' if component supports it + if (isTerminal) return 'completed'; return 'active'; } @@ -397,6 +418,11 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi ); }; + const isUnethicalCategory = String(request.category || '').trim().toLowerCase().includes('unethical'); + const fastTrackReason = isUnethicalCategory + ? 'Unethical Practice category — request was escalated directly to DD Lead Review.' + : null; + const progressStages = [ { id: 1, @@ -803,6 +829,15 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi Track the termination request approval process + {fastTrackReason && progressStages.some(s => s.status === 'skipped') && ( + + + Fast-tracked workflow + + {fastTrackReason} Stages marked “Skipped” below were intentionally bypassed and never reviewed. + + + )}
{progressStages.map((stage, index) => { const documentCount = stageDocuments[stage.name]?.length || 0; @@ -812,19 +847,23 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
{stage.status === 'completed' ? ( ) : stage.status === 'active' ? ( + ) : stage.status === 'skipped' ? ( + ) : ( {stage.id} )}
{index < progressStages.length - 1 && (
)}
@@ -834,8 +873,17 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi

{stage.name}

+ {stage.status === 'skipped' && ( + + Skipped + + )} {documentCount > 0 && (

{stage.description}

+ {stage.status === 'skipped' && ( +

+ + {fastTrackReason + ? `Bypassed — ${fastTrackReason}` + : 'This stage was bypassed by the workflow and not reviewed.'} +

+ )} {stageEntries.length > 0 && (
diff --git a/src/features/termination/pages/TerminationPage.tsx b/src/features/termination/pages/TerminationPage.tsx index 09c993f..be7a4cd 100644 --- a/src/features/termination/pages/TerminationPage.tsx +++ b/src/features/termination/pages/TerminationPage.tsx @@ -78,7 +78,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP reason: '', proposedLwd: '', comments: '', - document: null as File | null + documents: [] as File[] }); const fetchTerminations = async () => { @@ -214,6 +214,41 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP setAutoFilledData(mapDealerToFormData(matchedDealer)); }; + const isSuperAdmin = currentUser?.role === 'Super Admin'; + const isPresentationMandatory = !isSuperAdmin; + + const isPptFile = (file: File) => { + const name = file.name.toLowerCase(); + return name.endsWith('.ppt') || name.endsWith('.pptx'); + }; + + const handleFilesPicked = (files: FileList | null, inputEl?: HTMLInputElement | null) => { + if (!files || files.length === 0) return; + + setFormData((prev) => { + const existing = prev.documents; + const seen = new Set(existing.map((f) => `${f.name}::${f.size}`)); + const additions: File[] = []; + Array.from(files).forEach((file) => { + const key = `${file.name}::${file.size}`; + if (!seen.has(key)) { + seen.add(key); + additions.push(file); + } + }); + return { ...prev, documents: [...existing, ...additions] }; + }); + + if (inputEl) inputEl.value = ''; + }; + + const removeDocumentAt = (index: number) => { + setFormData({ + ...formData, + documents: formData.documents.filter((_, i) => i !== index) + }); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!autoFilledData) { @@ -221,43 +256,50 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP return; } - try { - const payload = { - dealerId: autoFilledData.dealerId || autoFilledData.id, - category: formData.terminationCategory, - reason: formData.reason, - proposedLwd: formData.proposedLwd, - comments: formData.comments - }; + if (isPresentationMandatory) { + if (formData.documents.length === 0) { + toast.error('Please upload at least one Presentation (.ppt or .pptx)'); + return; + } + if (!formData.documents.some(isPptFile)) { + toast.error('At least one PowerPoint file (.ppt or .pptx) is required'); + return; + } + } - if (!payload.dealerId) { + try { + const dealerId = autoFilledData.dealerId || autoFilledData.id; + if (!dealerId) { toast.error('Dealer record not found for the selected dealer'); return; } - const response = await API.createTermination(payload); + let requestBody: any; + if (formData.documents.length > 0) { + const fd = new FormData(); + fd.append('dealerId', String(dealerId)); + fd.append('category', formData.terminationCategory); + fd.append('reason', formData.reason); + fd.append('proposedLwd', formData.proposedLwd); + fd.append('comments', formData.comments); + formData.documents.forEach((file) => fd.append('files', file)); + requestBody = fd; + } else { + requestBody = { + dealerId, + category: formData.terminationCategory, + reason: formData.reason, + proposedLwd: formData.proposedLwd, + comments: formData.comments + }; + } + + const response = await API.createTermination(requestBody); const data = response.data as any; if (data?.success) { - // Use termination.id which is the UUID - const newId = data.termination?.id; - - // Upload document if selected - if (newId && formData.document) { - const docFormData = new FormData(); - docFormData.append('file', formData.document); - docFormData.append('documentType', 'Termination Recommendation'); - docFormData.append('stage', 'Submitted'); - - try { - await API.uploadTerminationDocument(newId, docFormData); - toast.success('Termination request and supporting document submitted'); - } catch (docErr) { - console.error('Error uploading supporting document:', docErr); - toast.warning('Termination created, but document upload failed. You can upload it from the details page.'); - } - } else { - toast.success('Termination request submitted successfully'); - } + toast.success(formData.documents.length > 0 + ? 'Termination request and documents submitted' + : 'Termination request submitted successfully'); setIsDialogOpen(false); fetchTerminations(); @@ -271,7 +313,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP reason: '', proposedLwd: '', comments: '', - document: null + documents: [] }); } } catch (error: any) { @@ -465,12 +507,45 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
- + setFormData({...formData, document: e.target.files?.[0] || null})} + multiple + accept=".ppt,.pptx,.pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png" + onChange={(e) => handleFilesPicked(e.target.files, e.currentTarget)} + required={isPresentationMandatory && formData.documents.length === 0} /> + {isPresentationMandatory && ( +

+ At least one PowerPoint (.ppt / .pptx) is mandatory. You can also attach MOM, dealer commitments, and other supporting files (PDF / DOC / XLS / image). +

+ )} + {formData.documents.length > 0 && ( +
+ {formData.documents.map((file, idx) => ( +
+
+ {file.name} + {isPptFile(file) && ( + Presentation + )} +
+ +
+ ))} +
+ )}
diff --git a/src/lib/offboardingDocumentOptions.ts b/src/lib/offboardingDocumentOptions.ts index 6a08b22..1db245e 100644 --- a/src/lib/offboardingDocumentOptions.ts +++ b/src/lib/offboardingDocumentOptions.ts @@ -15,6 +15,7 @@ export const RESIGNATION_STAGE_OPTIONS = [ "RBM", "ZBH", "DD Lead", + "DD Head", "NBH", "DD Admin", "Legal",