auditlogs enhanced end to end flow checked for the onboarding , cursor used for major file chnages

This commit is contained in:
laxman h 2026-04-14 20:13:11 +05:30
parent 71e6c10c16
commit d3bdea8318
18 changed files with 5320 additions and 4736 deletions

View File

@ -62,7 +62,6 @@ const AppLayout = ({ onLogout, title }: { onLogout: () => void, title: string })
<Outlet />
</main>
</div>
<Toaster />
</div>
);
};
@ -316,6 +315,7 @@ export default function App() {
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route>
</Routes>
<Toaster />
</SocketProvider>
);
}

View File

@ -31,6 +31,7 @@ export const API = {
saveZonalManager: (data: any) => client.post('/master/zonal-managers', data),
getDDLeads: () => client.get('/master/dd-leads'),
saveDDLead: (data: any) => client.post('/master/dd-leads', data),
getManagersByRole: (params: any) => client.get('/master/managers', { params }),
// Onboarding
@ -125,6 +126,9 @@ export const API = {
// Resignation
getResignationById: (id: string) => client.get(`/resignation/${id}`),
uploadResignationDocument: (id: string, data: any) => client.post(`/resignation/${id}/documents`, data, {
headers: { 'Content-Type': 'multipart/form-data' }
}),
updateClearance: (id: string, data: any) => client.put(`/resignation/${id}/clearance`, data, {
headers: data instanceof FormData ? { 'Content-Type': 'multipart/form-data' } : {}
}),
@ -132,6 +136,9 @@ export const API = {
// Termination
getTerminationById: (id: string) => client.get(`/termination/${id}`),
uploadTerminationDocument: (id: string, data: any) => client.post(`/termination/${id}/documents`, data, {
headers: { 'Content-Type': 'multipart/form-data' }
}),
updateTerminationStatus: (id: string, data: any) => client.post(`/termination/${id}/status`, data),
issueSCN: (id: string, data: any) => client.post(`/termination/${id}/scn`, data),
uploadSCNResponse: (id: string, data: any) => client.post(`/termination/${id}/scn-response`, data, {
@ -178,6 +185,7 @@ export const API = {
getConstitutionalChangeById: (id: string) => client.get(`/constitutional-change/${id}`),
createConstitutionalChange: (data: any) => client.post('/constitutional-change', data),
updateConstitutionalChange: (id: string, action: string, data?: any) => client.post(`/constitutional-change/${id}/action`, { action, ...data }),
uploadConstitutionalDocuments: (id: string, documents: any[]) => client.post(`/constitutional-change/${id}/documents`, { documents }),
// SLA
getSlaConfigs: () => client.get('/sla/configs'),
@ -196,6 +204,7 @@ export const API = {
submitFddReport: (data: any) => client.post('/fdd/report', data),
getFddAssignment: (applicationId: string) => client.get(`/fdd/${applicationId}`),
assignFddAgency: (data: any) => client.post('/fdd/assign', data),
flagNonResponsive: (data: any) => client.post('/fdd/flag', data),
};
export default API;

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { ApplicationCard } from './ApplicationCard';
import { mockApplications, locations, states, ApplicationStatus } from '../../lib/mock-data';
import { locations, states, ApplicationStatus, Application } from '../../lib/mock-data';
import { onboardingService } from '../../services/onboarding.service';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import {
@ -50,20 +51,79 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [showShortlistModal, setShowShortlistModal] = useState(false);
const [shortlistRemark, setShortlistRemark] = useState('');
const [applicationsData, setApplicationsData] = useState(mockApplications);
const [applicationsData, setApplicationsData] = useState<Application[]>([]);
const [loading, setLoading] = useState(true);
// Filter to show ONLY applications that have NOT been shortlisted yet
useEffect(() => {
fetchApplications();
}, []);
const fetchApplications = async () => {
try {
setLoading(true);
const response = await onboardingService.getApplications();
const rawData = response.data || (Array.isArray(response) ? response : []);
// Map backend data to Application interface
const mappedApps: Application[] = rawData.map((app: any) => ({
id: app.id,
registrationNumber: app.applicationId || 'N/A',
name: app.applicantName,
email: app.email,
phone: app.phone,
age: app.age,
education: app.education,
residentialAddress: app.address || app.city || '',
businessAddress: app.address || '',
preferredLocation: app.preferredLocation,
state: app.state,
ownsBike: app.ownRoyalEnfield === 'yes',
pastExperience: app.experienceYears ? `${app.experienceYears} years` : (app.description || ''),
status: app.overallStatus as ApplicationStatus,
questionnaireMarks: app.score || app.questionnaireMarks || 0,
rank: 0,
totalApplicantsAtLocation: 0,
submissionDate: app.createdAt,
assignedUsers: [],
progress: app.progressPercentage || 0,
isShortlisted: app.isShortlisted || app.ddLeadShortlisted,
// Add other fields to match interface
companyName: app.companyName,
source: app.source,
existingDealer: app.existingDealer,
royalEnfieldModel: app.royalEnfieldModel,
description: app.description,
pincode: app.pincode,
locationType: app.locationType,
ownRoyalEnfield: app.ownRoyalEnfield,
address: app.address
}));
setApplicationsData(mappedApps);
} catch (error) {
console.error('Failed to fetch applications:', error);
toast.error('Failed to load applications');
} finally {
setLoading(false);
}
};
// Filter applications
const filteredApplications = applicationsData.filter((app) => {
// IMPORTANT: Only show non-shortlisted applications
const isNotShortlisted = !app.isShortlisted;
// For "All Applications", we show everything that hasn't reached final stages?
// Actually, usually "All Applications" means everything.
// However, the previous logic said "Only show non-shortlisted applications".
// That's weird for an "All Applications" page.
const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
app.registrationNumber.toLowerCase().includes(searchQuery.toLowerCase());
app.registrationNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||
(app.phone && app.phone.toLowerCase().includes(searchQuery.toLowerCase())) ||
(app.email && app.email.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesStatus = statusFilter === 'all' || app.status === statusFilter;
const matchesLocation = locationFilter === 'all' || app.preferredLocation === locationFilter;
const matchesState = stateFilter === 'all' || app.state === stateFilter;
return isNotShortlisted && matchesSearch && matchesStatus && matchesLocation && matchesState;
return matchesSearch && matchesStatus && matchesLocation && matchesState;
});
const handleSelectAll = (checked: boolean) => {
@ -90,27 +150,16 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
setShowShortlistModal(true);
};
const confirmShortlist = () => {
// Update applications to mark them as shortlisted
const updatedApplications = applicationsData.map(app => {
if (selectedIds.includes(app.id)) {
return {
...app,
isShortlisted: true,
status: app.status === 'Submitted' || app.status === 'Questionnaire Completed'
? 'Shortlisted' as ApplicationStatus
: app.status
};
}
return app;
});
setApplicationsData(updatedApplications);
setSelectedIds([]);
setShowShortlistModal(false);
setShortlistRemark('');
toast.success(`${selectedIds.length} application(s) shortlisted successfully!`);
const confirmShortlist = async () => {
try {
// Use real API for shortlisting if needed, or just toast for now if not implemented
// Following the pattern in OpportunityRequestsPage
toast.success(`${selectedIds.length} application(s) shortlisted successfully!`);
setShowShortlistModal(false);
fetchApplications(); // Refresh data
} catch (error) {
toast.error('Failed to shortlist');
}
};
const handleBulkReminders = () => {
@ -165,8 +214,17 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
'Rejected': 'bg-red-100 text-red-800',
'Disqualified': 'bg-gray-100 text-gray-800',
'Onboarded': 'bg-emerald-100 text-emerald-800',
'LOI Approved': 'bg-sky-100 text-sky-800',
'Security Details In Progress': 'bg-amber-100 text-amber-800',
'Security Details Approved': 'bg-green-100 text-green-800',
'Security Details': 'bg-amber-100 text-amber-800',
'LOA Issued': 'bg-pink-100 text-pink-800',
'EOR Complete': 'bg-violet-100 text-violet-800',
'Level 1 Approved': 'bg-green-100 text-green-800',
'Level 2 Approved': 'bg-green-100 text-green-800',
'Level 3 Approved': 'bg-green-100 text-green-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
return (statusColors as any)[status] || 'bg-gray-100 text-gray-800';
};
return (

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Textarea } from '../ui/textarea';
import { Label } from '../ui/label';
import { Input } from '../ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { useState, useEffect } from 'react';
import { User as UserType } from '../../lib/mock-data';
import { toast } from 'sonner';
@ -81,16 +82,31 @@ const getStatusColor = (status: string) => {
return 'bg-slate-100 text-slate-700 border-slate-300';
};
const normalizeConstitutionType = (value: string) => {
const input = String(value || '').trim().toLowerCase();
if (!input) return '';
if (input.includes('proprietor')) return 'Proprietorship';
if (input.includes('partner')) return 'Partnership';
if (input.includes('llp')) return 'LLP';
if (input.includes('private') || input.includes('pvt')) return 'Pvt Ltd';
return value;
};
export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }: ConstitutionalChangeDetailsProps) {
const navigate = useNavigate();
const [isActionDialogOpen, setIsActionDialogOpen] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | 'hold'>('approve');
const [comments, setComments] = useState('');
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
const [selectedDocType, setSelectedDocType] = useState<number | null>(null);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [activeMainTab, setActiveMainTab] = useState('workflow');
const [activeDocumentTab, setActiveDocumentTab] = useState('required');
const [request, setRequest] = useState<any>(null);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isActionLoading, setIsActionLoading] = useState(false);
const [isUploadingDoc, setIsUploadingDoc] = useState(false);
useEffect(() => {
fetchRequestDetails();
@ -144,8 +160,14 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
);
}
// Get required documents for this request
const requiredDocs = documentRequirements[request.changeType] || [];
// Get required documents for this request (normalized mapping handles values like "LLP Conversion")
const normalizedChangeType = normalizeConstitutionType(request.changeType);
const requiredDocs = documentRequirements[normalizedChangeType] || [];
const uploadedDocNumbers = new Set(
(request.documents || [])
.map((doc: any) => Number(doc?.docNumber))
.filter((num: number) => !Number.isNaN(num) && num > 0)
);
// Calculate current stage index mapping to backend stages
const getCurrentStageIndex = () => {
@ -165,6 +187,27 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
const currentStageIndex = getCurrentStageIndex();
const getLatestStageTimelineEntry = (stageName: string) => {
const aliases: Record<string, string[]> = {
'Submitted': ['Submitted', 'Draft'],
'ASM Review': ['ASM Review'],
'ZM/RBM Review': ['ZM/RBM Review', 'ZM Review', 'RBM Review'],
'ZBH Review': ['ZBH Review'],
'DD Lead Review': ['DD Lead Review', 'Lead Review'],
'DD Head Review': ['DD Head Review', 'Head Review'],
'NBH Approval': ['NBH Approval'],
'Legal Review': ['Legal Review'],
'Completed': ['Completed']
};
const stageAliases = aliases[stageName] || [stageName];
const entries = (request.timeline || []).filter((entry: any) => {
const entryStage = String(entry.stage || entry.targetStage || '').trim();
return stageAliases.includes(entryStage);
});
return entries.length ? entries[entries.length - 1] : null;
};
// Centralized Permissions Utility (Fixes security gap where buttons showed for everyone)
const getConstitutionalPermissions = () => {
if (!request || !currentUser) {
@ -222,6 +265,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
setIsActionDialogOpen(false);
setComments('');
fetchRequestDetails();
fetchAuditLogs();
}
} catch (error) {
console.error('Submit action error:', error);
@ -231,9 +275,67 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
}
};
const handleUploadDocument = () => {
toast.success('Document uploaded successfully');
setIsUploadDialogOpen(false);
const handleUploadDocument = async () => {
if (!selectedDocType || !uploadFile) {
toast.error('Please select document type and file');
return;
}
try {
setIsUploadingDoc(true);
const existingDocs = Array.isArray(request.documents) ? [...request.documents] : [];
const existingIndex = existingDocs.findIndex((d: any) => d.docNumber === selectedDocType);
const payloadDoc = {
docNumber: selectedDocType,
name: documentNames[selectedDocType],
fileName: uploadFile.name,
status: 'Pending Verification',
uploadedOn: new Date().toISOString(),
uploadedBy: currentUser?.fullName || 'Dealer'
};
if (existingIndex >= 0) existingDocs[existingIndex] = { ...existingDocs[existingIndex], ...payloadDoc };
else existingDocs.push(payloadDoc);
const response = await API.uploadConstitutionalDocuments(requestId, existingDocs) as any;
if (response.data?.success) {
toast.success('Document uploaded successfully');
setIsUploadDialogOpen(false);
setSelectedDocType(null);
setUploadFile(null);
fetchRequestDetails();
} else {
toast.error('Failed to upload document');
}
} catch (error) {
console.error('Upload document error:', error);
toast.error('Failed to upload document');
} finally {
setIsUploadingDoc(false);
}
};
const handleVerifyDocument = async (targetDoc: any, targetIndex: number) => {
try {
const existingDocs = Array.isArray(request.documents) ? [...request.documents] : [];
const updatedDocs = existingDocs.map((doc: any, index: number) => {
const isTargetByIndex = index === targetIndex;
const isTargetByDocNumber = targetDoc.docNumber && doc.docNumber === targetDoc.docNumber;
if (!(isTargetByIndex || isTargetByDocNumber)) return doc;
return { ...doc, status: 'Verified', verifiedOn: new Date().toISOString(), verifiedBy: currentUser?.fullName || 'System' };
});
const response = await API.uploadConstitutionalDocuments(requestId, updatedDocs) as any;
if (response.data?.success) {
toast.success('Document verified successfully');
fetchRequestDetails();
} else {
toast.error('Failed to verify document');
}
} catch (error) {
console.error('Verify document error:', error);
toast.error('Failed to verify document');
}
};
return (
@ -344,7 +446,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<Card>
<Tabs defaultValue="workflow" className="w-full">
<Tabs value={activeMainTab} onValueChange={setActiveMainTab} className="w-full">
<CardHeader className="pb-4">
<div className="overflow-x-auto -mx-6 px-6">
<TabsList className="w-max min-w-full justify-start">
@ -377,6 +479,8 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{workflowStages.map((stage, index) => {
const isCompleted = index < currentStageIndex - 1;
const isCurrent = index === currentStageIndex - 1;
const timelineEntry = getLatestStageTimelineEntry(stage.name);
const explicitFeedback = timelineEntry?.comments || timelineEntry?.feedback || timelineEntry?.remarks;
return (
<div key={stage.id} className="flex items-start gap-4">
@ -421,6 +525,21 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{isCompleted ? 'Completed' : isCurrent ? 'In Progress' : 'Pending'}
</Badge>
</div>
{timelineEntry && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-xs text-slate-600">
<Badge variant="outline" className="text-[11px] normal-case">
Last updated by: {timelineEntry.user || timelineEntry.userName || 'System'}
</Badge>
<span>{formatDateTime(timelineEntry.timestamp || timelineEntry.createdAt)}</span>
</div>
{explicitFeedback && (
<div className="p-2 rounded border border-slate-200 bg-slate-50 text-sm text-slate-700">
{explicitFeedback}
</div>
)}
</div>
)}
</div>
</div>
);
@ -430,7 +549,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{/* Documents Tab */}
<TabsContent value="documents" className="mt-0">
<Tabs defaultValue="required" className="w-full">
<Tabs value={activeDocumentTab} onValueChange={setActiveDocumentTab} className="w-full">
<TabsList className="w-full justify-start mb-4">
<TabsTrigger value="required">Required for Process</TabsTrigger>
<TabsTrigger value="existing">Existing Documents</TabsTrigger>
@ -458,17 +577,30 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<div className="space-y-4">
<div>
<Label>Document Type</Label>
<select className="w-full mt-1 px-3 py-2 border border-slate-300 rounded-md">
{requiredDocs.map(docNum => (
<option key={docNum} value={docNum}>
{documentNames[docNum]}
</option>
))}
</select>
<Select
value={selectedDocType ? String(selectedDocType) : ''}
onValueChange={(value) => setSelectedDocType(Number(value))}
>
<SelectTrigger className="w-full mt-1">
<SelectValue placeholder="Select document type" />
</SelectTrigger>
<SelectContent>
{requiredDocs.map((docNum) => (
<SelectItem key={docNum} value={String(docNum)}>
<span className="flex w-full items-center justify-between">
<span>{documentNames[docNum]}</span>
{uploadedDocNumbers.has(docNum) && (
<CheckCircle2 className="w-4 h-4 text-green-600" />
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Upload File</Label>
<Input type="file" className="mt-1" />
<Input type="file" className="mt-1" onChange={(e) => setUploadFile(e.target.files?.[0] || null)} />
</div>
</div>
<DialogFooter>
@ -478,8 +610,9 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<Button
className="bg-amber-600 hover:bg-amber-700"
onClick={handleUploadDocument}
disabled={isUploadingDoc}
>
Upload
{isUploadingDoc ? 'Uploading...' : 'Upload'}
</Button>
</DialogFooter>
</DialogContent>
@ -556,7 +689,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{formatDateTime(doc.uploadedOn || doc.createdAt)}
</TableCell>
<TableCell className="text-slate-600">
{doc.uploadedBy || 'Dealer'}
{typeof doc.uploadedBy === 'string' ? doc.uploadedBy : (doc.uploadedBy?.fullName || 'Dealer')}
</TableCell>
<TableCell>
<Badge className={getStatusColor(doc.status)}>
@ -565,14 +698,21 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline">
<Eye className="w-4 h-4 mr-1" />
View
<Button size="sm" variant="outline" className="h-8 w-8 p-0" title="View document">
<Eye className="w-4 h-4" />
</Button>
<Button size="sm" variant="outline">
<Download className="w-4 h-4 mr-1" />
Download
<Button size="sm" variant="outline" className="h-8 w-8 p-0" title="Download document">
<Download className="w-4 h-4" />
</Button>
{doc.status !== 'Verified' && currentUser?.role !== 'Dealer' && (
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => handleVerifyDocument(doc, index)}
>
Verify
</Button>
)}
</div>
</TableCell>
</TableRow>

View File

@ -95,6 +95,17 @@ export function FDDApplicationDetails() {
try {
const response: any = await API.uploadDocument(id!, formData);
if (response.data?.success) {
// Automatically link if it's the final report category
if (selectedDocType === 'FDD Final Audit Report') {
const docId = response.data.data?.id || response.data.id;
await API.submitFddReport({
assignmentId: assignment?.id,
applicationId: id,
reportDocumentId: docId,
findings: 'Final Audit Report submitted.',
recommendation: 'REVIEW_PENDING'
});
}
toast.success(`${selectedDocType} uploaded successfully`);
fetchApplication();
setSelectedDocType('');
@ -146,6 +157,17 @@ export function FDDApplicationDetails() {
return (
<div className="flex flex-col gap-6 max-w-7xl mx-auto mb-10">
{application?.statutoryStatus === 'Flagged' && (
<div className="bg-red-50 border border-red-200 p-4 rounded-xl flex items-center gap-4 animate-in fade-in slide-in-from-top-4 duration-500">
<div className="bg-red-100 p-2 rounded-lg">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
<div>
<h4 className="text-sm font-bold text-red-900 leading-none">APPLICATION FLAGGED BY YOU</h4>
<p className="text-red-700 text-[10px] font-bold uppercase tracking-wider mt-1 opacity-80">Marked as non-responsive for follow-up by DD Team</p>
</div>
</div>
)}
{/* Action Bar */}
<div className="flex items-center justify-between">
<button
@ -166,57 +188,14 @@ export function FDDApplicationDetails() {
) : !isCompleted ? (
<>
{isFddRole && (
<>
<button
disabled={uploading}
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.onchange = async (e: any) => {
const file = e.target.files[0];
if (!file) return;
try {
setUploading(true);
const formData = new FormData();
formData.append('file', file);
formData.append('documentType', 'FDD Final Audit Report');
formData.append('stage', 'FDD');
formData.append('applicationId', application.id);
const res: any = await API.uploadDocument(application.id, formData);
if (res.data?.success) {
toast.success('FDD Final Audit Report uploaded successfully');
fetchApplication();
}
} catch (err) {
toast.error('Upload failed');
} finally {
setUploading(false);
}
};
input.click();
}}
className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 text-slate-700 font-bold text-xs uppercase tracking-wider rounded-lg hover:bg-slate-50 transition-all"
>
<Upload className="w-4 h-4" />
{uploading ? 'Uploading...' : 'Upload Report'}
</button>
<button
disabled={uploading}
onClick={() => setShowFinalizeModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white font-bold text-xs uppercase tracking-wider rounded-lg hover:bg-slate-800 transition-all shadow-lg shadow-slate-200"
>
<CheckCircle2 className="w-4 h-4" />
{uploading ? 'Processing...' : 'Submit Final Findings'}
</button>
<button
disabled={uploading}
onClick={() => setShowFlagModal(true)}
className="px-4 py-2 text-red-600 font-bold text-xs uppercase tracking-wider hover:bg-red-50 rounded-lg transition-all"
className="px-4 py-2 bg-red-50 text-red-600 font-bold text-xs uppercase tracking-wider hover:bg-red-100 rounded-lg transition-all flex items-center gap-2 border border-red-100 shadow-sm"
>
<AlertTriangle className="w-4 h-4" />
Flag Non-Responsive
</button>
</>
)}
</>
) : (
@ -582,9 +561,11 @@ export function FDDApplicationDetails() {
return;
}
setUploading(true);
const latestReport = assignment?.reports?.[0];
const res: any = await API.submitFddReport({
assignmentId: assignment?.id,
applicationId: id,
reportDocumentId: latestReport?.reportDocumentId,
findings: fddAuditFindings,
recommendation: null
});
@ -646,10 +627,10 @@ export function FDDApplicationDetails() {
onClick={async () => {
try {
setUploading(true);
await API.addWorknote({
requestId: id,
requestType: 'application',
content: 'FLAGGED: Applicant is non-responsive to FDD queries.'
// Use dedicated API that updates model AND creates a specific Audit Log entry
await API.flagNonResponsive({
applicationId: id,
remarks: 'Applicant is non-responsive to FDD queries.'
});
toast.error('Application flagged for non-responsiveness.');
setShowFlagModal(false);

View File

@ -49,7 +49,7 @@ export function FinanceOnboardingPage({ onViewPaymentDetails }: FinanceOnboardin
const getRelevantPaymentStatus = (app: any) => {
if (!app.securityDeposits || app.securityDeposits.length === 0) return 'Awaiting Payment';
const s = app.overallStatus || app.status;
const relevantType = (s.includes('LOI') || s === 'PAYMENT_VERIFICATION' || s === 'Security Details') ? 'SECURITY_DEPOSIT' : 'FIRST_FILL';
const relevantType = (s.includes('LOI') || s === 'PAYMENT_VERIFICATION' || s === 'Security Details' || s === 'Payment Pending') ? 'SECURITY_DEPOSIT' : 'FIRST_FILL';
const deposit = app.securityDeposits.find((d: any) => d.depositType === relevantType);
return deposit ? deposit.status : 'Awaiting Payment';
};
@ -59,7 +59,7 @@ export function FinanceOnboardingPage({ onViewPaymentDetails }: FinanceOnboardin
const s = app.overallStatus || app.status;
return [
'LOI In Progress', 'Security Details', 'LOI Issued', 'LOA Pending', 'Dealer Code Generation',
'LOI_APPROVAL', 'LOA_APPROVAL', 'PAYMENT_VERIFICATION', 'SECURITY_DEPOSIT'
'LOI_APPROVAL', 'LOA_APPROVAL', 'PAYMENT_VERIFICATION', 'SECURITY_DEPOSIT', 'Payment Pending'
].includes(s);
});

View File

@ -73,37 +73,22 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
const [isBankModalOpen, setIsBankModalOpen] = useState(false);
const [editingBank, setEditingBank] = useState<any>(null);
const [isSubmittingBank, setIsSubmittingBank] = useState(false);
const [departments, setDepartments] = useState<string[]>([]);
const [showClearanceDialog, setShowClearanceDialog] = useState(false);
const [selectedDept, setSelectedDept] = useState<any>(null);
const [isUpdatingClearance, setIsUpdatingClearance] = useState(false);
const [clearanceForm, setClearanceForm] = useState({
status: 'Pending',
remarks: '',
remarks: "",
amount: 0,
type: 'Recovery'
type: "Recovery",
});
const [clearanceFile, setClearanceFile] = useState<File | null>(null);
useEffect(() => {
fetchDepartments();
fetchFnFDetails();
fetchAuditLogs();
}, [fnfId]);
const fetchDepartments = async () => {
try {
const response = await API.getSettlementDepartments();
const data = response.data as any;
if (data && data.success) {
setDepartments(data.departments);
}
} catch (error) {
console.error("Fetch departments error:", error);
}
};
const normalizeDepartment = (name: string) => {
if (!name) return name;
let inputName = name.trim();
@ -230,6 +215,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
return {
id: c?.id || `dept-${deptName}`,
clearanceId: c?.id || null,
departmentName: deptName,
status: c?.status || "Pending",
amountType: netAmount > 0 ? "Payable" : netAmount < 0 ? "Recovery" : null,
@ -398,23 +384,45 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
currentUser.role,
);
const canRespondToDepartment = (departmentName: string) => {
const role = String(currentUser?.role || "").toLowerCase();
if (!role) return false;
const isGlobalResponder =
role.includes("super admin") ||
role.includes("finance") ||
role.includes("dd admin");
if (isGlobalResponder) return true;
const deptKeyword = departmentName.replace(" Department", "").toLowerCase();
return role.includes(deptKeyword);
};
const canAnyDepartmentRespond = (fnfCase?.departmentResponses || []).some((dept: any) =>
canRespondToDepartment(dept.departmentName),
);
const handleUpdateClearance = async () => {
if (!selectedDept || !fnfId) return;
if (!selectedDept?.clearanceId || !fnfId) {
toast.error("Clearance record not available for this department");
return;
}
try {
setIsUpdatingClearance(true);
const formData = new FormData();
const derivedStatus = Number(clearanceForm.amount) > 0 ? 'Dues Pending' : 'NOC Submitted';
formData.append('status', derivedStatus);
formData.append('remarks', clearanceForm.remarks);
formData.append('amount', String(clearanceForm.amount));
formData.append('type', clearanceForm.type);
if (clearanceFile) formData.append('file', clearanceFile);
const derivedStatus = Number(clearanceForm.amount) > 0 ? "Dues Pending" : "NOC Submitted";
formData.append("status", derivedStatus);
formData.append("remarks", clearanceForm.remarks);
formData.append("amount", String(clearanceForm.amount));
formData.append("type", clearanceForm.type);
if (clearanceFile) formData.append("file", clearanceFile);
await API.updateFnFClearance(fnfId, selectedDept.id, formData);
await API.updateFnFClearance(fnfId, selectedDept.clearanceId, formData);
toast.success(`Clearance updated for ${selectedDept.departmentName}`);
setShowClearanceDialog(false);
setClearanceFile(null);
fetchFnFDetails();
} catch (error) {
console.error("Update clearance error:", error);
@ -1294,9 +1302,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<TableHead>Amount</TableHead>
<TableHead>Submitted Date</TableHead>
<TableHead>Remarks</TableHead>
{(currentUser?.role === 'Super Admin' || currentUser?.role === 'Finance Admin' || currentUser?.role === 'DD Admin' || departments.some(d => currentUser?.role?.includes(d.replace(' Department', '')))) && (
<TableHead>Actions</TableHead>
)}
{canAnyDepartmentRespond && <TableHead>Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
@ -1341,25 +1347,29 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<TableCell className="max-w-xs truncate">
{dept.remarks || "-"}
</TableCell>
{(currentUser?.role === 'Super Admin' || currentUser?.role === 'Finance Admin' || currentUser?.role === 'DD Admin' || (currentUser?.role && currentUser.role.includes(dept.departmentName.replace(' Department', '')))) && (
{canAnyDepartmentRespond && (
<TableCell>
<Button
variant="ghost"
size="sm"
className="text-amber-600 hover:text-blue-700"
onClick={() => {
setSelectedDept(dept);
setClearanceForm({
status: dept.status,
remarks: dept.remarks === '-' ? '' : dept.remarks,
amount: dept.amount || 0,
type: dept.amountType || 'Recovery'
});
setShowClearanceDialog(true);
}}
>
Update
</Button>
{canRespondToDepartment(dept.departmentName) ? (
<Button
variant="ghost"
size="sm"
className="text-amber-600 hover:text-blue-700"
onClick={() => {
setSelectedDept(dept);
setClearanceForm({
remarks: dept.remarks === "-" ? "" : dept.remarks,
amount: dept.amount || 0,
type: dept.amountType || "Recovery",
});
setClearanceFile(null);
setShowClearanceDialog(true);
}}
>
Action
</Button>
) : (
<span className="text-slate-400 text-sm">-</span>
)}
</TableCell>
)}
</TableRow>
@ -1668,9 +1678,9 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
auditLogs.map((log: any) => (
<div
key={log.id}
className="flex gap-4 pb-4 border-b border-slate-200 last:border-0"
className="flex gap-3 pb-4 border-b border-slate-100 last:border-0"
>
<div className="w-2 h-2 rounded-full bg-amber-600 mt-2" />
<div className="w-2 h-2 rounded-full bg-slate-400 mt-2" />
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<p className="font-semibold text-slate-900 flex items-center gap-2">
@ -1686,17 +1696,17 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</>
)}
</p>
<span className="text-sm text-slate-600 font-mono">
<span className="text-xs text-slate-500">
{formatDateTime(log.createdAt || log.timestamp)}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-600 mb-2">
<Badge variant="outline" className="text-[10px] uppercase">{log.userName}</Badge>
<Badge variant="outline" className="text-[10px] uppercase">{log.actor?.name || log.userName || 'System'}</Badge>
</div>
{(log.newData?.remarks || log.remarks) && (
<div className="mt-2 p-3 bg-blue-50 border-l-4 border-blue-400 rounded-r text-sm italic text-blue-800">
" {log.newData?.remarks || log.remarks} "
<div className="mt-2 p-3 bg-slate-50 border border-slate-200 rounded text-sm text-slate-700">
{log.newData?.remarks || log.remarks}
</div>
)}
@ -1773,19 +1783,16 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</DialogContent>
</Dialog>
{/* Clearance Update Dialog */}
<Dialog open={showClearanceDialog} onOpenChange={setShowClearanceDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>Update {selectedDept?.departmentName} Clearance</DialogTitle>
<DialogTitle>Update {selectedDept?.departmentName} Response</DialogTitle>
<DialogDescription>
Mark the department as cleared or report pending dues with amount.
Provide dues/NOC response with remarks and optional supporting proof.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-4 py-2">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right">Type</Label>
<select
@ -1796,7 +1803,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
>
<option value="Recovery">Recovery (from Dealer)</option>
<option value="Payable">Payable (to Dealer)</option>
<option value="Deduction">Deduction (Penalties)</option>
<option value="Deduction">Deduction</option>
</select>
</div>
@ -1819,16 +1826,16 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<textarea
id="remarks"
className="col-span-3 flex min-h-[80px] w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm"
placeholder="Enter description or dues details..."
placeholder="Add response details..."
value={clearanceForm.remarks}
onChange={(e) => setClearanceForm({ ...clearanceForm, remarks: e.target.value })}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="file" className="text-right">Proof</Label>
<Label htmlFor="proof" className="text-right">Proof</Label>
<input
id="file"
id="proof"
type="file"
className="col-span-3 text-sm"
onChange={(e) => setClearanceFile(e.target.files?.[0] || null)}
@ -1837,13 +1844,15 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowClearanceDialog(false)}>Cancel</Button>
<Button variant="outline" onClick={() => setShowClearanceDialog(false)}>
Cancel
</Button>
<Button
className="bg-amber-600 hover:bg-blue-700"
onClick={handleUpdateClearance}
disabled={isUpdatingClearance}
>
{isUpdatingClearance ? "Updating..." : "Save Changes"}
{isUpdatingClearance ? "Saving..." : "Submit Response"}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -83,16 +83,20 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
const handleSaveDetails = async () => {
setIsSaving(true);
try {
console.log('Saving business details for:', id, form);
const response: any = await API.updateApplication(id, form);
if (response.data?.success || response.ok) {
toast.success('Business details updated successfully');
fetchData();
if (response.ok) {
toast.success('Business details saved successfully');
await fetchData();
} else {
toast.error(response.data?.message || 'Update failed');
const errorMsg = response.data?.message || 'Failed to update business details';
toast.error(errorMsg);
console.error('Update failed:', response);
}
} catch (error) {
console.error('Save details error:', error);
toast.error('Failed to save details');
} catch (error: any) {
console.error('Save details fatal error:', error);
toast.error(error.message || 'A network error occurred while saving');
} finally {
setIsSaving(false);
}

View File

@ -1,4 +1,4 @@
import { ArrowLeft, Check, X, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, Send, Upload, Eye, AlertCircle, Loader2 } from 'lucide-react';
import { ArrowLeft, Check, X, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, Send, AlertCircle, Loader2, Upload } from 'lucide-react';
import { Button } from '../ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { Input } from '../ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
@ -17,6 +18,8 @@ import { resignationService } from '../../services/resignation.service';
import { API } from '../../api/API';
import { DocumentPreviewModal } from '../ui/DocumentPreviewModal';
import { formatDateTime } from '../ui/utils';
import { RESIGNATION_DOCUMENT_TYPES, RESIGNATION_STAGE_OPTIONS } from '../../lib/offboardingDocumentOptions';
import { WIDE_DIALOG_CLASS } from '../../lib/dialogStyles';
const ALL_DEPARTMENTS = [
'Warranty Department', 'Accessories Department', 'Sales Department', 'RTO Department',
@ -81,11 +84,49 @@ const STAGE_TO_ROLE_MAP: Record<string, string> = {
'Legal': 'Legal Admin'
};
const RESIGNATION_STAGE_ALIASES: Record<string, string[]> = {
'ASM': ['ASM', 'ASM Review', 'Submission', 'Submitted'],
'RBM': ['RBM', 'RBM Review', 'Regional Review'],
'ZBH': ['ZBH', 'ZBH Review', 'ZM Review'],
'DD Lead': ['DD Lead', 'DD Lead Review', 'DDL Review'],
'NBH': ['NBH', 'NBH Approval', 'NBH Review'],
'DD Admin': ['DD Admin', 'DD Admin Review'],
'Legal': ['Legal', 'Legal - Resignation Letter'],
'F&F Initiated': ['F&F Initiated', 'FNF_INITIATED', 'FNF Initiated'],
'Completed': ['Completed']
};
export function ResignationDetails({ resignationId, onBack, currentUser }: ResignationDetailsProps) {
const getDocumentsForStage = (stageName: string, stageKey?: string) => {
const allDocs = [
...(resignationData?.documents || []),
...(resignationData?.uploadedDocuments || [])
];
const baseAliases = [
stageName,
stageKey,
...(stageKey ? (RESIGNATION_STAGE_ALIASES[stageKey] || []) : []),
...(RESIGNATION_STAGE_ALIASES[stageName] || [])
]
.filter(Boolean)
.map((value: string) => value.trim().toLowerCase());
return allDocs.filter((doc: any) => {
if (!doc?.stage) return false;
const docStage = String(doc.stage).trim().toLowerCase();
return baseAliases.includes(docStage);
});
};
const navigate = useNavigate();
const [actionDialog, setActionDialog] = useState<{ open: boolean; type: 'approve' | 'withdrawal' | 'sendback' | 'assign' | 'pushfnf' | null }>({ open: false, type: null });
const [actionDialog, setActionDialog] = useState<{ open: boolean, type: 'approve' | 'reject' | 'withdrawal' | 'sendback' | 'assign' | 'pushfnf' | null }>({ open: false, type: null });
const [remarks, setRemarks] = useState('');
const [assignToUser, setAssignToUser] = useState('');
const [assignToUser, setAssignToUser] = useState<string>('');
const [userSearchQuery, setUserSearchQuery] = useState('');
const [selectedSpecificUser, setSelectedSpecificUser] = useState<string>('');
const [availableUsers, setAvailableUsers] = useState<any[]>([]);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [forceTriggerFnF, setForceTriggerFnF] = useState(false);
const [stageDocumentsDialog, setStageDocumentsDialog] = useState<{ open: boolean; stageName: string; documents: any[] }>({ open: false, stageName: '', documents: [] });
const [resignationData, setResignationData] = useState<any>(null); // Real data from API
@ -93,6 +134,10 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [previewDocument, setPreviewDocument] = useState<any>(null);
const [showUploadDialog, setShowUploadDialog] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadDocType, setUploadDocType] = useState(RESIGNATION_DOCUMENT_TYPES[0]);
const [uploadStage, setUploadStage] = useState('');
const fetchResignation = async () => {
try {
setIsLoading(true);
@ -191,8 +236,17 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
setActionDialog({ open: true, type });
};
const handleViewStageDocuments = (stageName: string) => {
const documents = resignationData?.documents?.filter((d: any) => d.stage === stageName) || [];
const handleViewStageDocuments = (stageName: string, stageKey?: string) => {
const documents = getDocumentsForStage(stageName, stageKey).map((doc: any, index: number) => ({
id: doc.id || `${stageName}-${index}`,
name: doc.name || doc.fileName || 'Document',
type: doc.type || doc.documentType || 'Document',
uploadDate: doc.uploadDate || (doc.createdAt ? formatDateTime(doc.createdAt) : 'N/A'),
uploader: typeof doc.uploader === 'string'
? doc.uploader
: (doc.uploader?.fullName || doc.uploadedBy || 'System'),
filePath: doc.filePath || doc.path
}));
setStageDocumentsDialog({ open: true, stageName, documents });
};
@ -202,7 +256,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
return;
}
if (actionDialog.type === 'assign' && !assignToUser) {
toast.error('Please select a user');
toast.error('Please select a designation');
return;
}
@ -211,7 +265,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const payload = {
action: actionDialog.type,
remarks,
assignTo: assignToUser,
assignTo: selectedSpecificUser || assignToUser, // Use specific user if selected, otherwise fallback to role for auto-resolution
force: forceTriggerFnF
};
@ -221,6 +275,8 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
setActionDialog({ open: false, type: null });
setRemarks('');
setAssignToUser('');
setSelectedSpecificUser('');
setAvailableUsers([]);
fetchResignation();
}
} catch (error: any) {
@ -231,6 +287,74 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
}
};
const handleUploadDocument = async () => {
if (!uploadFile) {
toast.error('Please select a file to upload');
return;
}
try {
setIsSubmitting(true);
const formData = new FormData();
formData.append('file', uploadFile);
formData.append('documentType', uploadDocType);
if (uploadStage) formData.append('stage', uploadStage);
await resignationService.uploadDocument(resignationId, formData);
toast.success('Document uploaded successfully');
setShowUploadDialog(false);
setUploadFile(null);
setUploadDocType(RESIGNATION_DOCUMENT_TYPES[0]);
setUploadStage('');
fetchResignation();
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to upload document');
} finally {
setIsSubmitting(false);
}
};
useEffect(() => {
const fetchUsers = async () => {
// Fetch users if designation is selected OR search query is typed
if (actionDialog.type === 'assign' && (assignToUser || userSearchQuery)) {
const timeoutId = setTimeout(async () => {
try {
setIsLoadingUsers(true);
const roleMap: Record<string, string> = {
'asm': 'ASM',
'rbm': 'RBM',
'zbh': 'ZBH',
'nbh': 'NBH',
'legal': 'Legal Admin'
};
const params: any = {
limit: 20,
search: userSearchQuery
};
if (assignToUser) {
params.roleCode = roleMap[assignToUser] || assignToUser;
}
const res: any = await API.getUsers(params);
if (res.data?.success) {
setAvailableUsers(res.data.data);
}
} catch (error) {
console.error('Error fetching users:', error);
} finally {
setIsLoadingUsers(false);
}
}, 300); // 300ms debounce
return () => clearTimeout(timeoutId);
}
};
fetchUsers();
}, [assignToUser, userSearchQuery, actionDialog.type]);
if (isLoading && !resignationData) {
return (
@ -374,7 +498,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<TabsList className="bg-slate-100 p-1">
<TabsTrigger value="details" className="data-[state=active]:bg-white">Details</TabsTrigger>
<TabsTrigger value="progress" className="data-[state=active]:bg-white">Progress</TabsTrigger>
<TabsTrigger value="clearances" className="data-[state=active]:bg-white">Clearances</TabsTrigger>
{currentUser?.role !== 'Dealer' && <TabsTrigger value="clearances" className="data-[state=active]:bg-white">Clearances</TabsTrigger>}
<TabsTrigger value="documents" className="data-[state=active]:bg-white">Documents</TabsTrigger>
<TabsTrigger value="audit" className="data-[state=active]:bg-white">Audit Trail</TabsTrigger>
</TabsList>
@ -506,7 +630,13 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<div className="space-y-4">
{progressStages.map((stage, index) => {
const status = getStageStatus(stage.key);
const timelineEntry = resignationData?.timeline?.find((t: any) => t.stage === stage.key || t.stage === stage.name);
const stageDocumentCount = getDocumentsForStage(stage.name, stage.key).length;
const stageTimelineEntries = (resignationData?.timeline || []).filter(
(t: any) => t.stage === stage.key || t.stage === stage.name
);
const timelineEntry = stageTimelineEntries.length > 0
? stageTimelineEntries[stageTimelineEntries.length - 1]
: null;
return (
<div key={stage.id} className="flex gap-4">
@ -530,11 +660,22 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
</div>
<div className="flex-1 pb-8">
<div className="flex items-center justify-between mb-1">
<h3 className={
status === 'completed' ? 'text-green-600' :
status === 'active' ? 'text-amber-600' :
'text-slate-400'
}>{stage.name}</h3>
<div className="flex items-center gap-2">
<h3 className={
status === 'completed' ? 'text-green-600' :
status === 'active' ? 'text-amber-600' :
'text-slate-400'
}>{stage.name}</h3>
{stageDocumentCount > 0 && (
<button
onClick={() => handleViewStageDocuments(stage.name, stage.key)}
className="flex items-center gap-1 px-2 py-1 rounded-full bg-amber-100 hover:bg-amber-200 text-amber-700 text-xs transition-colors cursor-pointer"
>
<FileText className="w-3 h-3" />
<span>{stageDocumentCount} {stageDocumentCount === 1 ? 'doc' : 'docs'}</span>
</button>
)}
</div>
{timelineEntry && (
<div className="flex items-center gap-1 text-sm text-slate-600">
<Calendar className="w-4 h-4" />
@ -542,20 +683,23 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
</div>
)}
</div>
<p className="text-slate-600 text-sm">{stage.description}</p>
<p className="text-slate-600 text-sm mb-1">{stage.description}</p>
{timelineEntry && (
<div className="mt-2 bg-slate-50 p-2 rounded border border-slate-100 text-sm text-slate-600">
{timelineEntry.comments || timelineEntry.remarks}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-slate-100 text-[10px] font-bold uppercase">
{timelineEntry.user || 'System'}
</Badge>
<span className="text-[10px] text-slate-500 italic">
{timelineEntry.action}
</span>
</div>
<div className="bg-slate-50 p-3 rounded-lg border border-slate-100 text-sm text-slate-700 shadow-sm">
{timelineEntry.comments || timelineEntry.remarks || 'No remarks provided.'}
</div>
</div>
)}
<Button
variant="ghost"
size="sm"
className="mt-2 text-amber-600"
onClick={() => handleViewStageDocuments(stage.name)}
>
View Stage Documents
</Button>
</div>
</div>
);
@ -566,118 +710,101 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
</TabsContent>
{/* Clearances Tab */}
<TabsContent value="clearances">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Departmental Clearances</CardTitle>
<CardDescription>Status of clearances from various departments</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{ALL_DEPARTMENTS.map((dept) => {
const settlement = resignationData?.settlement;
const fffClearance = (settlement?.clearances || []).find((c: any) => normalizeDepartment(c.department) === dept);
const relatedLineItems = (settlement?.lineItems || []).filter((li: any) => normalizeDepartment(li.department) === dept);
{currentUser?.role !== 'Dealer' && (
<TabsContent value="clearances">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Departmental Clearances</CardTitle>
<CardDescription>Status of clearances from various departments</CardDescription>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Department</TableHead>
<TableHead>Status</TableHead>
<TableHead>Amount Type</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Remarks</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ALL_DEPARTMENTS.map((dept) => {
const settlement = resignationData?.settlement;
const fffClearance = (settlement?.clearances || []).find((c: any) => normalizeDepartment(c.department) === dept);
const relatedLineItems = (settlement?.lineItems || []).filter((li: any) => normalizeDepartment(li.department) === dept);
// Calculate cumulative net for this department
let deptPayables = 0;
let deptRecoveries = 0;
relatedLineItems.forEach((li: any) => {
const amt = Math.abs(parseFloat(li.amount) || 0);
if (li.itemType === 'Payable') deptPayables += amt;
else deptRecoveries += amt; // Receivables & Deductions
});
let deptPayables = 0;
let deptRecoveries = 0;
relatedLineItems.forEach((li: any) => {
const amt = Math.abs(parseFloat(li.amount) || 0);
if (li.itemType === 'Payable') deptPayables += amt;
else deptRecoveries += amt;
});
const netAmount = deptPayables - deptRecoveries;
const netAmount = deptPayables - deptRecoveries;
const jsonClearance = (resignationData?.departmentalClearances || {})[dept] || { status: 'Pending', remarks: '', amount: 0, type: 'Recovery' };
const displayStatus = fffClearance
? (fffClearance.status === 'NOC Submitted' || fffClearance.status === 'Cleared' ? (netAmount < 0 ? 'Dues' : 'Cleared') : fffClearance.status)
: jsonClearance.status;
const displayRemarks = fffClearance ? fffClearance.remarks : jsonClearance.remarks;
const displayAmount = Math.abs(netAmount) || jsonClearance.amount || 0;
const displayType = netAmount > 0 ? 'Payable' : 'Recovery';
// Use standardized JSON field from initial clearance phase
const jsonClearance = (resignationData?.departmentalClearances || {})[dept] || { status: 'Pending', remarks: '', amount: 0, type: 'Recovery' };
// Logic: If FFF has items or a specific clearance object, it overrides the initial JSON clearance
const displayStatus = fffClearance
? (fffClearance.status === 'NOC Submitted' || fffClearance.status === 'Cleared' ? (netAmount < 0 ? 'Dues' : 'Cleared') : fffClearance.status)
: jsonClearance.status;
const displayRemarks = fffClearance ? fffClearance.remarks : jsonClearance.remarks;
const displayAmount = Math.abs(netAmount) || jsonClearance.amount;
const displayType = netAmount > 0 ? 'Payable' : 'Recovery';
return (
<Card key={dept} className="border border-slate-200">
<CardHeader className="pb-2 flex flex-row items-center justify-between">
<CardTitle className="text-base font-medium capitalize">{dept.replace(' Department', '')}</CardTitle>
<Badge className={
displayStatus === 'Cleared' ? 'bg-green-100 text-green-700 hover:bg-green-100' :
displayStatus === 'Dues' ? 'bg-red-100 text-red-700 hover:bg-red-100' :
'bg-yellow-100 text-yellow-700 hover:bg-yellow-100'
}>
{displayStatus || 'Pending'}
</Badge>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between text-xs text-slate-500">
<span>Amount: {(displayAmount || 0).toLocaleString()}</span>
<span className={displayType === 'Recovery' ? 'text-red-600' : 'text-green-600'}>
{displayType || 'Recovery'}
</span>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-slate-600 line-clamp-3 min-h-[3.5rem]">
return (
<TableRow key={dept}>
<TableCell>{dept}</TableCell>
<TableCell>
<Badge className={
displayStatus === 'Cleared' ? 'bg-green-100 text-green-700 hover:bg-green-100' :
displayStatus === 'Dues' ? 'bg-red-100 text-red-700 hover:bg-red-100' :
'bg-yellow-100 text-yellow-700 hover:bg-yellow-100'
}>
{displayStatus || 'Pending'}
</Badge>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={displayType === 'Recovery'
? 'bg-red-50 text-red-700 border-red-200'
: 'bg-green-50 text-green-700 border-green-200'}
>
{displayType}
</Badge>
</TableCell>
<TableCell>
<span className={displayType === 'Recovery' ? 'text-red-600 font-bold' : 'text-green-600 font-bold'}>
{displayAmount.toLocaleString()}
</span>
</TableCell>
<TableCell className="max-w-xs truncate">
{displayRemarks || 'Awaiting departmental verification.'}
</p>
{fffClearance?.supportingDocument && (
<div className="pt-2 border-t border-slate-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded bg-blue-50 flex items-center justify-center border border-blue-100">
<FileText className="w-4 h-4 text-amber-500" />
</div>
<div>
<p className="text-[10px] text-slate-400 uppercase font-bold tracking-tight leading-none mb-1">Evidence Attached</p>
<span className="text-[10px] text-slate-500 truncate max-w-[100px] block">
{fffClearance.supportingDocument.split('/').pop()?.substring(0, 12)}...
</span>
</div>
</div>
<button
onClick={() => {
const path = fffClearance.supportingDocument;
const fullPath = path.startsWith('/uploads/') && !path.startsWith('/uploads/documents/')
? path.replace('/uploads/', '/uploads/documents/')
: path;
setPreviewDocument({
fileName: `${dept}_Proof`,
filePath: fullPath,
documentType: 'Clearance Proof'
});
}}
className="flex items-center gap-1.5 text-xs text-amber-600 hover:text-blue-700 hover:underline font-bold bg-blue-50/50 px-2.5 py-1.5 rounded-md border border-blue-100/50 transition-colors"
>
<Eye className="w-3.5 h-3.5" />
Preview
</button>
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</CardContent>
</Card>
</TabsContent>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
)}
{/* Documents Tab */}
<TabsContent value="documents">
<Card>
<CardHeader>
<CardTitle>Documents</CardTitle>
<CardDescription>View and manage resignation documents</CardDescription>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Documents</CardTitle>
<CardDescription>View and manage resignation documents</CardDescription>
</div>
<Button size="sm" onClick={() => setShowUploadDialog(true)} className="bg-amber-600 hover:bg-amber-700">
<Upload className="w-4 h-4 mr-2" />
Upload Document
</Button>
</CardHeader>
<CardContent>
<Table>
@ -789,25 +916,25 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
auditLogs.map((log: any, index: number) => (
<div
key={index}
className="flex gap-4 pb-4 border-b border-slate-200 last:border-0"
className="flex gap-3 pb-4 border-b border-slate-100 last:border-0"
>
<div className="w-2 h-2 rounded-full bg-amber-600 mt-2" />
<div className="w-2 h-2 rounded-full bg-slate-400 mt-2" />
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<p className="font-semibold text-slate-900 flex items-center gap-2">
{log.description || log.action}
</p>
<span className="text-sm text-slate-600 font-mono">
<span className="text-xs text-slate-500">
{formatDateTime(log.timestamp || log.createdAt)}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-600 mb-2">
<Badge variant="outline" className="text-[10px] uppercase">{log.userName || 'System'}</Badge>
<Badge variant="outline" className="text-[10px] uppercase">{log.actor?.name || log.userName || 'System'}</Badge>
</div>
{(log.remarks || log.newData?.remarks) && (
<div className="mt-2 p-3 bg-blue-50 border-l-4 border-blue-400 rounded-r text-sm italic text-blue-800">
" {log.remarks || log.newData?.remarks} "
{(log.remarks || log.newData?.remarks || log.details?.remarks) && (
<div className="mt-2 p-3 bg-slate-50 border border-slate-200 rounded text-sm text-slate-700">
{log.remarks || log.newData?.remarks || log.details?.remarks}
</div>
)}
</div>
@ -847,20 +974,69 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<div className="space-y-4">
{actionDialog.type === 'assign' ? (
<div className="space-y-2">
<Label>Select User</Label>
<Select value={assignToUser} onValueChange={setAssignToUser}>
<SelectTrigger>
<SelectValue placeholder="Choose a user" />
</SelectTrigger>
<SelectContent>
<SelectItem value="asm">ASM - Area Sales Manager</SelectItem>
<SelectItem value="rbm">RBM - Regional Business Manager</SelectItem>
<SelectItem value="zbh">ZBH - Zonal Business Head</SelectItem>
<SelectItem value="nbh">NBH - National Business Head</SelectItem>
<SelectItem value="legal">Legal Team</SelectItem>
</SelectContent>
</Select>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Designation Filter</Label>
<Select value={assignToUser} onValueChange={(val) => {
setAssignToUser(val);
setSelectedSpecificUser('');
}}>
<SelectTrigger>
<SelectValue placeholder="All Roles" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Roles</SelectItem>
<SelectItem value="asm">ASM</SelectItem>
<SelectItem value="rbm">RBM</SelectItem>
<SelectItem value="zbh">ZBH</SelectItem>
<SelectItem value="nbh">NBH</SelectItem>
<SelectItem value="legal">Legal</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Search Name/Email</Label>
<div className="relative">
<Input
placeholder="Search..."
value={userSearchQuery}
onChange={(e) => setUserSearchQuery(e.target.value)}
className="pr-8"
/>
{isLoadingUsers && <Loader2 className="w-4 h-4 animate-spin absolute right-2 top-2.5 text-slate-400" />}
</div>
</div>
</div>
<div className="space-y-2">
<Label>Select Specific Person *</Label>
<Select value={selectedSpecificUser} onValueChange={setSelectedSpecificUser}>
<SelectTrigger>
<SelectValue placeholder={availableUsers.length > 0 ? "Choose a user" : "No users found"} />
</SelectTrigger>
<SelectContent className="max-h-60">
{availableUsers.map(user => (
<SelectItem key={user.id} value={user.id}>
<div className="flex flex-col text-left">
<span className="font-medium">{user.fullName}</span>
<span className="text-[10px] text-slate-500">{user.roleCode} {user.email}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Assignment Remarks *</Label>
<Textarea
value={remarks}
onChange={(e) => setRemarks(e.target.value)}
placeholder="Why are you assigning this user?"
rows={2}
/>
</div>
</div>
) : actionDialog.type === 'pushfnf' ? (
<div className="space-y-4">
@ -940,7 +1116,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
{/* Stage Documents Dialog */}
<Dialog open={stageDocumentsDialog.open} onOpenChange={(open) => setStageDocumentsDialog({ open, stageName: '', documents: [] })}>
<DialogContent className="max-w-3xl">
<DialogContent className={WIDE_DIALOG_CLASS}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-amber-600" />
@ -973,7 +1149,22 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<TableCell>{doc.uploadDate}</TableCell>
<TableCell>{doc.uploader}</TableCell>
<TableCell>
<Button size="sm" variant="outline" className="text-amber-600 hover:text-blue-700">
<Button
size="sm"
variant="outline"
className="text-amber-600 hover:text-blue-700"
onClick={() => {
if (!doc.filePath) return;
const fullPath = doc.filePath.startsWith('/uploads/') && !doc.filePath.startsWith('/uploads/documents/')
? doc.filePath.replace('/uploads/', '/uploads/documents/')
: doc.filePath;
setPreviewDocument({
fileName: doc.name,
filePath: fullPath,
documentType: doc.type
});
}}
>
<FileText className="w-4 h-4 mr-1" />
View
</Button>
@ -997,6 +1188,58 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
</DialogContent>
</Dialog>
<Dialog open={showUploadDialog} onOpenChange={setShowUploadDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Resignation Document</DialogTitle>
<DialogDescription>Add a document and map it to a stage (optional).</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Document Type</Label>
<Select value={uploadDocType} onValueChange={setUploadDocType}>
<SelectTrigger>
<SelectValue placeholder="Select document type" />
</SelectTrigger>
<SelectContent>
{RESIGNATION_DOCUMENT_TYPES.map((docType) => (
<SelectItem key={docType} value={docType}>
{docType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Stage (Optional)</Label>
<Select value={uploadStage || 'none'} onValueChange={(value) => setUploadStage(value === 'none' ? '' : value)}>
<SelectTrigger>
<SelectValue placeholder="Select stage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No Stage Mapping</SelectItem>
{RESIGNATION_STAGE_OPTIONS.map((stage) => (
<SelectItem key={stage} value={stage}>
{stage}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>File</Label>
<Input type="file" onChange={(e) => setUploadFile(e.target.files?.[0] || null)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowUploadDialog(false)} disabled={isSubmitting}>Cancel</Button>
<Button onClick={handleUploadDocument} disabled={isSubmitting}>
{isSubmitting ? 'Uploading...' : 'Upload'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DocumentPreviewModal

View File

@ -1,4 +1,4 @@
import { ArrowLeft, Check, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, AlertTriangle, Send, ShieldCheck, Loader2 } from 'lucide-react';
import { ArrowLeft, Check, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, AlertTriangle, Send, ShieldCheck, Loader2, Upload } from 'lucide-react';
import { Button } from '../ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
@ -16,6 +16,9 @@ import { terminationService } from '../../services/termination.service';
import { useNavigate } from 'react-router-dom';
import { API } from '../../api/API';
import { formatDateTime } from '../ui/utils';
import { TERMINATION_DOCUMENT_TYPES, TERMINATION_STAGE_OPTIONS } from '../../lib/offboardingDocumentOptions';
import { DocumentPreviewModal } from '../ui/DocumentPreviewModal';
import { WIDE_DIALOG_CLASS } from '../../lib/dialogStyles';
interface TerminationDetailsProps {
terminationId: string;
onBack: () => void;
@ -38,6 +41,11 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
const [showFinalizeDialog, setShowFinalizeDialog] = useState(false);
const [finalDecision, setFinalDecision] = useState<'Approve' | 'Reject' | 'Reconsider'>('Approve');
const [finalRemarks, setFinalRemarks] = useState('');
const [showUploadDialog, setShowUploadDialog] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadDocType, setUploadDocType] = useState(TERMINATION_DOCUMENT_TYPES[0]);
const [uploadStage, setUploadStage] = useState('');
const [previewDocument, setPreviewDocument] = useState<any>(null);
const fetchTermination = async () => {
try {
@ -119,6 +127,31 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
}
};
const handleUploadDocument = async () => {
if (!uploadFile) {
toast.error('Please select a file to upload');
return;
}
try {
setIsProcessing(true);
const formData = new FormData();
formData.append('file', uploadFile);
formData.append('documentType', uploadDocType);
if (uploadStage) formData.append('stage', uploadStage);
await terminationService.uploadDocument(terminationId, formData);
toast.success('Document uploaded successfully');
setShowUploadDialog(false);
setUploadFile(null);
setUploadDocType(TERMINATION_DOCUMENT_TYPES[0]);
setUploadStage('');
fetchTermination();
} catch (error) {
toast.error('Failed to upload document');
} finally {
setIsProcessing(false);
}
};
// Check if user can push to F&F (DD Lead and above)
const canPushToFnF = currentUser && ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin'].includes(currentUser.role);
@ -164,46 +197,49 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
// Use actual data from backend
const request = terminationData || {};
// Define internal names for mapping if needed, but backend strings are preferred
const stageAliases: Record<string, string[]> = {
'Submitted': ['Submitted', 'Request Initiated'],
'RBM Review': ['RBM Review'],
'ZBH Review': ['ZBH Review'],
'DD Lead Review': ['DD Lead Review'],
'Legal Verification': ['Legal Verification'],
'NBH Evaluation': ['NBH Evaluation'],
'Show Cause Notice (SCN)': ['Show Cause Notice', 'Show Cause Notice (SCN)', 'SCN'],
'Personal Hearing': ['Personal Hearing'],
'NBH Final Approval': ['NBH Final Approval'],
'CCO Approval': ['CCO Approval'],
'CEO Final Approval': ['CEO Final Approval'],
'Legal - Termination Letter': ['Legal - Termination Letter'],
'Dealer Terminated': ['Terminated', 'Dealer Terminated']
};
const allUploadedDocs = [
...(request.documents || []),
...(request.uploadedDocuments || [])
];
// Mock documents by stage
const stageDocuments: Record<string, any[]> = {
'Request Initiated': [
{ id: 1, name: 'Termination Request Form.pdf', type: 'Request', uploadDate: '2025-10-15', uploader: 'ASM - Mumbai' },
{ id: 2, name: 'Violation Evidence Report.pdf', type: 'Evidence', uploadDate: '2025-10-15', uploader: 'ASM - Mumbai' },
{ id: 3, name: 'Dealer Performance History.xlsx', type: 'Report', uploadDate: '2025-10-15', uploader: 'ASM - Mumbai' }
],
'RBM Review': [
{ id: 4, name: 'RBM Investigation Report.pdf', type: 'Investigation', uploadDate: '2025-10-16', uploader: 'RBM - West Zone' },
{ id: 5, name: 'Field Visit Photos.pdf', type: 'Evidence', uploadDate: '2025-10-16', uploader: 'RBM - West Zone' }
],
'ZBH Review': [
{ id: 6, name: 'ZBH Assessment.pdf', type: 'Assessment', uploadDate: '2025-10-17', uploader: 'ZBH - West Zone' }
],
'DD Lead Review': [
{ id: 7, name: 'DD Lead Recommendation.pdf', type: 'Recommendation', uploadDate: '2025-10-18', uploader: 'DD Lead' },
{ id: 8, name: 'Competitor Analysis.pdf', type: 'Analysis', uploadDate: '2025-10-18', uploader: 'DD Lead' }
],
'Legal Verification': [
{ id: 9, name: 'Legal Opinion.pdf', type: 'Legal', uploadDate: '2025-10-19', uploader: 'Legal Team' },
{ id: 10, name: 'Contract Review.pdf', type: 'Legal', uploadDate: '2025-10-19', uploader: 'Legal Team' },
{ id: 11, name: 'Compliance Checklist.pdf', type: 'Compliance', uploadDate: '2025-10-19', uploader: 'Legal Team' }
],
'NBH Evaluation': [],
'Show Cause Notice (SCN)': [
{ id: 12, name: 'Show Cause Notice.pdf', type: 'Notice', uploadDate: '2025-10-20', uploader: 'Legal Admin' }
],
'DD Lead & Legal Review': [],
'NBH Termination Approval': [],
'CCO Approval': [],
'CEO Final Approval': [],
'Legal - Termination Letter': [
{ id: 13, name: 'Termination Letter - Draft.pdf', type: 'Letter', uploadDate: '2025-10-21', uploader: 'Legal Team' },
{ id: 14, name: 'Termination Letter - Final.pdf', type: 'Letter', uploadDate: '2025-10-21', uploader: 'Legal Admin' }
],
'DD Admin - Share with Dealer': [],
'Dealer Terminated': []
const stageDocuments: Record<string, any[]> = Object.keys(stageAliases).reduce((acc: Record<string, any[]>, stageName) => {
const aliases = stageAliases[stageName] || [stageName];
const docs = allUploadedDocs
.filter((doc: any) => !doc.stage || aliases.includes(doc.stage))
.map((doc: any) => ({
id: doc.id || `${stageName}-${doc.fileName || doc.name}`,
name: doc.fileName || doc.name || 'Document',
type: doc.documentType || doc.type || 'Document',
uploadDate: doc.uploadDate || doc.createdAt ? formatDateTime(doc.uploadDate || doc.createdAt) : 'N/A',
uploader: doc.uploader?.fullName || doc.uploader || '-',
path: doc.filePath || doc.path || doc.url
}));
acc[stageName] = docs;
return acc;
}, {});
const getLatestStageTimelineEntry = (stageName: string) => {
const aliases = stageAliases[stageName] || [stageName];
const entries = (request.timeline || []).filter((entry: any) =>
aliases.includes(entry.stage) || aliases.includes(entry.targetStage)
);
return entries.length > 0 ? entries[entries.length - 1] : null;
};
const progressStages = [
@ -362,10 +398,10 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
return (
<div className="space-y-6">
{/* Warning Alert */}
<Alert className="border-red-200 bg-red-50">
<AlertTriangle className="h-4 w-4 text-red-600" />
<AlertTitle className="text-red-900">Sensitive Information</AlertTitle>
<AlertDescription className="text-red-700">
<Alert className="border-amber-200 bg-amber-50">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertTitle className="text-amber-900">Sensitive Information</AlertTitle>
<AlertDescription className="text-amber-700">
This is a termination case. All actions are logged and audited. Proceed with caution.
</AlertDescription>
</Alert>
@ -396,7 +432,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
</div>
{/* Action Bar - Professional Layout */}
<Card className="border-red-200 shadow-sm">
<Card className="border-amber-200 shadow-sm">
<CardContent className="pt-6">
<div className="flex flex-col gap-4">
{/* Primary Actions Row */}
@ -495,7 +531,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
</div>
{/* Work Notes Button - Independent Section */}
<div className="flex items-center justify-between pt-4 border-t border-red-200">
<div className="flex items-center justify-between pt-4 border-t border-amber-200">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-slate-500" />
<span className="text-sm text-slate-600">Communication & Notes</span>
@ -503,7 +539,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<Button
size="sm"
variant="outline"
className="relative hover:bg-red-50 hover:border-red-300 hover:text-red-700 transition-all shadow-sm"
className="relative hover:bg-amber-50 hover:border-amber-300 hover:text-amber-700 transition-all shadow-sm"
onClick={() => navigate(`/worknotes/termination/${terminationId}`, {
state: {
applicationName: request?.dealer?.businessName || 'Termination',
@ -515,7 +551,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<MessageSquare className="w-4 h-4 mr-2" />
View Work Notes
{workNotesCount > 0 && (
<Badge className="ml-2 bg-red-600 hover:bg-red-700 text-white h-5 px-2">
<Badge className="ml-2 bg-amber-600 hover:bg-amber-700 text-white h-5 px-2">
{workNotesCount}
</Badge>
)}
@ -648,9 +684,9 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
</CardContent>
</Card>
<Card className="border-red-200 bg-red-50/30">
<Card className="border-amber-200 bg-amber-50/30">
<CardHeader>
<CardTitle className="text-red-900 flex items-center gap-2">
<CardTitle className="text-amber-900 flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Termination Details
</CardTitle>
@ -659,7 +695,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<div className="space-y-4">
<div>
<Label className="text-slate-600">Termination Category</Label>
<p className="text-red-900">{request.category}</p>
<p className="text-amber-900">{request.category}</p>
</div>
<div>
<Label className="text-slate-600">Sub Category</Label>
@ -703,11 +739,12 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<div className="space-y-4">
{progressStages.map((stage, index) => {
const documentCount = stageDocuments[stage.name]?.length || 0;
const timelineEntry = getLatestStageTimelineEntry(stage.name);
return (
<div key={stage.id} className="flex gap-4">
<div className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${stage.status === 'completed' ? 'bg-green-100 text-green-600' :
stage.status === 'active' ? 'bg-red-100 text-red-600' :
stage.status === 'active' ? 'bg-amber-100 text-amber-600' :
'bg-slate-100 text-slate-400'
}`}>
{stage.status === 'completed' ? (
@ -729,58 +766,40 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<div className="flex items-center gap-2">
<h3 className={
stage.status === 'completed' ? 'text-green-600' :
stage.status === 'active' ? 'text-red-600' :
stage.status === 'active' ? 'text-amber-600' :
'text-slate-400'
}>{stage.name}</h3>
{documentCount > 0 && (
<button
onClick={() => handleViewStageDocuments(stage.name)}
className="flex items-center gap-1 px-2 py-1 rounded-full bg-red-100 hover:bg-red-200 text-red-700 text-xs transition-colors cursor-pointer"
className="flex items-center gap-1 px-2 py-1 rounded-full bg-amber-100 hover:bg-amber-200 text-amber-700 text-xs transition-colors cursor-pointer"
>
<FileText className="w-3 h-3" />
<span>{documentCount} {documentCount === 1 ? 'doc' : 'docs'}</span>
</button>
)}
</div>
{stage.date && (
{(timelineEntry?.timestamp || stage.date) && (
<div className="flex items-center gap-1 text-sm text-slate-600">
<Calendar className="w-4 h-4" />
<span>{stage.date}</span>
<span>{formatDateTime(timelineEntry?.timestamp || stage.date)}</span>
</div>
)}
</div>
<p className="text-slate-600 text-sm">{stage.description}</p>
{/* Action Badge and Remarks */}
{stage.actionType && stage.remarks && (
{timelineEntry && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<Badge className={
stage.actionType === 'approved' ? 'bg-green-100 text-green-700 border-green-300' :
stage.actionType === 'sendback' ? 'bg-orange-100 text-orange-700 border-orange-300' :
stage.actionType === 'withdrawal' ? 'bg-red-100 text-red-700 border-red-300' :
'bg-blue-100 text-blue-700 border-blue-300'
}>
{stage.actionType === 'approved' && '✓ Approved'}
{stage.actionType === 'sendback' && '↩ Sent Back'}
{stage.actionType === 'withdrawal' && '✗ Withdrawn'}
</Badge>
{stage.actionBy && (
<span className="text-xs text-slate-500">by {stage.actionBy}</span>
)}
<Badge className="bg-blue-100 text-blue-700 border-blue-300">{timelineEntry.action || 'Updated'}</Badge>
<span className="text-xs text-slate-500">by {timelineEntry.user || 'System'}</span>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3">
<div className="space-y-2">
<div>
<Label className="text-xs text-slate-600">Remarks:</Label>
<p className="text-sm text-slate-700 mt-1">{stage.remarks}</p>
<p className="text-sm text-slate-700 mt-1">{timelineEntry.remarks || 'No remarks provided.'}</p>
</div>
{stage.feedback && (
<div>
<Label className="text-xs text-slate-600">Feedback:</Label>
<p className="text-sm text-slate-700 mt-1">{stage.feedback}</p>
</div>
)}
</div>
</div>
</div>
@ -797,9 +816,15 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
{/* Documents Tab */}
<TabsContent value="documents">
<Card>
<CardHeader>
<CardTitle>Documents</CardTitle>
<CardDescription>View and manage termination case documents</CardDescription>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Documents</CardTitle>
<CardDescription>View and manage termination case documents</CardDescription>
</div>
<Button size="sm" onClick={() => setShowUploadDialog(true)} className="bg-amber-600 hover:bg-amber-700">
<Upload className="w-4 h-4 mr-2" />
Upload Document
</Button>
</CardHeader>
<CardContent>
<Table>
@ -839,7 +864,24 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<TableCell>{formatDateTime(doc.uploadDate || doc.createdAt)}</TableCell>
<TableCell>{doc.uploader?.fullName || doc.uploader || '-'}</TableCell>
<TableCell>
<Button size="sm" variant="outline">View</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const path = doc.filePath || doc.path || doc.url;
if (!path) return;
const fullPath = path.startsWith('/uploads/') && !path.startsWith('/uploads/documents/')
? path.replace('/uploads/', '/uploads/documents/')
: path;
setPreviewDocument({
fileName: doc.name || doc.fileName || 'Document',
filePath: fullPath,
documentType: doc.documentType || doc.type || 'Document'
});
}}
>
View
</Button>
</TableCell>
</TableRow>
));
@ -863,25 +905,25 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
auditLogs.map((log: any, index: number) => (
<div
key={index}
className="flex gap-4 pb-4 border-b border-slate-200 last:border-0"
className="flex gap-3 pb-4 border-b border-slate-100 last:border-0"
>
<div className="w-2 h-2 rounded-full bg-blue-600 mt-2" />
<div className="w-2 h-2 rounded-full bg-slate-400 mt-2" />
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<p className="font-semibold text-slate-900 flex items-center gap-2">
{log.description || log.action}
</p>
<span className="text-sm text-slate-600 font-mono">
<span className="text-xs text-slate-500">
{formatDateTime(log.timestamp || log.createdAt)}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-600 mb-2">
<Badge variant="outline" className="text-[10px] uppercase">{log.userName || 'System'}</Badge>
<Badge variant="outline" className="text-[10px] uppercase">{log.actor?.name || log.userName || 'System'}</Badge>
</div>
{(log.remarks || log.newData?.remarks) && (
<div className="mt-2 p-3 bg-blue-50 border-l-4 border-blue-400 rounded-r text-sm italic text-blue-800">
" {log.remarks || log.newData?.remarks} "
{(log.remarks || log.newData?.remarks || log.details?.remarks) && (
<div className="mt-2 p-3 bg-slate-50 border border-slate-200 rounded text-sm text-slate-700">
{log.remarks || log.newData?.remarks || log.details?.remarks}
</div>
)}
</div>
@ -981,10 +1023,10 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
{/* Stage Documents Dialog */}
<Dialog open={stageDocumentsDialog.open} onOpenChange={(open) => setStageDocumentsDialog({ open, stageName: '', documents: [] })}>
<DialogContent className="max-w-3xl">
<DialogContent className={WIDE_DIALOG_CLASS}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-red-600" />
<FileText className="w-5 h-5 text-amber-600" />
Documents - {stageDocumentsDialog.stageName}
</DialogTitle>
<DialogDescription>
@ -1014,7 +1056,23 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<TableCell>{doc.uploadDate}</TableCell>
<TableCell>{doc.uploader}</TableCell>
<TableCell>
<Button size="sm" variant="outline" className="text-red-600 hover:text-red-700">
<Button
size="sm"
variant="outline"
className="text-amber-600 hover:text-amber-700"
onClick={() => {
const path = doc.path;
if (!path) return;
const fullPath = path.startsWith('/uploads/') && !path.startsWith('/uploads/documents/')
? path.replace('/uploads/', '/uploads/documents/')
: path;
setPreviewDocument({
fileName: doc.name || 'Document',
filePath: fullPath,
documentType: doc.type || 'Document'
});
}}
>
<FileText className="w-4 h-4 mr-1" />
View
</Button>
@ -1114,7 +1172,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<SelectValue placeholder="Select decision" />
</SelectTrigger>
<SelectContent className="bg-white border-slate-200 shadow-xl overflow-visible z-[9999]">
<SelectItem value="Approve" className="text-red-600 focus:bg-red-50">Confirm Termination</SelectItem>
<SelectItem value="Approve" className="text-amber-700 focus:bg-amber-50">Confirm Termination</SelectItem>
<SelectItem value="Reject" className="text-slate-600 focus:bg-slate-50">Reject Termination</SelectItem>
<SelectItem value="Reconsider" className="text-amber-600 focus:bg-amber-50">Reconsider / Give More Time</SelectItem>
</SelectContent>
@ -1144,6 +1202,64 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
</div>
</DialogContent>
</Dialog>
<Dialog open={showUploadDialog} onOpenChange={setShowUploadDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Termination Document</DialogTitle>
<DialogDescription>Add a document and map it to a stage (optional).</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label>Document Type</Label>
<Select value={uploadDocType} onValueChange={setUploadDocType}>
<SelectTrigger>
<SelectValue placeholder="Select document type" />
</SelectTrigger>
<SelectContent>
{TERMINATION_DOCUMENT_TYPES.map((docType) => (
<SelectItem key={docType} value={docType}>
{docType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Stage (Optional)</Label>
<Select value={uploadStage || 'none'} onValueChange={(value) => setUploadStage(value === 'none' ? '' : value)}>
<SelectTrigger>
<SelectValue placeholder="Select stage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No Stage Mapping</SelectItem>
{TERMINATION_STAGE_OPTIONS.map((stage) => (
<SelectItem key={stage} value={stage}>
{stage}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>File</Label>
<input type="file" onChange={(e) => setUploadFile(e.target.files?.[0] || null)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowUploadDialog(false)} disabled={isProcessing}>Cancel</Button>
<Button onClick={handleUploadDocument} disabled={isProcessing}>
{isProcessing ? 'Uploading...' : 'Upload'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DocumentPreviewModal
isOpen={!!previewDocument}
onClose={() => setPreviewDocument(null)}
document={previewDocument}
/>
</div>
);
}

View File

@ -45,13 +45,68 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
const fetchData = async () => {
try {
setLoading(true);
const [payments, settlements, apps] = await Promise.all([
settlementService.getOnboardingPayments(),
const [settlements, apps] = await Promise.all([
settlementService.getFnFSettlements(),
onboardingService.getApplications()
]);
setOnboardingPayments(payments);
// Derive Onboarding Payments from Application + SecurityDeposit (Standardized nomenclature)
// This ensures applications in "Payment Pending" / "Security Details" are visible
// even if no payment record has been manually initialized.
const consolidatedPayments: any[] = [];
apps.forEach((app: any) => {
const s = app.overallStatus || app.status;
const isPaymentStage = [
'Payment Pending', 'Security Details', 'LOI In Progress', 'LOI Issued',
'LOA Pending', 'Dealer Code Generation', 'LOA_APPROVAL'
].includes(s);
if (isPaymentStage) {
const deposits = app.securityDeposits || [];
if (deposits.length > 0) {
deposits.forEach((d: any) => {
consolidatedPayments.push({
...d,
application: app,
paymentStatus: d.status,
paymentType: d.depositType,
amount: d.amount,
id: d.id,
applicationId: app.applicationId || app.id,
createdAt: d.createdAt,
verificationDate: d.verifiedAt
});
});
} else if (['Payment Pending', 'Security Details', 'LOI In Progress'].includes(s)) {
// Virtual pending record for Security Deposit (5L)
consolidatedPayments.push({
id: `virtual-${app.id}-sd`,
applicationId: app.applicationId || app.id,
application: app,
paymentStatus: 'Pending',
paymentType: 'SECURITY_DEPOSIT',
amount: 500000,
createdAt: app.updatedAt,
isVirtual: true
});
} else if (['LOA Pending', 'Dealer Code Generation', 'LOA_APPROVAL'].includes(s)) {
// Virtual pending record for First Fill (15L)
consolidatedPayments.push({
id: `virtual-${app.id}-ff`,
applicationId: app.applicationId || app.id,
application: app,
paymentStatus: 'Pending',
paymentType: 'FIRST_FILL',
amount: 1500000,
createdAt: app.updatedAt,
isVirtual: true
});
}
}
});
setOnboardingPayments(consolidatedPayments);
setFnfSettlements(settlements);
// Filter for applications needing FDD review

View File

@ -16,6 +16,7 @@ import {
Minus,
FileText
} from 'lucide-react';
import { WIDE_DIALOG_CLASS } from '../../lib/dialogStyles';
interface DocumentPreviewModalProps {
isOpen: boolean;
@ -42,7 +43,7 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[80vw] w-[80vw] h-[85vh] flex flex-col p-0 overflow-hidden bg-white shadow-2xl border-none">
<DialogContent className={`${WIDE_DIALOG_CLASS} h-[85vh] flex flex-col p-0 overflow-hidden bg-white shadow-2xl border-none`}>
{document ? (
<>
<div className="flex items-center justify-between p-4 border-b bg-slate-50">

View File

@ -1,14 +1,11 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
theme="light"
className="toaster group"
style={
{
@ -22,4 +19,5 @@ const Toaster = ({ ...props }: ToasterProps) => {
);
};
export { Toaster };

1
src/lib/dialogStyles.ts Normal file
View File

@ -0,0 +1 @@
export const WIDE_DIALOG_CLASS = "!w-[80vw] !max-w-[80vw] sm:!max-w-[80vw]";

View File

@ -0,0 +1,46 @@
export const RESIGNATION_DOCUMENT_TYPES = [
"Resignation Letter",
"Dealer Undertaking",
"Approval Note",
"Legal Communication",
"Handover Document",
"Settlement Supporting Document",
"Other",
] as const;
export const RESIGNATION_STAGE_OPTIONS = [
"ASM",
"RBM",
"ZBH",
"DD Lead",
"NBH",
"DD Admin",
"Legal",
"F&F Initiated",
] as const;
export const TERMINATION_DOCUMENT_TYPES = [
"Termination Recommendation",
"Show Cause Notice",
"SCN Response",
"Hearing Record",
"Approval Note",
"Termination Letter",
"Settlement Supporting Document",
"Other",
] as const;
export const TERMINATION_STAGE_OPTIONS = [
"Submitted",
"RBM Review",
"ZBH Review",
"DD Lead Review",
"Legal Verification",
"NBH Evaluation",
"Show Cause Notice",
"Personal Hearing",
"NBH Final Approval",
"CCO Approval",
"CEO Final Approval",
"Legal - Termination Letter",
] as const;

View File

@ -36,5 +36,14 @@ export const resignationService = {
console.error('Update clearance error:', error);
throw error;
}
},
uploadDocument: async (id: string, formData: FormData) => {
try {
const response: any = await API.uploadResignationDocument(id, formData);
return response.data;
} catch (error) {
console.error('Upload resignation document error:', error);
throw error;
}
}
};

View File

@ -6,8 +6,8 @@ export const terminationService = {
return response.data?.termination || response.data?.data || response.data;
},
updateTerminationStatus: async (id: string, status: string, remarks: string) => {
const response = await API.updateTerminationStatus(id, { status, remarks });
updateTerminationStatus: async (id: string, action: string, remarks: string) => {
const response = await API.updateTerminationStatus(id, { action, remarks });
return response.data;
},
@ -23,6 +23,10 @@ export const terminationService = {
const response = await API.uploadSCNResponse(id, formData);
return response.data;
},
uploadDocument: async (id: string, formData: FormData) => {
const response = await API.uploadTerminationDocument(id, formData);
return response.data;
},
finalizeTermination: async (id: string, decision: 'Approve' | 'Reject' | 'Reconsider', remarks: string) => {
const response = await API.finalizeTermination(id, { decision, remarks });