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('');
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 (

View File

@ -5,6 +5,7 @@ import { Application, ApplicationStatus } from '../../lib/mock-data';
import { onboardingService } from '../../services/onboarding.service';
import { auditService } from '../../services/audit.service';
import { eorService } from '../../services/eor.service';
import { collaborationService } from '../../services/collaboration.service';
import QuestionnaireResponseView from './QuestionnaireResponseView';
import { useSelector } from 'react-redux';
import { RootState } from '../../store';
@ -411,6 +412,11 @@ export const ApplicationDetails = () => {
// Audit Trail State
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [auditLoading, setAuditLoading] = useState(false);
const [worknotes, setWorknotes] = useState<any[]>([]);
const isNonResponsive = (worknotes || []).some(note =>
(note.noteText || '').includes('FLAGGED:')
) || application?.statutoryStatus === 'Flagged';
const [showFirmTypeModal, setShowFirmTypeModal] = useState(false);
const [updatingFirmType, setUpdatingFirmType] = useState(false);
@ -446,6 +452,16 @@ export const ApplicationDetails = () => {
}
};
fetchAuditLogs();
const fetchWorknotes = async () => {
try {
const res = await collaborationService.getWorknotes('application', application.id);
setWorknotes(res.data || []);
} catch (error) {
console.error('Failed to fetch worknotes', error);
}
};
fetchWorknotes();
}
}, [application?.id]);
@ -527,8 +543,11 @@ export const ApplicationDetails = () => {
}
};
const canEditStatutory = currentUser?.roleCode === 'Super Admin' || currentUser?.roleCode === 'DD Admin';
const isAdmin = currentUser?.roleCode === 'Super Admin' || currentUser?.roleCode === 'DD Admin';
const canEditStatutory = currentUser?.roleCode === 'Super Admin' || currentUser?.roleCode === 'DD Admin' || currentUser?.role === 'Super Admin' || currentUser?.role === 'DD Admin';
const isAdmin = currentUser?.roleCode === 'Super Admin' || currentUser?.roleCode === 'DD Admin' ||
currentUser?.role === 'Super Admin' || currentUser?.role === 'DD Admin' ||
currentUser?.role === 'NBH' || currentUser?.role === 'DD Head' ||
currentUser?.roleCode === 'NBH' || currentUser?.roleCode === 'DD_HEAD';
const [interviews, setInterviews] = useState<any[]>([]);
const [isScheduling, setIsScheduling] = useState(false);
@ -568,7 +587,16 @@ export const ApplicationDetails = () => {
applicationId: application?.id || applicationId,
assignedToAgency: selectedAgencyId
});
toast.success('FDD Agency assigned successfully');
// Automatically add as participant to ensure access
await onboardingService.addParticipant({
requestId: application?.id || applicationId,
requestType: 'application',
userId: selectedAgencyId,
participantType: 'contributor'
});
toast.success('FDD Agency assigned and added as participant');
fetchApplication();
} catch (error) {
toast.error('Failed to assign agency');
@ -1197,7 +1225,10 @@ export const ApplicationDetails = () => {
{
id: 10,
name: 'LOI Issue',
status: getStageStatus('LOI Issue', () => ['LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : 'pending'),
status: getStageStatus('LOI Issue', () =>
['Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' :
application.status === 'LOI Issued' ? 'active' : 'pending'
),
date: application.loiIssueDate,
description: 'Letter of Intent issued',
documentsUploaded: 1
@ -1280,7 +1311,7 @@ export const ApplicationDetails = () => {
{
id: 15,
name: 'Dealership Active',
status: getStageStatus('Onboarded', () => application.status === 'Onboarded' ? 'completed' : 'pending'),
status: getStageStatus('Onboarded', () => application.status === 'Onboarded' ? 'completed' : ['Inauguration', 'Approved'].includes(application.status) ? 'active' : 'pending'),
description: 'Dealer profile active'
}
];
@ -1415,6 +1446,8 @@ export const ApplicationDetails = () => {
case 'FDD Verification':
newStatus = 'LOI In Progress'; break;
case 'LOI In Progress':
case 'Security Details':
case 'Payment Pending':
newStatus = 'LOI Issued'; break;
case 'LOI Issued':
newStatus = 'Dealer Code Generation'; break;
@ -1422,7 +1455,6 @@ export const ApplicationDetails = () => {
case 'Architecture Team Assigned':
case 'Architecture Document Upload':
case 'Architecture Team Completion':
newStatus = 'Statutory GST'; break;
case 'Statutory GST':
case 'Statutory PAN':
case 'Statutory Nodal':
@ -1442,9 +1474,10 @@ export const ApplicationDetails = () => {
case 'EOR Complete':
newStatus = 'Inauguration'; break;
case 'Inauguration':
newStatus = 'Approved'; break;
case 'Approved':
newStatus = 'Onboarded'; break;
default:
newStatus = 'Approved'; // Final fallback
newStatus = 'Onboarded'; // Final fallback
}
const policyManagedStages: { [key: string]: string } = {
@ -1481,13 +1514,13 @@ export const ApplicationDetails = () => {
}
// Special case: If final approval, create Dealer record
if (newStatus === 'Approved') {
if (newStatus === 'Onboarded') {
// In a real scenario, we'd have the dealerCodeId from the application's associated DealerCode record
await onboardingService.createDealer({
applicationId: applicationId,
// dealerCodeId is handled by backend if not provided, or we can fetch it
});
toast.success('Application approved and Dealer profile created!');
toast.success('Application finalized and Dealer profile created!');
} else {
toast.success(`Application moved to ${newStatus}`);
}
@ -1496,9 +1529,9 @@ export const ApplicationDetails = () => {
setApprovalRemark('');
setApprovalFile(null);
fetchApplication();
} catch (error) {
} catch (error: any) {
console.error('Approval error:', error);
toast.error('Failed to process approval');
toast.error(error.message || 'Failed to process approval');
} finally {
setIsApproving(false);
}
@ -1567,9 +1600,9 @@ export const ApplicationDetails = () => {
setShowRejectModal(false);
setRejectionReason('');
fetchApplication();
} catch (error) {
} catch (error: any) {
console.error('Rejection error:', error);
toast.error('Failed to process rejection');
toast.error(error.message || 'Failed to process rejection');
} finally {
setIsRejecting(false);
}
@ -1580,9 +1613,9 @@ export const ApplicationDetails = () => {
await onboardingService.generateDealerCodes(applicationId!);
toast.success('Dealer codes generated successfully');
fetchApplication();
} catch (error) {
} catch (error: any) {
console.error('Generate codes error:', error);
toast.error('Failed to generate dealer codes');
toast.error(error.message || 'Failed to generate dealer codes');
}
};
@ -1597,8 +1630,9 @@ export const ApplicationDetails = () => {
toast.success('Architecture team assigned successfully');
setShowAssignArchitectureModal(false);
fetchApplication(); // Refresh to update status
} catch (error) {
toast.error('Failed to assign architecture team');
} catch (error: any) {
console.error('Assign architecture error:', error);
toast.error(error.message || 'Failed to assign architecture team');
} finally {
setIsAssigningArchitecture(false);
}
@ -1625,12 +1659,23 @@ export const ApplicationDetails = () => {
}
try {
setIsAssigningParticipant(true);
// If user has role FDD, automatically assign as FDD Agency too
const u = Array.isArray(users) ? users.find(user => user.id === selectedUser) : null;
if (u && (u.role === 'FDD' || u.roleCode === 'FDD')) {
await onboardingService.assignFddAgency({
applicationId: applicationId,
assignedToAgency: selectedUser
});
toast.info(`${u.fullName || u.name} assigned as FDD Agency based on role.`);
}
await onboardingService.addParticipant({
requestId: applicationId,
requestType: 'application',
userId: selectedUser,
participantType: participantType || 'contributor'
});
toast.success('User assigned successfully!');
// Refresh application data
fetchApplication();
@ -1735,16 +1780,16 @@ export const ApplicationDetails = () => {
const isAdminRole = ['DD Admin', 'Super Admin', 'NBH', 'DD Lead', 'DD Head', 'Finance', 'Finance Admin', 'FDD', 'ZBH', 'RBM'].includes(currentUser.role);
const isAdministrativeStage = [
'Level 3 Approved', 'FDD Verification',
'LOI In Progress', 'LOI Issued', 'Statutory LOI Ack',
'LOI In Progress', 'Security Details', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack',
'Architecture Team Assigned', 'Architecture Document Upload', 'Architecture Team Completion',
'Statutory GST', 'Statutory PAN', 'Statutory Nodal', 'Statutory Check',
'Statutory Partnership', 'Statutory Firm Reg', 'Statutory Rental',
'Statutory Virtual Code', 'Statutory Domain', 'Statutory MSD',
'LOA Pending', 'EOR In Progress', 'EOR Complete', 'Inauguration'
'LOA Pending', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved'
].includes(application.status);
const isLoaLocked = application.status === 'LOA Pending' && getDeposit('FIRST_FILL')?.status !== 'Verified';
const isFinalState = application.status === 'Onboarded' || application.status === 'Rejected' || application.status === 'Approved';
const isFinalState = application.status === 'Onboarded' || application.status === 'Rejected';
// 2. Interview Specific Logic
const activeInterviewForUser = (interviews || []).find(i =>
@ -1769,13 +1814,12 @@ export const ApplicationDetails = () => {
// 4. Decision Tracking
const hasMadeStageDecision = !!application.stageApprovals?.find(a => policyManagedStages[application.status] === a.stageCode && String(a.actorUserId) === String(currentUser.id));
const hasMadeInterviewDecision = ['Approved', 'Rejected', 'Selected'].includes(currentUserEvaluation?.decision || currentUserEvaluation?.recommendation || '');
const hasMadeDecisionTotal = hasMadeStageDecision || hasMadeInterviewDecision;
// 5. Final Permission Bits
const isDecisionMade = hasMadeDecisionTotal || hasMadeStageDecision;
const isDecisionMade = (activeInterviewForUser ? hasMadeInterviewDecision : false) || hasMadeStageDecision;
const canApproveReject = !isLoaLocked && !isFinalState && !isDecisionMade && (
(!!activeInterviewForUser && !!hasSubmittedFeedback) ||
(isAdminRole && isAdministrativeStage && sequenceMet)
(isAdminRole && isAdministrativeStage && sequenceMet && (!['EOR In Progress', 'Inauguration', 'Approved'].includes(application.status) || eorProgress === 100))
);
return {
@ -1811,7 +1855,8 @@ export const ApplicationDetails = () => {
{ type: 'Income Tax Returns (ITR)', label: 'ITR (Last 3 Years)' },
{ type: 'CIBIL Report', label: 'CIBIL / Credit Reports' },
{ type: 'Property Documents', label: 'Property Documents' },
{ type: 'Business Valuation Report', label: 'Valuation Reports' }
{ type: 'Business Valuation Report', label: 'Valuation Reports' },
{ type: 'FDD Final Audit Report', label: 'Final Audit Report' }
];
const getDocByTypeName = (typeName: string) => {
@ -1887,68 +1932,17 @@ export const ApplicationDetails = () => {
return (
<div className="space-y-8">
{/* FDD/Finance Audit Workspace */}
{((currentUser?.role === 'FDD' || currentUser?.role === 'DD Admin' || currentUser?.role === 'Super Admin' || hasAssignment) &&
(['FDD Verification', 'Level 3 Approved', 'LOI In Progress'].includes(application.status))) && (
<div className="flex flex-col md:flex-row gap-4 p-6 bg-slate-900 rounded-2xl text-white shadow-xl border-b-4 border-amber-500 mb-8">
<div className="flex-1">
<h4 className="text-lg font-bold flex items-center gap-3">
<div className="p-2 bg-amber-500/20 rounded-lg">
<ShieldCheck className="w-5 h-5 text-amber-400" />
</div>
Audit Management Workspace {primaryFddUser && <span className="text-amber-400 text-sm font-normal ml-2">Assigned to: {primaryFddUser.name}</span>}
</h4>
<p className="text-slate-400 text-[11px] mt-1 font-medium leading-relaxed max-w-md">Capture financial findings, upload reports, and provide your formal recommendation to progress the application.</p>
</div>
{/* FDD Status Header */}
{hasAssignment && (
<div className="flex items-center justify-between p-4 bg-slate-50 border border-slate-200 rounded-xl mb-6">
<div className="flex items-center gap-3">
<input
type="file"
id="fdd-report-upload"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setIsUploading(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);
await onboardingService.uploadDocument(application.id, formData);
toast.success('FDD Final Audit Report uploaded successfully');
refreshDocuments();
} catch (err) {
toast.error('Upload failed');
} finally {
setIsUploading(false);
}
}}
/>
<Button
className="bg-white text-slate-900 hover:bg-slate-100 font-black text-[10px] uppercase tracking-widest px-6 h-11 border-none"
disabled={isUploading}
onClick={() => document.getElementById('fdd-report-upload')?.click()}
>
<Upload className="w-4 h-4 mr-2" />
{isUploading ? 'Uploading...' : 'Upload Report'}
</Button>
{(currentUser?.role === 'DD Admin' || currentUser?.role === 'Super Admin') && (
<>
<Button
variant="outline"
className="bg-transparent text-white border-white/20 hover:bg-red-600/20 hover:border-red-500/50 hover:text-red-400 font-black text-[10px] uppercase tracking-widest px-6 h-11"
onClick={() => setShowFddFlagModal(true)}
>
Flag Non-Responsive
</Button>
<Button
className="bg-amber-500 text-white hover:bg-amber-600 font-black text-[10px] uppercase tracking-widest px-6 h-11 border-none shadow-lg shadow-amber-500/20"
onClick={() => setShowFddFinalizeModal(true)}
>
Finalize Audit
</Button>
</>
)}
<div className="p-2 bg-amber-100 rounded-lg">
<ShieldCheck className="w-5 h-5 text-amber-600" />
</div>
<div>
<h4 className="text-sm font-bold text-slate-900">FDD Assignment Active</h4>
{primaryFddUser && <p className="text-xs text-slate-500 font-medium">Assigned to: {primaryFddUser.name}</p>}
</div>
</div>
</div>
)}
@ -2011,7 +2005,19 @@ export const ApplicationDetails = () => {
formData.append('documentType', docType.type);
formData.append('stage', 'FDD');
formData.append('applicationId', application.id);
await onboardingService.uploadDocument(application.id, formData);
const res = await onboardingService.uploadDocument(application.id, formData);
// Auto-link if it's the final report
if (docType.type === 'FDD Final Audit Report') {
await onboardingService.submitFddReport({
applicationId: application.id,
reportDocumentId: res.data?.id || res.id,
findings: 'Final Audit Report uploaded via checklist.',
recommendation: 'REVIEW_PENDING'
});
fetchApplication();
}
toast.success(`${docType.label} uploaded successfully`);
refreshDocuments();
} catch (err) {
@ -2035,130 +2041,7 @@ export const ApplicationDetails = () => {
</Card>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Financial Due Diligence Reports</h3>
<Badge variant="outline" className="bg-amber-50 text-amber-600 border-amber-200">
{assignments.length} Assignment(s)
</Badge>
</div>
{assignments.map((assignment: any) => (
<Card key={assignment.id} className="overflow-hidden border-slate-200 shadow-sm">
<CardHeader className="bg-slate-50/50 py-4 border-b border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-white flex items-center justify-center border border-slate-200 shadow-sm">
<ShieldCheck className="w-6 h-6 text-amber-600" />
</div>
<div>
<h4 className="text-slate-900 font-bold truncate max-w-[200px]">FDD Agency Audit</h4>
<p className="text-slate-500 text-[10px] uppercase tracking-wider font-bold">
Agency ID: {assignment.assignedToAgency || 'Assigned'} Status: {assignment.status}
</p>
</div>
</div>
<Badge className={cn(
assignment.status === 'completed' ? "bg-green-100 text-green-700 hover:bg-green-100" : "bg-amber-100 text-amber-700 hover:bg-amber-100"
)}>
{assignment.status}
</Badge>
</div>
</CardHeader>
<CardContent className="p-0">
{(!assignment.reports || assignment.reports.length === 0) ? (
<div className="p-12 text-center">
<Clock className="w-8 h-8 text-slate-300 mx-auto mb-3" />
<p className="text-slate-500 italic text-sm">Waiting for internal or external agency to submit the final audit report...</p>
</div>
) : (
<div className="divide-y divide-slate-100">
{assignment.reports.map((report: any) => (
<div key={report.id} className="p-6 space-y-6 bg-white">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-5">
{/* Auditor Recommendation Hidden as per request */}
<div>
<Label className="text-[10px] text-slate-400 uppercase tracking-widest font-black mb-2 block">Findings Summary</Label>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100 text-slate-700 text-sm leading-relaxed shadow-inner italic">
"{report.findings || 'No detail findings provided by the auditor.'}"
</div>
</div>
</div>
<div className="space-y-5">
<Label className="text-[10px] text-slate-400 uppercase tracking-widest font-black mb-2 block">Report Document</Label>
{report.reportDocument ? (
<div className="group bg-white border-2 border-slate-100 rounded-xl p-4 flex items-center justify-between hover:border-amber-400 transition-all hover:shadow-md cursor-pointer">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-red-50 flex items-center justify-center group-hover:scale-110 transition-all shadow-sm">
<FileText className="w-6 h-6 text-red-500" />
</div>
<div className="overflow-hidden">
<p className="text-slate-500 text-[10px] font-medium">SUBMITTED {formatDateTime(report.createdAt)}</p>
</div>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-slate-400 hover:text-amber-600 hover:bg-amber-50"
onClick={(e) => {
e.stopPropagation();
window.open(`http://localhost:5000/${report.reportDocument.filePath}`, '_blank');
}}
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-slate-400 hover:text-amber-600 hover:bg-amber-50"
onClick={(e) => {
e.stopPropagation();
setPreviewDoc(report.reportDocument);
setShowPreviewModal(true);
}}
>
<Eye className="w-4 h-4" />
</Button>
</div>
</div>
) : (
<div className="p-6 bg-slate-50 rounded-xl border border-dashed border-slate-200 text-center text-slate-400 text-xs font-medium">
No audit report file attached
</div>
)}
<div className="pt-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 bg-slate-50 border border-slate-100 px-3 py-1 rounded-full">
<User className="w-3.5 h-3.5 text-slate-500" />
<span className="text-[10px] font-bold text-slate-600 uppercase">Submitted by: {report.submitter?.fullName || 'Auditor'}</span>
</div>
</div>
{report.verifiedAt ? (
<div className="flex items-center gap-2 bg-green-50 border border-green-100 px-3 py-1.5 rounded-full w-fit">
<CheckCircle className="w-4 h-4 text-green-600" />
<span className="text-[10px] font-black text-green-700 uppercase"> Verified by {report.verifier?.fullName || 'Admin'}</span>
</div>
) : (
<div className="flex items-center gap-2 bg-amber-50 border border-amber-100 px-3 py-1.5 rounded-full w-fit">
<Clock className="w-4 h-4 text-amber-600" />
<span className="text-[10px] font-black text-amber-700 uppercase">Pending Review</span>
</div>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
))}
{/* Supporting documents and checklist are enough for simplified view */}
{/* FDD Supporting Documents Section */}
<div className="space-y-4">
@ -2247,6 +2130,33 @@ export const ApplicationDetails = () => {
return (
<div className="space-y-6">
{isNonResponsive && (
<div className="bg-red-50 border border-red-200 p-4 rounded-2xl flex items-center justify-between animate-in fade-in slide-in-from-top-4 duration-500">
<div className="flex items-center gap-4">
<div className="bg-red-100 p-2 rounded-xl">
<ShieldAlert className="w-6 h-6 text-red-600" />
</div>
<div>
<h3 className="text-sm font-black text-red-900 tracking-tight leading-none uppercase">Applicant Flagged Non-Responsive</h3>
<p className="text-red-700 text-[11px] font-bold uppercase tracking-widest mt-1 opacity-80">Audit process is currently on hold due to missing cooperation</p>
</div>
</div>
{isAdmin && (
<Button
variant="outline"
size="sm"
className="bg-white border-red-200 text-red-600 hover:bg-red-50 font-black text-[10px] uppercase tracking-widest hidden sm:block h-9"
onClick={() => {
const worknotesTab = document.querySelector('[value="worknotes"]') as HTMLElement;
worknotesTab?.click();
}}
>
Review Audit
</Button>
)}
</div>
)}
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
@ -2462,7 +2372,7 @@ export const ApplicationDetails = () => {
<Label className="text-[10px] uppercase font-bold text-slate-500">Legal Entity Name</Label>
<Input
value={statutoryForm.accountHolderName}
onChange={(e) => setStatutoryForm({...statutoryForm, accountHolderName: e.target.value})}
onChange={(e) => setStatutoryForm({ ...statutoryForm, accountHolderName: e.target.value })}
placeholder="Enter Legal Entity Name"
className="bg-white border-slate-200"
/>
@ -2471,7 +2381,7 @@ export const ApplicationDetails = () => {
<Label className="text-[10px] uppercase font-bold text-slate-500">PAN Number</Label>
<Input
value={statutoryForm.panNumber}
onChange={(e) => setStatutoryForm({...statutoryForm, panNumber: e.target.value.toUpperCase()})}
onChange={(e) => setStatutoryForm({ ...statutoryForm, panNumber: e.target.value.toUpperCase() })}
placeholder="10-digit PAN"
maxLength={10}
className="bg-white border-slate-200 uppercase"
@ -2481,7 +2391,7 @@ export const ApplicationDetails = () => {
<Label className="text-[10px] uppercase font-bold text-slate-500">GST Number</Label>
<Input
value={statutoryForm.gstNumber}
onChange={(e) => setStatutoryForm({...statutoryForm, gstNumber: e.target.value.toUpperCase()})}
onChange={(e) => setStatutoryForm({ ...statutoryForm, gstNumber: e.target.value.toUpperCase() })}
placeholder="15-digit GSTIN"
maxLength={15}
className="bg-white border-slate-200 uppercase"
@ -2491,7 +2401,7 @@ export const ApplicationDetails = () => {
<Label className="text-[10px] uppercase font-bold text-slate-500">Registered Address</Label>
<Input
value={statutoryForm.registeredAddress}
onChange={(e) => setStatutoryForm({...statutoryForm, registeredAddress: e.target.value})}
onChange={(e) => setStatutoryForm({ ...statutoryForm, registeredAddress: e.target.value })}
placeholder="Enter Registered Office Address"
className="bg-white border-slate-200"
/>
@ -2500,7 +2410,7 @@ export const ApplicationDetails = () => {
<Label className="text-[10px] uppercase font-bold text-slate-500">Bank Name</Label>
<Input
value={statutoryForm.bankName}
onChange={(e) => setStatutoryForm({...statutoryForm, bankName: e.target.value})}
onChange={(e) => setStatutoryForm({ ...statutoryForm, bankName: e.target.value })}
placeholder="Enter Bank Name"
className="bg-white border-slate-200"
/>
@ -2509,7 +2419,7 @@ export const ApplicationDetails = () => {
<Label className="text-[10px] uppercase font-bold text-slate-500">Account Number</Label>
<Input
value={statutoryForm.accountNumber}
onChange={(e) => setStatutoryForm({...statutoryForm, accountNumber: e.target.value})}
onChange={(e) => setStatutoryForm({ ...statutoryForm, accountNumber: e.target.value })}
placeholder="Enter Account Number"
className="bg-white border-slate-200"
/>
@ -2518,7 +2428,7 @@ export const ApplicationDetails = () => {
<Label className="text-[10px] uppercase font-bold text-slate-500">IFSC Code</Label>
<Input
value={statutoryForm.ifscCode}
onChange={(e) => setStatutoryForm({...statutoryForm, ifscCode: e.target.value.toUpperCase()})}
onChange={(e) => setStatutoryForm({ ...statutoryForm, ifscCode: e.target.value.toUpperCase() })}
placeholder="11-digit IFSC"
maxLength={11}
className="bg-white border-slate-200 uppercase"
@ -3287,14 +3197,18 @@ export const ApplicationDetails = () => {
className="w-full sm:w-auto bg-green-600 hover:bg-green-700 text-white font-bold h-12 px-8 rounded-xl shadow-lg shadow-green-600/20 transition-all hover:scale-[1.02] active:scale-[0.98]"
onClick={async () => {
try {
await onboardingService.updateApplicationStatus(application.id, {
status: 'EOR Complete',
remarks: 'EOR Checklist verified and audit completed.'
const checklistId = eorData?.id || eorChecklist?.id;
if (!checklistId) throw new Error('Checklist ID not found');
await eorService.submitAudit(checklistId, {
status: 'Completed',
overallComments: 'EOR Checklist verified and audit completed.'
});
toast.success('EOR Audit completed successfully!');
fetchApplication();
} catch (error) {
toast.error('Failed to complete EOR audit');
fetchEorData();
} catch (error: any) {
toast.error(error.message || 'Failed to complete EOR audit');
}
}}
>
@ -3509,6 +3423,11 @@ export const ApplicationDetails = () => {
</span>
</div>
<p className="text-slate-600 mt-1">by {log.userName || 'System'}</p>
{log.remarks && (
<p className="mt-2 text-red-600 text-sm font-bold bg-red-50 p-2 rounded border border-red-100 italic">
"{log.remarks}"
</p>
)}
{log.changes && log.changes.length > 0 && (
<div className="mt-1 space-y-0.5">
{log.changes.map((change: string, idx: number) => (
@ -3597,6 +3516,26 @@ export const ApplicationDetails = () => {
</Alert>
)}
{isNonResponsive && isAdmin && (
<Alert variant="destructive" className="mb-4 bg-red-50 border-red-200 text-red-800">
<AlertCircle className="w-4 h-4 text-red-600" />
<AlertTitle className="text-red-900 font-black uppercase tracking-tighter"> Non-Responsive Flag</AlertTitle>
<AlertDescription className="text-red-800 text-xs font-bold leading-tight">
FDD Audit has flagged this applicant. Review audit logs before approval.
</AlertDescription>
</Alert>
)}
{isAdmin && (application.status === 'Level 3 Approved' || application.status === 'FDD Verification') && (!application.fddAssignments || application.fddAssignments.length === 0) && (
<Alert className="mb-4 bg-amber-50 border-amber-200 text-amber-800">
<AlertCircle className="w-4 h-4 text-amber-600" />
<AlertTitle className="text-amber-900 font-bold">FDD Assignment Required</AlertTitle>
<AlertDescription className="text-amber-800 font-medium">
This application is pending financial due diligence. Please assign an FDD Agency to proceed with the audit.
</AlertDescription>
</Alert>
)}
{permissions.canApprove && (
<>
<Button
@ -3604,7 +3543,7 @@ export const ApplicationDetails = () => {
onClick={() => setShowApproveModal(true)}
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve
{['Inauguration', 'Approved'].includes(application.status) ? 'Onboard Dealer' : 'Approve'}
</Button>
<Button
@ -3652,7 +3591,8 @@ export const ApplicationDetails = () => {
</Button>
)}
{currentUser && ['DD Admin', 'Super Admin'].includes(currentUser.role) && application.status === 'Dealer Code Generation' && (
{currentUser && ['DD Admin', 'Super Admin'].includes(currentUser.role) &&
['Dealer Code Generation', 'LOA Pending', 'Architecture Team Assigned', 'Architecture Document Upload', 'Architecture Team Completion'].includes(application.status) && (
<>
{!application.dealerCode && (
<Button
@ -3664,7 +3604,7 @@ export const ApplicationDetails = () => {
</Button>
)}
{application.dealerCode && (
{application.dealerCode && !application.architectureAssignedTo && (
<Button
variant="outline"
className="w-full border-blue-200 hover:bg-blue-50 text-blue-700"
@ -3677,16 +3617,7 @@ export const ApplicationDetails = () => {
</>
)}
{((currentUser && currentUser.id === application.architectureAssignedTo) || (currentUser && ['DD Admin', 'Super Admin'].includes(currentUser.role))) &&
application.architectureStatus === 'IN_PROGRESS' && (
<Button
className="w-full bg-blue-600 hover:bg-blue-700"
onClick={() => setShowArchitectureStatusModal(true)}
>
<CheckCircle className="w-4 h-4 mr-2" />
Complete Architecture Work
</Button>
)}
{/* Show Interview Feedback only if active interview exists AND feedback NOT submitted */}
{activeInterviewForUser && !hasSubmittedFeedback && (
@ -3728,27 +3659,6 @@ export const ApplicationDetails = () => {
</>
)}
{/* Dedicated Onboarding Button - Appears ONLY when everything is ready (last step) */}
{isAdmin && application.status === 'Inauguration' && !application.dealer && (
<div className="space-y-2">
{eorProgress < 100 && (
<Alert variant="destructive" className="bg-amber-50 border-amber-200 text-amber-800 py-2">
<AlertCircle className="h-4 w-4 text-amber-600" />
<AlertDescription className="text-xs">
EOR Checklist must be 100% complete before onboarding. (Current: {eorProgress.toFixed(0)}%)
</AlertDescription>
</Alert>
)}
<Button
className="w-full bg-green-600 hover:bg-green-700 font-bold shadow-lg shadow-green-100 disabled:bg-slate-300 disabled:text-slate-500"
onClick={() => setShowOnboardModal(true)}
disabled={eorProgress < 100}
>
<CheckCircle className="w-4 h-4 mr-2" />
Onboard as Dealer (Final Step)
</Button>
</div>
)}
{/* Dealer Onboarded Status & Link */}
{application.dealer && (
@ -5200,7 +5110,7 @@ export const ApplicationDetails = () => {
</Dialog>
</div>
</div>
</div>
</div>
);
};

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 = () => {
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
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>
))}
</select>
</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,8 +1347,9 @@ 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>
{canRespondToDepartment(dept.departmentName) ? (
<Button
variant="ghost"
size="sm"
@ -1350,16 +1357,19 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
onClick={() => {
setSelectedDept(dept);
setClearanceForm({
status: dept.status,
remarks: dept.remarks === '-' ? '' : dept.remarks,
remarks: dept.remarks === "-" ? "" : dept.remarks,
amount: dept.amount || 0,
type: dept.amountType || 'Recovery'
type: dept.amountType || "Recovery",
});
setClearanceFile(null);
setShowClearanceDialog(true);
}}
>
Update
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">
<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,6 +710,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
</TabsContent>
{/* Clearances Tab */}
{currentUser?.role !== 'Dealer' && (
<TabsContent value="clearances">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
@ -575,39 +720,43 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<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
else deptRecoveries += amt;
});
const netAmount = deptPayables - deptRecoveries;
// 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 displayAmount = Math.abs(netAmount) || jsonClearance.amount || 0;
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>
<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' :
@ -615,69 +764,47 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
}>
{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]">
{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"
</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'}
>
<Eye className="w-3.5 h-3.5" />
Preview
</button>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{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.'}
</TableCell>
</TableRow>
);
})}
</div>
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
)}
{/* Documents Tab */}
<TabsContent value="documents">
<Card>
<CardHeader>
<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,21 +974,70 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<div className="space-y-4">
{actionDialog.type === 'assign' ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Select User</Label>
<Select value={assignToUser} onValueChange={setAssignToUser}>
<Label>Designation Filter</Label>
<Select value={assignToUser} onValueChange={(val) => {
setAssignToUser(val);
setSelectedSpecificUser('');
}}>
<SelectTrigger>
<SelectValue placeholder="Choose a user" />
<SelectValue placeholder="All Roles" />
</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>
<SelectItem value="all">All Roles</SelectItem>
<SelectItem value="asm">ASM</SelectItem>
<SelectItem value="rbm">RBM</SelectItem>
<SelectItem value="zbh">ZBH</SelectItem>
<SelectItem value="nbh">NBH</SelectItem>
<SelectItem value="legal">Legal</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Search Name/Email</Label>
<div className="relative">
<Input
placeholder="Search..."
value={userSearchQuery}
onChange={(e) => setUserSearchQuery(e.target.value)}
className="pr-8"
/>
{isLoadingUsers && <Loader2 className="w-4 h-4 animate-spin absolute right-2 top-2.5 text-slate-400" />}
</div>
</div>
</div>
<div className="space-y-2">
<Label>Select Specific Person *</Label>
<Select value={selectedSpecificUser} onValueChange={setSelectedSpecificUser}>
<SelectTrigger>
<SelectValue placeholder={availableUsers.length > 0 ? "Choose a user" : "No users found"} />
</SelectTrigger>
<SelectContent className="max-h-60">
{availableUsers.map(user => (
<SelectItem key={user.id} value={user.id}>
<div className="flex flex-col text-left">
<span className="font-medium">{user.fullName}</span>
<span className="text-[10px] text-slate-500">{user.roleCode} {user.email}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Assignment Remarks *</Label>
<Textarea
value={remarks}
onChange={(e) => setRemarks(e.target.value)}
placeholder="Why are you assigning this user?"
rows={2}
/>
</div>
</div>
) : actionDialog.type === 'pushfnf' ? (
<div className="space-y-4">
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start gap-3">
@ -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>
<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 });