dispatch feature approve restriction and some few bugs covered from tracker
This commit is contained in:
parent
06116af31a
commit
dc49fa9065
@ -158,11 +158,17 @@ export const API = {
|
|||||||
getResignations: (params?: any) => client.get('/resignation', params),
|
getResignations: (params?: any) => client.get('/resignation', params),
|
||||||
createResignation: (data: any) => client.post('/resignation', data),
|
createResignation: (data: any) => client.post('/resignation', data),
|
||||||
approveResignation: (id: string, data?: any) => client.post(`/resignation/${id}/approve`, 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),
|
rejectResignation: (id: string, data: any) => client.post(`/resignation/${id}/reject`, data),
|
||||||
withdrawResignation: (id: string, reason: string) => client.post(`/resignation/${id}/withdraw`, { reason }),
|
withdrawResignation: (id: string, reason: string) => client.post(`/resignation/${id}/withdraw`, { reason }),
|
||||||
|
|
||||||
getTerminations: (params?: any) => client.get('/termination', params),
|
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),
|
updateTermination: (id: string, data: any) => client.post(`/termination/${id}/status`, data),
|
||||||
|
|
||||||
getOnboardingPayments: () => client.get('/settlement/onboarding'),
|
getOnboardingPayments: () => client.get('/settlement/onboarding'),
|
||||||
|
|||||||
@ -33,8 +33,10 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Save,
|
Save,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
FileDown
|
FileDown,
|
||||||
|
MessageSquare
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
|
import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
|
||||||
import { formatDateTime } from '@/lib/dateUtils';
|
import { formatDateTime } from '@/lib/dateUtils';
|
||||||
@ -80,6 +82,7 @@ interface FinancialLineItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPageProps) {
|
export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPageProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [fnfCase, setFnfCase] = useState<any>(null);
|
const [fnfCase, setFnfCase] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState('overview');
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
@ -801,14 +804,31 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Button variant="outline" size="icon" onClick={onBack}>
|
<div className="flex items-center gap-4">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<Button variant="outline" size="icon" onClick={onBack}>
|
||||||
</Button>
|
<ArrowLeft className="w-4 h-4" />
|
||||||
<div>
|
</Button>
|
||||||
<h1 className="text-3xl mb-1">F&F Settlement Review</h1>
|
<div>
|
||||||
<p className="text-slate-600">Full & Final Settlement for {fnfCase.dealerName}</p>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Status Banner */}
|
{/* Status Banner */}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -40,7 +41,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { API } from "@/api/API";
|
import { API } from "@/api/API";
|
||||||
@ -86,6 +87,12 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
|
|||||||
type: "Receivable",
|
type: "Receivable",
|
||||||
});
|
});
|
||||||
const [clearanceFile, setClearanceFile] = useState<File | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
@ -300,6 +307,16 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
|
|||||||
status: "Finance",
|
status: "Finance",
|
||||||
url: doc.filePath,
|
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 || []
|
participants: s.participants || []
|
||||||
};
|
};
|
||||||
@ -423,9 +440,13 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
|
|||||||
|
|
||||||
const fnfAge = calculateAge(fnfCase.submittedOn);
|
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) => {
|
const canRespondToDepartment = (dept: any) => {
|
||||||
if (!fnfCase || !dept) return false;
|
if (!fnfCase || !dept) return false;
|
||||||
const role = String(currentUser?.role || "").toLowerCase();
|
const role = currentUserRole;
|
||||||
if (!role) return false;
|
if (!role) return false;
|
||||||
|
|
||||||
// 1. If any user (including Admin) has already responded, hide the button to prevent double-submission
|
// 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 = () => {
|
const handleSendToStakeholders = () => {
|
||||||
toast.success("Notifications sent to all 16 departments");
|
toast.success("Notifications sent to all 16 departments");
|
||||||
setSendStakeholdersDialog(false);
|
setSendStakeholdersDialog(false);
|
||||||
@ -691,7 +767,9 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
|
|||||||
<TabsTrigger value="progress">Progress</TabsTrigger>
|
<TabsTrigger value="progress">Progress</TabsTrigger>
|
||||||
<TabsTrigger value="details">Case Details</TabsTrigger>
|
<TabsTrigger value="details">Case Details</TabsTrigger>
|
||||||
<TabsTrigger value="departments">Department Responses</TabsTrigger>
|
<TabsTrigger value="departments">Department Responses</TabsTrigger>
|
||||||
<TabsTrigger value="documents">Documents</TabsTrigger>
|
{canViewDocuments && (
|
||||||
|
<TabsTrigger value="documents">Documents</TabsTrigger>
|
||||||
|
)}
|
||||||
{/* Bank Details tab hidden temporarily */}
|
{/* Bank Details tab hidden temporarily */}
|
||||||
{/* <TabsTrigger value="bank">Bank Details</TabsTrigger> */}
|
{/* <TabsTrigger value="bank">Bank Details</TabsTrigger> */}
|
||||||
<TabsTrigger value="audit">Audit Trail</TabsTrigger>
|
<TabsTrigger value="audit">Audit Trail</TabsTrigger>
|
||||||
@ -1590,14 +1668,32 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Documents Tab */}
|
{/* Documents Tab — admin / super admin only */}
|
||||||
|
{canViewDocuments && (
|
||||||
<TabsContent value="documents">
|
<TabsContent value="documents">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
<CardTitle>Documents</CardTitle>
|
<div>
|
||||||
<CardDescription>
|
<CardTitle>Documents</CardTitle>
|
||||||
All NOC documents and due statements from departments
|
<CardDescription>
|
||||||
</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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
@ -1611,7 +1707,17 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<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}>
|
<TableRow key={doc.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -1661,6 +1767,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bank Details Tab */}
|
{/* Bank Details Tab */}
|
||||||
<TabsContent value="bank">
|
<TabsContent value="bank">
|
||||||
@ -1961,6 +2068,96 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Bank Details Modal */}
|
||||||
<BankDetailsModal
|
<BankDetailsModal
|
||||||
isOpen={isBankModalOpen}
|
isOpen={isBankModalOpen}
|
||||||
|
|||||||
@ -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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
@ -42,6 +42,7 @@ const STAGE_TO_ROLE_MAP: Record<string, string> = {
|
|||||||
'RBM': 'RBM',
|
'RBM': 'RBM',
|
||||||
'ZBH': 'ZBH',
|
'ZBH': 'ZBH',
|
||||||
'DD Lead': 'DD Lead',
|
'DD Lead': 'DD Lead',
|
||||||
|
'DD Head': 'DD Head',
|
||||||
'NBH': 'NBH',
|
'NBH': 'NBH',
|
||||||
'DD Admin': 'DD Admin',
|
'DD Admin': 'DD Admin',
|
||||||
'Legal': 'Legal 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'],
|
'RBM': ['RBM', 'RBM Review', 'Regional Review', 'RBM + DD-ZM Review'],
|
||||||
'ZBH': ['ZBH', 'ZBH Review', 'ZM Review'],
|
'ZBH': ['ZBH', 'ZBH Review', 'ZM Review'],
|
||||||
'DD Lead': ['DD Lead', 'DD Lead Review', 'DDL 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'],
|
'NBH': ['NBH', 'NBH Approval', 'NBH Review'],
|
||||||
'DD Admin': ['DD Admin', 'DD Admin Review'],
|
'DD Admin': ['DD Admin', 'DD Admin Review'],
|
||||||
'Awaiting F&F': ['Awaiting F&F', 'Awaiting F&F — manual initiation'],
|
'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 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 [remarks, setRemarks] = useState('');
|
||||||
const [assignToUser, setAssignToUser] = useState<string>('');
|
const [assignToUser, setAssignToUser] = useState<string>('');
|
||||||
const [userSearchQuery, setUserSearchQuery] = 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: 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: 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: 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: 5, name: 'DD Lead Review', key: 'DD Lead', description: 'DD Lead consolidated review' },
|
||||||
{ id: 6, name: 'NBH Approval', key: 'NBH', description: 'National Business Head approval' },
|
{ id: 6, name: 'DD Head Review', key: 'DD Head', description: 'DD Head final dealer development approval' },
|
||||||
{ id: 7, name: 'Legal - Resignation Letter', key: 'Legal', description: 'Legal team issues resignation approval letter' },
|
{ id: 7, name: 'NBH Approval', key: 'NBH', description: 'National Business Head approval' },
|
||||||
{ id: 8, name: 'DD Admin Review', key: 'DD Admin', description: 'DD Admin verification and final closure' },
|
{ id: 8, name: 'Legal - Resignation Letter', key: 'Legal', description: 'Legal team issues resignation approval letter' },
|
||||||
{ 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: 9, name: 'DD Admin Review', key: 'DD Admin', description: 'DD Admin verification and final closure' },
|
||||||
{ id: 10, name: 'F&F Settlement', key: 'F&F Initiated', description: 'Full & Final settlement process' },
|
{ 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: 'Completed', key: 'Completed', description: 'Resignation process finalized' }
|
{ 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 = (() => {
|
const legalStageApproved = (() => {
|
||||||
if (!resignationData) return false;
|
if (!resignationData) return false;
|
||||||
@ -194,7 +197,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
|
|||||||
|
|
||||||
const getResignationPermissions = () => {
|
const getResignationPermissions = () => {
|
||||||
if (!resignationData || !currentUser) {
|
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;
|
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 fnfPushRoles = ['DD Lead', 'DD Head', 'DD Admin', 'Super Admin'];
|
||||||
const fnfPushLegacyRoles = ['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 &&
|
const canApprove = isCurrentlyAssigned &&
|
||||||
!isFinalState &&
|
!isFinalState &&
|
||||||
!isSettlementPhase &&
|
!isSettlementPhase &&
|
||||||
@ -262,10 +320,15 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
|
|||||||
!(currentStage === 'Legal' && legalStageApproved) &&
|
!(currentStage === 'Legal' && legalStageApproved) &&
|
||||||
!(isDDLead && isDDLeadStage && !hasUploadedPPT) &&
|
!(isDDLead && isDDLeadStage && !hasUploadedPPT) &&
|
||||||
!(currentStage === 'DD Admin' && !isLwdReached) &&
|
!(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;
|
!isAwaitingFnfGate;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canApprove,
|
canApprove,
|
||||||
|
canDispatch,
|
||||||
|
dispatchMissed,
|
||||||
// SRS §7.3.2: Send Back returns to DD Admin for correction. Legal Admin only drafts/uploads
|
// 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.
|
// the Resignation Acceptance Letter and cannot send the case back to earlier reviewers.
|
||||||
canSendBack:
|
canSendBack:
|
||||||
@ -298,6 +361,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
|
|||||||
'RBM': ['RBM', 'RBM Review', 'RBM + DD-ZM Review'],
|
'RBM': ['RBM', 'RBM Review', 'RBM + DD-ZM Review'],
|
||||||
'ZBH': ['ZBH', 'ZBH Review'],
|
'ZBH': ['ZBH', 'ZBH Review'],
|
||||||
'DD Lead': ['DD Lead', 'DD Lead Review', 'Lead 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'],
|
'NBH': ['NBH', 'NBH Approval', 'NBH Review'],
|
||||||
'DD Admin': ['DD Admin', 'DD Admin Review'],
|
'DD Admin': ['DD Admin', 'DD Admin Review'],
|
||||||
'Awaiting F&F': ['Awaiting F&F', 'Awaiting F&F — manual initiation'],
|
'Awaiting F&F': ['Awaiting F&F', 'Awaiting F&F — manual initiation'],
|
||||||
@ -350,7 +414,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
|
|||||||
return 'pending';
|
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 });
|
setActionDialog({ open: true, type });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -369,7 +433,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitAction = async () => {
|
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)');
|
toast.error('Please provide remarks (min 5 characters)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1032,6 +1096,36 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
|
|||||||
</Button>
|
</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 && (
|
{permissions.canSendBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -1130,13 +1224,16 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
|
|||||||
{actionDialog.type === 'revoke' && 'Revoke Resignation Request'}
|
{actionDialog.type === 'revoke' && 'Revoke Resignation Request'}
|
||||||
{actionDialog.type === 'assign' && 'Assign to User'}
|
{actionDialog.type === 'assign' && 'Assign to User'}
|
||||||
{actionDialog.type === 'pushfnf' && 'Push to Full & Final Settlement'}
|
{actionDialog.type === 'pushfnf' && 'Push to Full & Final Settlement'}
|
||||||
|
{actionDialog.type === 'dispatch' && 'Dispatch Resignation Letter'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{actionDialog.type === 'assign'
|
{actionDialog.type === 'assign'
|
||||||
? 'Select a user to assign this request to'
|
? 'Select a user to assign this request to'
|
||||||
: actionDialog.type === 'pushfnf'
|
: actionDialog.type === 'pushfnf'
|
||||||
? 'This will move the resignation request to F&F for dues clearance'
|
? '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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@ -1270,7 +1367,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
|
|||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<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 === 'revoke' && 'Revoke'}
|
||||||
{actionDialog.type === 'assign' && 'Assign'}
|
{actionDialog.type === 'assign' && 'Assign'}
|
||||||
{actionDialog.type === 'pushfnf' && 'Push to F&F'}
|
{actionDialog.type === 'pushfnf' && 'Push to F&F'}
|
||||||
|
{actionDialog.type === 'dispatch' && 'Send to Dealer'}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
@ -263,7 +263,10 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
|
|||||||
return aliases.includes(currentStage);
|
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('RBM + DD-ZM Review') && (userRole === 'RBM' || userRole === 'DD-ZM') && !userHasApprovedJointly) ||
|
||||||
(isStage('ZBH Review') && userRole === 'ZBH') ||
|
(isStage('ZBH Review') && userRole === 'ZBH') ||
|
||||||
(isStage('DD Lead Review') && userRole === 'DD Lead') ||
|
(isStage('DD Lead Review') && userRole === 'DD Lead') ||
|
||||||
@ -323,6 +326,16 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
|
|||||||
return matched || normalized;
|
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 getProgressStatus = (stageName: string) => {
|
||||||
const isTerminal = ['Rejected', 'Revoked', 'Withdrawn'].includes(request.status);
|
const isTerminal = ['Rejected', 'Revoked', 'Withdrawn'].includes(request.status);
|
||||||
const isSuccessFinal = [
|
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 workflow finished successfully or entered F&F, ALL reached stages (including Terminated itself) turn completed
|
||||||
if (isSuccessFinal && stageIndex <= currentIndex) {
|
if (isSuccessFinal && stageIndex <= currentIndex) {
|
||||||
|
if (stageIndex < currentIndex && stageName !== 'Submitted' && !hasTimelineActivity(stageName)) {
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
return 'completed';
|
return 'completed';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stageIndex === -1) return 'pending';
|
if (stageIndex === -1) return 'pending';
|
||||||
if (currentIndex === -1) return stageName === 'Submitted' ? 'completed' : '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 (stageIndex === currentIndex) {
|
||||||
if (isTerminal) return 'completed'; // Or 'rejected' if component supports it
|
if (isTerminal) return 'completed';
|
||||||
return 'active';
|
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 = [
|
const progressStages = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@ -803,6 +829,15 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
|
|||||||
<CardDescription>Track the termination request approval process</CardDescription>
|
<CardDescription>Track the termination request approval process</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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 “Skipped” below were intentionally bypassed and never reviewed.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{progressStages.map((stage, index) => {
|
{progressStages.map((stage, index) => {
|
||||||
const documentCount = stageDocuments[stage.name]?.length || 0;
|
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="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' :
|
<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 === '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'
|
'bg-slate-100 text-slate-400'
|
||||||
}`}>
|
}`}>
|
||||||
{stage.status === 'completed' ? (
|
{stage.status === 'completed' ? (
|
||||||
<Check className="w-5 h-5" />
|
<Check className="w-5 h-5" />
|
||||||
) : stage.status === 'active' ? (
|
) : stage.status === 'active' ? (
|
||||||
<AlertTriangle className="w-5 h-5" />
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
) : stage.status === 'skipped' ? (
|
||||||
|
<FastForward className="w-5 h-5" />
|
||||||
) : (
|
) : (
|
||||||
<span>{stage.id}</span>
|
<span>{stage.id}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{index < progressStages.length - 1 && (
|
{index < progressStages.length - 1 && (
|
||||||
<div className={`w-0.5 ${stage.remarks ? 'h-32' : 'h-16'
|
<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>
|
</div>
|
||||||
@ -834,8 +873,17 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
|
|||||||
<h3 className={
|
<h3 className={
|
||||||
stage.status === 'completed' ? 'text-green-600' :
|
stage.status === 'completed' ? 'text-green-600' :
|
||||||
stage.status === 'active' ? 'text-re-red' :
|
stage.status === 'active' ? 'text-re-red' :
|
||||||
|
stage.status === 'skipped' ? 'text-amber-700' :
|
||||||
'text-slate-400'
|
'text-slate-400'
|
||||||
}>{stage.name}</h3>
|
}>{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 && (
|
{documentCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleViewStageDocuments(stage.name)}
|
onClick={() => handleViewStageDocuments(stage.name)}
|
||||||
@ -854,6 +902,14 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-600 text-sm">{stage.description}</p>
|
<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 && (
|
{stageEntries.length > 0 && (
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
|
|||||||
@ -78,7 +78,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
reason: '',
|
reason: '',
|
||||||
proposedLwd: '',
|
proposedLwd: '',
|
||||||
comments: '',
|
comments: '',
|
||||||
document: null as File | null
|
documents: [] as File[]
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchTerminations = async () => {
|
const fetchTerminations = async () => {
|
||||||
@ -214,6 +214,41 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
setAutoFilledData(mapDealerToFormData(matchedDealer));
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!autoFilledData) {
|
if (!autoFilledData) {
|
||||||
@ -221,43 +256,50 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (isPresentationMandatory) {
|
||||||
const payload = {
|
if (formData.documents.length === 0) {
|
||||||
dealerId: autoFilledData.dealerId || autoFilledData.id,
|
toast.error('Please upload at least one Presentation (.ppt or .pptx)');
|
||||||
category: formData.terminationCategory,
|
return;
|
||||||
reason: formData.reason,
|
}
|
||||||
proposedLwd: formData.proposedLwd,
|
if (!formData.documents.some(isPptFile)) {
|
||||||
comments: formData.comments
|
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');
|
toast.error('Dealer record not found for the selected dealer');
|
||||||
return;
|
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;
|
const data = response.data as any;
|
||||||
if (data?.success) {
|
if (data?.success) {
|
||||||
// Use termination.id which is the UUID
|
toast.success(formData.documents.length > 0
|
||||||
const newId = data.termination?.id;
|
? 'Termination request and documents submitted'
|
||||||
|
: 'Termination request submitted successfully');
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
fetchTerminations();
|
fetchTerminations();
|
||||||
@ -271,7 +313,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
reason: '',
|
reason: '',
|
||||||
proposedLwd: '',
|
proposedLwd: '',
|
||||||
comments: '',
|
comments: '',
|
||||||
document: null
|
documents: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -465,12 +507,45 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="document">Upload Supporting Document</Label>
|
<Label htmlFor="documents">
|
||||||
|
{isPresentationMandatory ? 'Upload Documents *' : 'Upload Supporting Documents'}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="document"
|
id="documents"
|
||||||
type="file"
|
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>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const RESIGNATION_STAGE_OPTIONS = [
|
|||||||
"RBM",
|
"RBM",
|
||||||
"ZBH",
|
"ZBH",
|
||||||
"DD Lead",
|
"DD Lead",
|
||||||
|
"DD Head",
|
||||||
"NBH",
|
"NBH",
|
||||||
"DD Admin",
|
"DD Admin",
|
||||||
"Legal",
|
"Legal",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user