dispatch feature approve restriction and some few bugs covered from tracker

This commit is contained in:
Laxman 2026-05-25 22:53:58 +05:30
parent 06116af31a
commit dc49fa9065
7 changed files with 527 additions and 74 deletions

View File

@ -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'),

View File

@ -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<any>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('overview');
@ -801,14 +804,31 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="outline" size="icon" onClick={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h1 className="text-3xl mb-1">F&F Settlement Review</h1>
<p className="text-slate-600">Full & Final Settlement for {fnfCase.dealerName}</p>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="outline" size="icon" onClick={onBack}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h1 className="text-3xl mb-1">F&F Settlement Review</h1>
<p className="text-slate-600">Full & Final Settlement for {fnfCase.dealerName}</p>
</div>
</div>
<Button
variant="outline"
onClick={() =>
navigate(`/worknotes/fnf/${fnfId}`, {
state: {
applicationName: fnfCase.dealerName || 'F&F Settlement',
registrationNumber: fnfCase.caseNumber || fnfCase.settlementId || '',
participants: fnfCase.participants || []
}
})
}
>
<MessageSquare className="w-4 h-4 mr-2" />
View Work Notes
</Button>
</div>
{/* Status Banner */}

View File

@ -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<File | null>(null);
const [isUploadingDocs, setIsUploadingDocs] = useState(false);
const documentInputRef = useRef<HTMLInputElement>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [uploadDocName, setUploadDocName] = useState("");
const [uploadDocType, setUploadDocType] = useState("");
const [uploadFile, setUploadFile] = useState<File | null>(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<HTMLInputElement>) => {
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) {
<TabsTrigger value="progress">Progress</TabsTrigger>
<TabsTrigger value="details">Case Details</TabsTrigger>
<TabsTrigger value="departments">Department Responses</TabsTrigger>
<TabsTrigger value="documents">Documents</TabsTrigger>
{canViewDocuments && (
<TabsTrigger value="documents">Documents</TabsTrigger>
)}
{/* Bank Details tab hidden temporarily */}
{/* <TabsTrigger value="bank">Bank Details</TabsTrigger> */}
<TabsTrigger value="audit">Audit Trail</TabsTrigger>
@ -1590,14 +1668,32 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</Card>
</TabsContent>
{/* Documents Tab */}
{/* Documents Tab — admin / super admin only */}
{canViewDocuments && (
<TabsContent value="documents">
<Card>
<CardHeader>
<CardTitle>Documents</CardTitle>
<CardDescription>
All NOC documents and due statements from departments
</CardDescription>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>Documents</CardTitle>
<CardDescription>
All NOC documents and due statements from departments
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => setUploadDialogOpen(true)}
disabled={isUploadingDocs}
className="bg-re-red hover:bg-re-red-hover text-white"
data-testid="fnf-upload-docs-btn"
>
{isUploadingDocs ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{isUploadingDocs ? "Uploading..." : "Upload Document"}
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
@ -1611,7 +1707,17 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</TableRow>
</TableHeader>
<TableBody>
{fnfCase.documents.map((doc: any) => (
{(fnfCase.documents || []).length === 0 && (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-slate-500 py-8"
>
No documents uploaded yet. Click <span className="font-medium">Upload Documents</span> to add files.
</TableCell>
</TableRow>
)}
{(fnfCase.documents || []).map((doc: any) => (
<TableRow key={doc.id}>
<TableCell>
<div className="flex items-center gap-2">
@ -1661,6 +1767,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</CardContent>
</Card>
</TabsContent>
)}
{/* Bank Details Tab */}
<TabsContent value="bank">
@ -1961,6 +2068,96 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</DialogContent>
</Dialog>
{/* Upload Document Dialog */}
<Dialog
open={uploadDialogOpen}
onOpenChange={(open) => {
if (!open) resetUploadDialog();
else setUploadDialogOpen(true);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Upload Document</DialogTitle>
<DialogDescription>
Provide a name for this document and attach a file. The name is required.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="fnf-upload-doc-name">
Document Name <span className="text-red-600">*</span>
</Label>
<input
id="fnf-upload-doc-name"
type="text"
placeholder="e.g. Final NOC — Sales"
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm"
value={uploadDocName}
onChange={(e) => setUploadDocName(e.target.value)}
data-testid="fnf-upload-doc-name"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="fnf-upload-doc-type">Document Type (optional)</Label>
<input
id="fnf-upload-doc-type"
type="text"
placeholder="e.g. NOC, Statement, Receipt"
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm"
value={uploadDocType}
onChange={(e) => setUploadDocType(e.target.value)}
data-testid="fnf-upload-doc-type"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="fnf-upload-doc-file">
File <span className="text-red-600">*</span>
</Label>
<input
id="fnf-upload-doc-file"
ref={documentInputRef}
type="file"
className="flex w-full text-sm"
onChange={handlePickDocumentFile}
data-testid="fnf-upload-docs-input"
/>
{uploadFile && (
<p className="text-xs text-slate-500 truncate">
Selected: <span className="font-medium">{uploadFile.name}</span>
</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={resetUploadDialog}
disabled={isUploadingDocs}
>
Cancel
</Button>
<Button
className="bg-re-red hover:bg-re-red-hover text-white"
onClick={handleSubmitDocumentUpload}
disabled={isUploadingDocs || !uploadDocName.trim() || !uploadFile}
data-testid="fnf-upload-doc-submit"
>
{isUploadingDocs ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{isUploadingDocs ? "Uploading..." : "Upload"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bank Details Modal */}
<BankDetailsModal
isOpen={isBankModalOpen}

View File

@ -1,4 +1,4 @@
import { ArrowLeft, Check, X, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, Send, AlertCircle, Loader2, Upload, Ban } from 'lucide-react';
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';
@ -42,6 +42,7 @@ const STAGE_TO_ROLE_MAP: Record<string, string> = {
'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<string, string[]> = {
'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<string>('');
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
</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"
@ -1130,13 +1224,16 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
{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'
: 'Please provide remarks for this action'
: 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>
@ -1270,7 +1367,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Processing...
{actionDialog.type === 'dispatch' ? 'Dispatching...' : 'Processing...'}
</>
) : (
<>
@ -1280,6 +1377,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
{actionDialog.type === 'revoke' && 'Revoke'}
{actionDialog.type === 'assign' && 'Assign'}
{actionDialog.type === 'pushfnf' && 'Push to F&F'}
{actionDialog.type === 'dispatch' && 'Send to Dealer'}
</>
)}
</Button>

View File

@ -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
<CardDescription>Track the termination request approval process</CardDescription>
</CardHeader>
<CardContent>
{fastTrackReason && progressStages.some(s => s.status === 'skipped') && (
<Alert className="mb-4 border-amber-300 bg-amber-50 text-amber-900">
<Info className="h-4 w-4 text-amber-600" />
<AlertTitle>Fast-tracked workflow</AlertTitle>
<AlertDescription>
{fastTrackReason} Stages marked &ldquo;Skipped&rdquo; below were intentionally bypassed and never reviewed.
</AlertDescription>
</Alert>
)}
<div className="space-y-4">
{progressStages.map((stage, index) => {
const documentCount = stageDocuments[stage.name]?.length || 0;
@ -812,19 +847,23 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<div className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${stage.status === 'completed' ? 'bg-green-100 text-green-600' :
stage.status === 'active' ? 'bg-red-50 text-re-red' :
stage.status === 'skipped' ? 'bg-amber-50 text-amber-600 border border-dashed border-amber-300' :
'bg-slate-100 text-slate-400'
}`}>
{stage.status === 'completed' ? (
<Check className="w-5 h-5" />
) : stage.status === 'active' ? (
<AlertTriangle className="w-5 h-5" />
) : stage.status === 'skipped' ? (
<FastForward className="w-5 h-5" />
) : (
<span>{stage.id}</span>
)}
</div>
{index < progressStages.length - 1 && (
<div className={`w-0.5 ${stage.remarks ? 'h-32' : 'h-16'
} ${stage.status === 'completed' ? 'bg-green-300' : 'bg-slate-200'
} ${stage.status === 'completed' ? 'bg-green-300' :
stage.status === 'skipped' ? 'bg-amber-200' : 'bg-slate-200'
}`} />
)}
</div>
@ -834,8 +873,17 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<h3 className={
stage.status === 'completed' ? 'text-green-600' :
stage.status === 'active' ? 'text-re-red' :
stage.status === 'skipped' ? 'text-amber-700' :
'text-slate-400'
}>{stage.name}</h3>
{stage.status === 'skipped' && (
<Badge
className="bg-amber-100 text-amber-800 border-amber-300 hover:bg-amber-100"
title={fastTrackReason || 'This stage was bypassed by the workflow.'}
>
Skipped
</Badge>
)}
{documentCount > 0 && (
<button
onClick={() => handleViewStageDocuments(stage.name)}
@ -854,6 +902,14 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
)}
</div>
<p className="text-slate-600 text-sm">{stage.description}</p>
{stage.status === 'skipped' && (
<p className="text-xs text-amber-700 mt-1 flex items-center gap-1">
<FastForward className="w-3 h-3" />
{fastTrackReason
? `Bypassed — ${fastTrackReason}`
: 'This stage was bypassed by the workflow and not reviewed.'}
</p>
)}
{stageEntries.length > 0 && (
<div className="mt-3 space-y-3">

View File

@ -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
</div>
<div className="space-y-2">
<Label htmlFor="document">Upload Supporting Document</Label>
<Label htmlFor="documents">
{isPresentationMandatory ? 'Upload Documents *' : 'Upload Supporting Documents'}
</Label>
<Input
id="document"
id="documents"
type="file"
onChange={(e) => 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 && (
<p className="text-xs text-slate-500">
At least one PowerPoint (.ppt / .pptx) is mandatory. You can also attach MOM, dealer commitments, and other supporting files (PDF / DOC / XLS / image).
</p>
)}
{formData.documents.length > 0 && (
<div className="border rounded-md divide-y bg-slate-50">
{formData.documents.map((file, idx) => (
<div key={`${file.name}-${idx}`} className="flex items-center justify-between px-3 py-2 text-sm">
<div className="flex items-center gap-2 min-w-0">
<span className="truncate">{file.name}</span>
{isPptFile(file) && (
<Badge className="bg-blue-100 text-blue-700 border-blue-300">Presentation</Badge>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeDocumentAt(idx)}
className="text-red-600 hover:text-red-700"
>
Remove
</Button>
</div>
))}
</div>
)}
</div>
<DialogFooter>

View File

@ -15,6 +15,7 @@ export const RESIGNATION_STAGE_OPTIONS = [
"RBM",
"ZBH",
"DD Lead",
"DD Head",
"NBH",
"DD Admin",
"Legal",