auditlogs enhanced end to end flow checked for the onboarding , cursor used for major file chnages
This commit is contained in:
parent
71e6c10c16
commit
d3bdea8318
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
1
src/lib/dialogStyles.ts
Normal file
@ -0,0 +1 @@
|
||||
export const WIDE_DIALOG_CLASS = "!w-[80vw] !max-w-[80vw] sm:!max-w-[80vw]";
|
||||
46
src/lib/offboardingDocumentOptions.ts
Normal file
46
src/lib/offboardingDocumentOptions.ts
Normal 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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user