Dealer_Onboard_Frontend/src/features/onboarding/components/application-details/ApplicationDetailsTabs.tsx

1149 lines
66 KiB
TypeScript

import { toast } from 'sonner';
import {
AlertCircle,
Check,
CheckCircle,
CheckCircle2,
ChevronDown,
ChevronRight,
Clock,
ClipboardList,
Download,
Eye,
FileText,
GitBranch,
Lock,
RefreshCw,
ShieldCheck,
Upload,
User,
} from 'lucide-react';
import { cn, formatDateTime } from '@/components/ui/utils';
import QuestionnaireResponseView from '../QuestionnaireResponseView';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
interface ApplicationDetailsTabsProps {
application: any;
activeTab: string;
setActiveTab: (value: string) => void;
processStages: any[];
documents: any[];
interviews: any[];
expandedBranches: Record<string, boolean>;
setExpandedBranches: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
setSelectedStage: (value: string | null) => void;
setShowDocumentsModal: (value: boolean) => void;
setShowUploadForm: (value: boolean) => void;
handleRetriggerEvaluators: () => void;
handleRescheduleInterview: (interview: any) => void;
setSelectedEvaluationForView: (value: any) => void;
setShowFeedbackDetailsModal: (value: boolean) => void;
renderFddAuditContent: () => React.ReactNode;
eorProgress: number;
eorData: any;
eorChecklist: any[];
setUploadDocType: (value: string) => void;
isAdmin: boolean;
fetchApplication: () => void;
fetchEorData: () => void;
deposits: any[];
getDeposit: (type: string) => any;
paymentConfigs: any;
setPreviewDoc: (value: any) => void;
setShowPreviewModal: (value: boolean) => void;
auditLoading: boolean;
auditLogs: any[];
auditLogActionBadgeClass: (action: string) => string;
}
export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
const {
application,
activeTab,
setActiveTab,
processStages,
documents,
interviews,
expandedBranches,
setExpandedBranches,
setSelectedStage,
setShowDocumentsModal,
setShowUploadForm,
handleRetriggerEvaluators,
handleRescheduleInterview,
setSelectedEvaluationForView,
setShowFeedbackDetailsModal,
renderFddAuditContent,
eorProgress,
eorData,
eorChecklist,
setUploadDocType,
isAdmin,
fetchApplication,
fetchEorData,
deposits,
getDeposit,
paymentConfigs,
setPreviewDoc,
setShowPreviewModal,
auditLoading,
auditLogs,
auditLogActionBadgeClass,
} = props;
const normalizeRole = (value: unknown): string =>
String(value || '')
.trim()
.toLowerCase()
.replace(/[_\s-]+/g, ' ');
const participantHasAnyRole = (participant: any, expectedRoles: string[]) => {
const participantRoles = [
participant?.user?.role,
participant?.user?.roleCode,
participant?.metadata?.role,
].map(normalizeRole);
const normalizedExpected = expectedRoles.map(normalizeRole);
return participantRoles.some((role) => normalizedExpected.includes(role));
};
return (
<Card data-testid="onboarding-details-tabs-container">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<CardHeader className="pb-4 px-4 sm:px-6">
<div className="overflow-x-auto custom-scrollbar-x -mx-4 px-4 sm:-mx-6 sm:px-6">
<TabsList className="w-max min-w-full justify-start h-11 bg-slate-100/80 p-1" data-testid="onboarding-tabs-list">
<TabsTrigger value="questionnaire" className="min-w-[120px]" data-testid="onboarding-tab-trigger-questionnaire">Questionnaire</TabsTrigger>
<TabsTrigger value="progress" className="min-w-[80px]" data-testid="onboarding-tab-trigger-progress">Progress</TabsTrigger>
<TabsTrigger value="documents" className="min-w-[100px]" data-testid="onboarding-tab-trigger-documents">Documents</TabsTrigger>
<TabsTrigger value="interviews" className="min-w-[100px]" data-testid="onboarding-tab-trigger-interviews">Interviews</TabsTrigger>
<TabsTrigger value="fdd" className="min-w-[120px]" data-testid="onboarding-tab-trigger-fdd">FDD Audit</TabsTrigger>
<TabsTrigger value="eor" className="min-w-[120px]" data-testid="onboarding-tab-trigger-eor">EOR Checklist</TabsTrigger>
<TabsTrigger value="payments" className="min-w-[100px]" data-testid="onboarding-tab-trigger-payments">Payments</TabsTrigger>
<TabsTrigger value="audit" className="min-w-[100px]" data-testid="onboarding-tab-trigger-audit">Audit Trail</TabsTrigger>
</TabsList>
</div>
</CardHeader>
<CardContent>
<TabsContent value="questionnaire" className="space-y-6" data-testid="onboarding-tab-content-questionnaire">
<QuestionnaireResponseView application={application} />
</TabsContent>
<TabsContent value="progress" className="space-y-6" data-testid="onboarding-tab-content-progress">
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-slate-900">Application Journey</h3>
<Badge className="bg-amber-600" data-testid="onboarding-progress-percentage-badge">{application.progress}% Complete</Badge>
</div>
<Progress value={application.progress} className="h-3 mb-6" data-testid="onboarding-progress-bar" />
</div>
<div className="relative" data-testid="onboarding-progress-stages-container">
{(() => {
const interviewRoleMap: Record<number, string[]> = {
1: ['DD-ZM', 'RBM'],
2: ['DD Lead', 'ZBH'],
3: ['NBH', 'DD Head'],
};
const stageRoleMap: Record<string, string[]> = {
LOI_APPROVAL: ['DD Head', 'NBH'],
LOA_APPROVAL: ['DD Head', 'NBH'],
};
const getApproverStatus = (stageCode: string | number) => {
const stageParticipants = (application.participants || []).filter((p: any) => {
const metadataMatch =
p.metadata?.stageCode === stageCode ||
p.metadata?.allAssignments?.includes(stageCode) ||
(typeof stageCode === 'number' &&
(p.metadata?.interviewLevel === stageCode ||
p.metadata?.interviewLevel === String(stageCode) ||
p.metadata?.allAssignments?.includes(stageCode) ||
p.metadata?.allAssignments?.includes(String(stageCode)))) ||
(typeof stageCode === 'string' &&
!isNaN(Number(stageCode)) &&
(p.metadata?.interviewLevel === Number(stageCode) ||
p.metadata?.allAssignments?.includes(Number(stageCode))));
if (metadataMatch) return true;
if (typeof stageCode === 'number') {
return participantHasAnyRole(p, interviewRoleMap[stageCode] || []);
}
return participantHasAnyRole(p, stageRoleMap[stageCode] || []);
});
return stageParticipants.map((p: any) => {
const saCode = typeof stageCode === 'number' ? `INTERVIEW_LEVEL_${stageCode}` : stageCode;
const approval = (application.stageApprovals || []).find((sa: any) =>
sa.stageCode === saCode &&
String(sa.actorUserId) === String(p.userId)
);
return {
name: p.user?.name || p.user?.fullName || 'Unknown',
role: p.user?.role || p.user?.roleCode || p.metadata?.role || 'Reviewer',
status: approval ? (approval.decision === 'Approved' ? 'approved' : 'rejected') : 'pending'
};
});
};
const renderApprovers = (stageName: string, stageIndex: number) => {
const stageMapping: Record<string, string | number> = {
'1st Level Interview': 1,
'2nd Level Interview': 2,
'3rd Level Interview': 3,
'LOI Approval': 'LOI_APPROVAL',
'LOA': 'LOA_APPROVAL'
};
const stageCode = stageMapping[stageName];
if (!stageCode) return null;
const approvers = getApproverStatus(stageCode);
if (approvers.length === 0) return null;
return (
<div className="flex flex-wrap gap-2 mt-3" data-testid={`onboarding-stage-approvers-${stageIndex}`}>
{approvers.map((approver: any, i: number) => (
<div key={i} className="group relative flex items-center gap-1.5 bg-slate-50 border border-slate-200 rounded-full pl-1 pr-2.5 py-0.5 transition-all hover:bg-white hover:shadow-sm" data-testid={`onboarding-stage-approver-${stageIndex}-${i}`}>
<div className={cn(
"w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white",
approver.status === 'approved' ? "bg-green-500" : approver.status === 'rejected' ? "bg-red-500" : "bg-slate-300"
)}>
{approver.name.split(' ').map((n: string) => n[0]).join('').substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-[10px] font-medium text-slate-700 leading-none">{approver.name}</span>
<span className="text-[8px] text-slate-500 leading-none mt-0.5">{approver.role}</span>
</div>
<div className={cn(
"absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-white",
approver.status === 'approved' ? "bg-green-500" : approver.status === 'rejected' ? "bg-red-500" : "bg-amber-400"
)} data-testid={`onboarding-stage-approver-status-dot-${stageIndex}-${i}`} />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-slate-900 text-white text-[10px] rounded opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
{approver.role}: {approver.status.toUpperCase()}
</div>
</div>
))}
</div>
);
};
return processStages.map((stage, index) => (
<div key={stage.id} data-testid={`onboarding-progress-stage-${index}`}>
<div className="flex gap-4 pb-8">
<div className="relative">
<div className={`w-10 h-10 rounded-full flex items-center justify-center border-2 z-10 relative ${stage.status === 'completed'
? 'bg-green-500 border-green-500 text-white shadow-md'
: stage.status === 'active'
? stage.isLocked ? 'bg-slate-400 border-slate-400 text-white' : 'bg-amber-500 border-amber-500 text-white animate-pulse-subtle'
: 'bg-white border-slate-300 text-slate-400 shadow-none'
}`} data-testid={`onboarding-progress-stage-icon-${index}`}>
{stage.isParallel ? (
<GitBranch className="w-5 h-5" />
) : stage.isLocked ? (
<div className="group relative">
<Lock className="w-5 h-5 text-white cursor-help" />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1.5 bg-slate-900 text-white text-[10px] rounded shadow-xl opacity-0 group-hover:opacity-100 pointer-events-none transition-all duration-200 whitespace-nowrap z-[100] border border-slate-700">
<div className="flex flex-col gap-1">
<span className="font-bold text-amber-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> Stage Locked
</span>
<span>{stage.lockMessage}</span>
</div>
<div className="absolute top-full left-1/2 -translate-x-1/2 border-8 border-transparent border-t-slate-900"></div>
</div>
</div>
) : (
<>
{stage.status === 'completed' ? (
<CheckCircle2 className="w-6 h-6" />
) : stage.status === 'active' ? (
<Clock className="w-5 h-5 text-white" />
) : (
<div className="w-3 h-3 bg-slate-300 rounded-full"></div>
)}
</>
)}
</div>
{index < processStages.length - 1 && !stage.isParallel && (
<div className={`absolute top-10 left-1/2 -translate-x-1/2 w-0.5 h-full z-0 ${stage.status === 'completed' ? 'bg-green-500/30' : 'bg-slate-200'
}`} data-testid={`onboarding-progress-stage-connector-${index}`}></div>
)}
</div>
<div className="flex-1 pt-1">
<p className={cn(
"font-bold transition-colors",
stage.status === 'completed' ? "text-green-700" : stage.status === 'active' ? "text-amber-700" : "text-slate-900"
)} data-testid={`onboarding-progress-stage-name-${index}`}>{stage.name}</p>
{stage.description && (
<p className="text-slate-600 text-sm mt-0.5 leading-relaxed" data-testid={`onboarding-progress-stage-desc-${index}`}>{stage.description}</p>
)}
{renderApprovers(stage.name as string, index)}
{stage.evaluators && stage.evaluators.length > 0 && !['LOI Approval', 'LOA', '1st Level Interview', '2nd Level Interview', '3rd Level Interview'].includes(stage.name as string) && (
<p className="text-amber-600 text-xs mt-1.5 flex items-center gap-1 bg-amber-50 w-fit px-2 py-0.5 rounded border border-amber-100" data-testid={`onboarding-progress-stage-evaluators-${index}`}>
<User className="w-3 h-3" />
Evaluators: {stage.evaluators.join(' + ')}
</p>
)}
{(() => {
const expectedMap: Record<number, number> = {
3: 2,
4: 2,
5: 2,
6: 2,
8: 2,
12: 2
};
const stageId = Number(stage.id);
const expectedCount = expectedMap[stageId];
const stageCodeById: Record<number, string | number> = {
3: 1, // shortlist depends on L1 evaluators
4: 1,
5: 2,
6: 3,
8: 'LOI_APPROVAL',
12: 'LOA_APPROVAL',
};
const mappedStageCode = stageCodeById[stageId];
const actualCount = mappedStageCode ? getApproverStatus(mappedStageCode).length : (stage.evaluators?.length || 0);
const isEligibleForWarning = stageId === 3 ? (stage.status === 'completed') : (stage.status !== 'pending');
if (expectedCount && actualCount < expectedCount && application.status !== 'Rejected' && isEligibleForWarning) {
return (
<div className="mt-2" data-testid={`onboarding-progress-stage-warning-${index}`}>
<Alert variant="destructive" className="py-2 px-3 border-amber-200 bg-amber-50 text-amber-800">
<AlertCircle className="h-4 w-4 text-amber-600" />
<AlertTitle className="text-xs font-semibold">Missing Evaluators</AlertTitle>
<AlertDescription className="text-xs">
{actualCount === 0
? "Respective role users were not found for this location."
: `Some roles (${actualCount}/${expectedCount}) are missing for this location.`
}
<Button
variant="link"
size="sm"
className="h-auto p-0 ml-1 text-xs text-amber-700 underline"
onClick={handleRetriggerEvaluators}
data-testid={`onboarding-progress-stage-retrigger-${index}`}
>
<RefreshCw className="w-3 h-3 mr-1" />
Re-trigger Assignment
</Button>
</AlertDescription>
</Alert>
</div>
);
}
return null;
})()}
{(() => {
const stageDocsCount = documents.filter(doc =>
doc.stage === stage.name ||
(!doc.stage && doc.documentType?.toLowerCase().includes(stage.name.toLowerCase().split(' ')[0]))
).length;
return (
<div className="flex items-center gap-2 mt-1">
<button
onClick={() => {
setSelectedStage(stage.name);
setShowDocumentsModal(true);
if (stageDocsCount === 0) setShowUploadForm(true);
}}
className="text-xs font-semibold text-blue-600 hover:text-blue-800 flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-50 border border-blue-100 hover:bg-blue-100 transition-all shadow-sm"
data-testid={`onboarding-progress-stage-docs-${index}`}
>
<FileText className="w-3.5 h-3.5" />
{stageDocsCount > 0 ? `${stageDocsCount} Documents` : 'Upload'}
</button>
</div>
);
})()}
<p className="text-slate-500 mt-1 text-xs" data-testid={`onboarding-progress-stage-status-text-${index}`}>
{stage.status === 'completed' && stage.date && `Completed: ${formatDateTime(stage.date)}`}
{stage.status === 'active' && 'In Progress'}
{stage.status === 'pending' && 'Pending'}
</p>
</div>
</div>
{stage.isParallel && stage.branches && (
<div className="ml-5 mb-8" data-testid={`onboarding-progress-parallel-branches-${index}`}>
{stage.branches.map((branch: any, branchIndex: number) => {
const branchKey = branch.name.toLowerCase().replace(/\s+/g, '-');
const isExpanded = expandedBranches[branchKey];
const branchColor = branch.color === 'blue' ? 'blue' : 'green';
return (
<div key={branchIndex} className="mb-6 last:mb-0">
<div className="flex items-center gap-3 mb-2">
<button
onClick={() => setExpandedBranches(prev => ({
...prev,
[branchKey]: !prev[branchKey]
}))}
className={`flex-1 flex items-center gap-3 p-4 rounded-lg border-2 transition-all hover:shadow-md ${branchColor === 'blue'
? 'border-blue-300 bg-blue-50 hover:bg-blue-100'
: 'border-green-300 bg-green-50 hover:bg-green-100'
}`}
data-testid={`onboarding-progress-branch-trigger-${branchKey}`}
>
{isExpanded ? (
<ChevronDown className={`w-5 h-5 ${branchColor === 'blue' ? 'text-blue-600' : 'text-green-600'}`} />
) : (
<ChevronRight className={`w-5 h-5 ${branchColor === 'blue' ? 'text-blue-600' : 'text-green-600'}`} />
)}
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${branchColor === 'blue' ? 'bg-blue-200' : 'bg-green-200'
}`}>
<GitBranch className={`w-4 h-4 ${branchColor === 'blue' ? 'text-blue-700' : 'text-green-700'}`} />
</div>
<div className="flex-1 text-left">
<p className={`${branchColor === 'blue' ? 'text-blue-900' : 'text-green-900'} font-semibold tracking-tight`}>
{branch.name}
</p>
<p className={`text-[10px] uppercase font-bold tracking-wider ${branchColor === 'blue' ? 'text-blue-500' : 'text-green-500'}`}>
{branch.stages.length} SUB-STEPS
</p>
</div>
</button>
</div>
{isExpanded && (
<div className="mt-4 ml-8 border-l-2 border-slate-200 pl-6 space-y-6" data-testid={`onboarding-progress-branch-content-${branchKey}`}>
{branch.stages.map((branchStage: any, bsIdx: number) => (
<div key={branchStage.id} className="relative">
<div className="flex gap-4 text-xs" data-testid={`onboarding-progress-branch-stage-${branchKey}-${bsIdx}`}>
{(() => {
const stageDocs = documents.filter((doc: any) =>
doc.documentType?.toLowerCase().includes(branchStage.name.toLowerCase().split(' ')[0]) ||
doc.stage === branchStage.name
);
const isDone = branchStage.status === 'completed' || stageDocs.length > 0;
return (
<>
<div className="relative">
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${isDone
? `${branchColor === 'blue' ? 'bg-blue-500 border-blue-500' : 'bg-green-500 border-green-500'}`
: branchStage.status === 'active'
? 'bg-amber-500 border-amber-500 text-white shadow-sm'
: 'bg-white border-slate-300 text-slate-400'
}`} data-testid={`onboarding-progress-branch-stage-icon-${branchKey}-${bsIdx}`}>
{isDone ? (
<Check className="w-4 h-4 text-white" strokeWidth={3} />
) : branchStage.status === 'active' ? (
<Clock className="w-4 h-4 text-white" />
) : (
<div className="w-2 h-2 bg-slate-300 rounded-full"></div>
)}
</div>
</div>
<div className="flex-1">
<p className="font-semibold text-slate-800" data-testid={`onboarding-progress-branch-stage-name-${branchKey}-${bsIdx}`}>{branchStage.name}</p>
{branchStage.description && (
<p className="text-slate-500 text-xs mt-0.5">{branchStage.description}</p>
)}
<div className="flex items-center gap-2 mt-1">
<button
onClick={() => {
setSelectedStage(branchStage.name);
setShowDocumentsModal(true);
if (stageDocs.length === 0) setShowUploadForm(true);
}}
className={cn(
"text-[10px] font-medium flex items-center gap-1 transition-colors",
branchColor === 'blue' ? "text-blue-600 hover:text-blue-800" : "text-green-600 hover:text-green-800"
)}
data-testid={`onboarding-progress-branch-stage-docs-${branchKey}-${bsIdx}`}
>
<FileText className="w-2.5 h-2.5" />
{stageDocs.length > 0 ? `${stageDocs.length} Docs` : 'Upload'}
</button>
</div>
<p className="text-slate-400 text-[10px] mt-1" data-testid={`onboarding-progress-branch-stage-status-${branchKey}-${bsIdx}`}>
{isDone && branchStage.date ? `Done: ${formatDateTime(branchStage.date)}` : isDone && stageDocs.length > 0 ? `Uploaded: ${formatDateTime(stageDocs[0].updatedAt || stageDocs[0].createdAt)}` : 'Pending'}
</p>
</div>
</>
);
})()}
</div>
</div>
))}
</div>
)}
</div>
);
})}
<div className="h-8 w-0.5 bg-slate-300 ml-5 opacity-50"></div>
</div>
)}
</div>
))
})()}
</div>
</TabsContent>
<TabsContent value="documents" className="space-y-4" data-testid="onboarding-tab-content-documents">
<div className="flex items-center justify-between">
<h3 className="text-slate-900">Uploaded Documents</h3>
<Button size="sm" className="bg-amber-600 hover:bg-amber-700" data-testid="onboarding-documents-upload-tab-button" onClick={() => {
setSelectedStage(null);
setShowDocumentsModal(true);
setShowUploadForm(true);
}}>
<Upload className="w-4 h-4 mr-2" />
Upload Document
</Button>
</div>
<div className="overflow-x-auto">
<Table data-testid="onboarding-documents-table">
<TableHeader>
<TableRow>
<TableHead className="min-w-[200px]">File Name</TableHead>
<TableHead className="min-w-[120px]">Type</TableHead>
<TableHead className="min-w-[120px]">Upload Date</TableHead>
<TableHead className="min-w-[150px]">Uploader</TableHead>
<TableHead className="text-right min-w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documents.length === 0 ? (
<TableRow data-testid="onboarding-documents-empty-row">
<TableCell colSpan={5} className="text-center py-8 text-slate-500">
No documents uploaded yet
</TableCell>
</TableRow>
) : (
documents.map((doc, idx) => (
<TableRow key={doc.id} data-testid={`onboarding-document-row-${idx}`}>
<TableCell className="flex items-center gap-2">
<FileText className="w-4 h-4 text-slate-400" />
<span className="truncate max-w-[150px] md:max-w-[300px]" data-testid={`onboarding-document-name-${idx}`}>{doc.fileName}</span>
</TableCell>
<TableCell data-testid={`onboarding-document-type-${idx}`}>{doc.documentType}</TableCell>
<TableCell>{formatDateTime(doc.createdAt)}</TableCell>
<TableCell data-testid={`onboarding-document-uploader-${idx}`}>
{doc.uploader?.fullName || (doc.uploadedBy ? 'Unknown User' : 'Applicant')}
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline" data-testid={`onboarding-document-preview-${idx}`} onClick={() => {
setPreviewDoc(doc);
setShowPreviewModal(true);
}}>
<Eye className="w-3 h-3 text-slate-500" />
</Button>
<Button size="sm" variant="outline" data-testid={`onboarding-document-download-${idx}`} onClick={() => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000';
window.open(`${baseUrl}/${doc.filePath}`, '_blank');
}}>
<Download className="w-3 h-3 text-slate-500" />
</Button>
</div>
</TableCell>
</TableRow>
)))}
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="interviews" className="space-y-6" data-testid="onboarding-tab-content-interviews">
<div>
<h3 className="text-slate-900 mb-4">Scheduled Interviews</h3>
<div className="overflow-x-auto">
<Table data-testid="onboarding-interviews-scheduled-table">
<TableHeader>
<TableRow>
<TableHead className="min-w-[100px]">Level</TableHead>
<TableHead className="min-w-[180px]">Date & Time</TableHead>
<TableHead className="min-w-[100px]">Type</TableHead>
<TableHead className="min-w-[200px]">Location/Link</TableHead>
<TableHead className="min-w-[120px]">Status</TableHead>
<TableHead className="min-w-[150px]">Scheduled By</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(!interviews || interviews.length === 0) ? (
<TableRow data-testid="onboarding-interviews-empty-row">
<TableCell colSpan={7} className="text-center py-8 text-slate-500">
No interviews scheduled yet
</TableCell>
</TableRow>
) : (
(Array.isArray(interviews) ? interviews : []).map((interview, idx) => (
<TableRow key={interview.id} data-testid={`onboarding-interview-row-${idx}`}>
<TableCell className="font-medium">Level {interview.level}</TableCell>
<TableCell>{interview.scheduleDate ? new Date(interview.scheduleDate).toLocaleString() : 'N/A'}</TableCell>
<TableCell className="capitalize">{interview.interviewType}</TableCell>
<TableCell>
{interview.interviewType?.toLowerCase().includes('virtual') ? (
<a href={interview.linkOrLocation} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline" data-testid={`onboarding-interview-link-${idx}`}>
Join Meeting
</a>
) : (
<span data-testid={`onboarding-interview-location-${idx}`}>{interview.linkOrLocation}</span>
)}
</TableCell>
<TableCell>
<Badge variant={interview.status === 'Completed' ? 'default' : 'secondary'} data-testid={`onboarding-interview-status-${idx}`}>
{interview.status}
</Badge>
</TableCell>
<TableCell>{interview.scheduler?.fullName || interview.scheduledBy || 'N/A'}</TableCell>
<TableCell className="text-right">
{(interview.status === 'Scheduled' || interview.status === 'scheduled') && (
<Button
variant="ghost"
size="sm"
className="text-primary-600 hover:text-primary-700 hover:bg-primary-50 h-8 px-2"
data-testid={`onboarding-interview-reschedule-${idx}`}
onClick={() => handleRescheduleInterview(interview)}
>
Reschedule
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
<div>
<h3 className="text-slate-900 mb-4">Interview Feedback</h3>
{(!interviews || interviews.length === 0) ? (
<p className="text-slate-500 italic" data-testid="onboarding-interviews-no-feedback">No interviews scheduled.</p>
) : (
(Array.isArray(interviews) ? interviews : []).map((interview, iIdx) => (
<div key={interview.id} className="mb-6 border p-4 rounded-lg bg-slate-50/50" data-testid={`onboarding-interview-feedback-block-${iIdx}`}>
<h4 className="font-semibold text-slate-800 mb-2">
Level {interview.level} Interview
<span className="font-normal text-slate-500 text-sm ml-2">
({formatDateTime(interview.scheduleDate)} - {interview.interviewType})
</span>
</h4>
{interview.evaluations && interview.evaluations.length > 0 ? (
<Table data-testid={`onboarding-interview-evaluations-table-${iIdx}`}>
<TableHeader>
<TableRow>
<TableHead>Interviewer</TableHead>
<TableHead>Role</TableHead>
<TableHead>
{interview.level === 1 ? 'Score (KT Matrix)' : 'Overall Score'}
</TableHead>
<TableHead>Remarks</TableHead>
<TableHead>Recommendation</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{interview.evaluations.map((evalItem: any, eIdx: number) => (
<TableRow key={evalItem.id} data-testid={`onboarding-interview-evaluation-row-${iIdx}-${eIdx}`}>
<TableCell className="font-medium">{evalItem.evaluator?.fullName}</TableCell>
<TableCell>{evalItem.evaluator?.role?.roleName || 'N/A'}</TableCell>
<TableCell>
{evalItem.ktMatrixScore ? (
<Badge variant={
interview.level === 1
? (Number(evalItem.ktMatrixScore) >= 50 ? 'outline' : 'destructive')
: (Number(evalItem.ktMatrixScore) >= 5 ? 'outline' : 'destructive')
} data-testid={`onboarding-interview-evaluation-score-${iIdx}-${eIdx}`}>
{evalItem.ktMatrixScore}/{interview.level === 1 ? '100' : '10'}
</Badge>
) : 'N/A'}
</TableCell>
<TableCell className="max-w-xs truncate" title={evalItem.remarks || evalItem.qualitativeFeedback}>
{evalItem.remarks ? (
<div className="flex flex-col gap-1">
<span className="font-medium text-slate-800">{evalItem.remarks}</span>
{evalItem.feedbackDetails && evalItem.feedbackDetails.length > 0 && (
<Button
variant="link"
className="p-0 h-auto font-normal text-blue-600 text-xs w-fit"
data-testid={`onboarding-interview-evaluation-details-btn-${iIdx}-${eIdx}`}
onClick={() => {
setSelectedEvaluationForView({ ...evalItem, interview });
setShowFeedbackDetailsModal(true);
}}
>
View Detailed Feedback
</Button>
)}
</div>
) : evalItem.feedbackDetails && evalItem.feedbackDetails.length > 0 ? (
<Button
variant="link"
className="p-0 h-auto font-normal text-blue-600"
data-testid={`onboarding-interview-evaluation-details-btn-${iIdx}-${eIdx}`}
onClick={() => {
setSelectedEvaluationForView({ ...evalItem, interview });
setShowFeedbackDetailsModal(true);
}}
>
View Detailed Feedback
</Button>
) : (
evalItem.qualitativeFeedback || '-'
)}
</TableCell>
<TableCell data-testid={`onboarding-interview-evaluation-rec-${iIdx}-${eIdx}`}>{evalItem.recommendation || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-sm text-slate-500 italic pl-2">No feedback recorded yet.</p>
)}
</div>
))
)}
</div>
{['Level 2 Approved', 'Level 3 Interview Pending', 'Approved', 'Onboarded'].includes(application.status) && (
<div data-testid="onboarding-interviews-summary-l2">
<h3 className="text-slate-900 mb-4">Level 2 Interview Summary</h3>
<div className="p-4 bg-slate-50 rounded-lg">
<p className="text-slate-600">Decision: Approved by both ZBH and DD Lead</p>
<p className="text-slate-600 mt-2">Overall Assessment: Strong candidate with excellent business plan</p>
</div>
</div>
)}
</TabsContent>
<TabsContent value="fdd" className="space-y-6" data-testid="onboarding-tab-content-fdd">
{renderFddAuditContent()}
</TabsContent>
<TabsContent value="eor" className="space-y-4" data-testid="onboarding-tab-content-eor">
<div className="flex items-center justify-between mb-4">
<h3 className="text-slate-900">Essential Operating Requirements</h3>
<Badge className="bg-amber-600" data-testid="onboarding-eor-progress-badge">{Math.round(eorProgress)}% Complete</Badge>
</div>
<Progress value={eorProgress} className="h-3 mb-6" data-testid="onboarding-eor-progress-bar" />
<div className="space-y-3" data-testid="onboarding-eor-checklist">
{(eorData?.items || eorChecklist).map((item: any, idx: number) => {
const docType = item.description || item.item;
const hasDocument = !!item.proofDocument;
return (
<div
key={item.id}
className="flex items-center gap-3 p-3 bg-slate-50 rounded-xl transition-all border border-transparent hover:border-slate-200 group"
data-testid={`onboarding-eor-item-${idx}`}
>
<Checkbox
checked={item.isCompliant || item.completed}
className="pointer-events-none shrink-0"
data-testid={`onboarding-eor-checkbox-${idx}`}
/>
<div
className="flex flex-col flex-1 min-w-0 cursor-pointer"
data-testid={`onboarding-eor-clickable-${idx}`}
onClick={() => {
setSelectedStage(`EOR: ${docType}`);
setUploadDocType(docType);
setShowDocumentsModal(true);
if (!hasDocument) setShowUploadForm(true);
else setShowUploadForm(false);
}}
>
<div className="flex items-center gap-2">
<span className={(item.isCompliant || item.completed) ? 'text-slate-900 font-bold' : 'text-slate-600 font-medium'}>
{docType}
</span>
{hasDocument && !item.isCompliant && (
<Badge variant="outline" className="text-[10px] h-4 px-1.5 bg-amber-50 text-amber-600 border-amber-200 uppercase tracking-wider font-bold">
Needs Verification
</Badge>
)}
</div>
{hasDocument && (
<div className="flex items-center gap-1.5 text-xs text-blue-600 font-semibold mt-1">
<FileText className="w-3.5 h-3.5" />
<span className="truncate">{item.proofDocument.fileName}</span>
</div>
)}
{!hasDocument && (
<span className="text-[10px] text-slate-400 mt-1 uppercase tracking-tighter">Click to upload proof</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{hasDocument && !item.isCompliant && isAdmin && (
<div className="flex gap-2">
<Button
size="sm"
className="h-8 px-3 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg shadow-sm"
data-testid={`onboarding-eor-verify-btn-${idx}`}
onClick={async () => {
await (await import('@/services/eor.service')).eorService.updateItem(eorData.id, {
...item,
isCompliant: true
});
fetchEorData();
toast.success(`${docType} verified!`);
}}
>
Verify
</Button>
<Button
size="sm"
variant="outline"
className="h-8 px-3 border-red-200 text-red-600 hover:bg-red-50 font-bold rounded-lg"
data-testid={`onboarding-eor-reject-btn-${idx}`}
onClick={async () => {
await (await import('@/services/eor.service')).eorService.updateItem(eorData.id, {
...item,
isCompliant: false,
proofDocumentId: null
});
fetchEorData();
toast.success(`${docType} rejected.`);
}}
>
Reject
</Button>
</div>
)}
{(item.isCompliant || item.completed) && (
<div className="bg-green-100 p-1.5 rounded-full" data-testid={`onboarding-eor-done-icon-${idx}`}>
<CheckCircle className="w-4 h-4 text-green-600" />
</div>
)}
{!hasDocument && (
<div className="p-2 text-slate-300 group-hover:text-amber-500 transition-colors" data-testid={`onboarding-eor-upload-hint-${idx}`}>
<Upload className="w-4 h-4" />
</div>
)}
</div>
</div>
);
})}
</div>
{eorProgress === 100 && isAdmin && (application.status === 'EOR In Progress' || application.status === 'LOA Pending') && (
<div className="mt-8 p-6 bg-green-50 rounded-xl border-2 border-green-200 animate-in fade-in slide-in-from-bottom-4 duration-500" data-testid="onboarding-eor-complete-banner">
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-green-100 flex items-center justify-center shrink-0">
<ShieldCheck className="w-7 h-7 text-green-600" />
</div>
<div className="flex-1 text-center sm:text-left">
<h4 className="text-green-900 font-bold text-lg">EOR Checklist Complete</h4>
<p className="text-green-700 text-sm">All 12 mandatory requirements have been verified. You can now complete the audit and move to final inauguration.</p>
</div>
<Button
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]"
data-testid="onboarding-eor-submit-audit"
onClick={async () => {
try {
const checklistId = eorData?.id;
if (!checklistId) throw new Error('Checklist ID not found');
await (await import('@/services/eor.service')).eorService.submitAudit(checklistId, {
status: 'Completed',
overallComments: 'EOR Checklist verified and audit completed.'
});
toast.success('EOR Audit completed successfully!');
fetchApplication();
fetchEorData();
} catch (error: any) {
toast.error(error.message || 'Failed to complete EOR audit');
}
}}
>
Complete Audit & Proceed
</Button>
</div>
</div>
)}
</TabsContent>
<TabsContent value="payments" className="space-y-6" data-testid="onboarding-tab-content-payments">
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold text-slate-900">Security Deposits</h3>
<Badge variant="outline" className="bg-slate-50 text-slate-500 border-slate-200" data-testid="onboarding-payments-count-badge">
{deposits.length} Payment Record(s)
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{(() => {
const deposit = getDeposit('SECURITY_DEPOSIT');
const config = paymentConfigs.SECURITY_DEPOSIT;
const expectedAmount = config?.amount || 500000;
return (
<Card className={cn(
"border-l-4",
deposit?.status === 'Verified' ? "border-l-green-500" :
deposit?.status === 'Rejected' ? "border-l-red-500" : "border-l-amber-500"
)} data-testid="onboarding-payment-card-security">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded bg-amber-50 flex items-center justify-center text-amber-600">
<ClipboardList className="w-4 h-4" />
</div>
<span className="font-semibold text-slate-700">Security Deposit</span>
</div>
<Badge className={cn(
deposit?.status === 'Verified' ? "bg-green-100 text-green-700 hover:bg-green-100" :
deposit?.status === 'Rejected' ? "bg-red-100 text-red-700 hover:bg-red-100" :
"bg-amber-100 text-amber-700 hover:bg-amber-100"
)} data-testid="onboarding-payment-status-security">
{deposit?.status || 'Awaiting'}
</Badge>
</div>
<div className="space-y-3">
<div className="flex justify-between items-baseline">
<span className="text-xs text-slate-500 uppercase font-bold tracking-wider">Amount Received</span>
<span className="text-lg font-bold text-slate-900" data-testid="onboarding-payment-amount-security">{Number(deposit?.amount || 0).toLocaleString()}</span>
</div>
<div className="flex justify-between items-baseline border-t border-slate-100 pt-2">
<span className="text-xs text-slate-500">Expected Total</span>
<span className="text-sm font-medium text-slate-600">{expectedAmount.toLocaleString()}</span>
</div>
{deposit?.paymentReference && (
<div className="bg-slate-50 p-2 rounded text-xs font-mono text-slate-600 flex justify-between items-center" data-testid="onboarding-payment-ref-security">
<span>Ref: {deposit.paymentReference}</span>
{deposit.verifiedAt && <span>{formatDateTime(deposit.verifiedAt)}</span>}
</div>
)}
{deposit?.remarks && (
<div className="text-[11px] text-slate-500 bg-red-50/50 p-2 rounded border border-red-100 italic" data-testid="onboarding-payment-remarks-security">
"{deposit.remarks}"
</div>
)}
<div className="pt-4 mt-2 border-t border-slate-100">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">Verification Documents</p>
<div className="space-y-2">
{documents.filter((d: any) => d.documentType?.toLowerCase().includes('security') && d.documentType?.toLowerCase().includes('deposit')).map((doc: any, idx: number) => (
<div key={idx} className="flex items-center justify-between p-2 rounded bg-slate-50/50 border border-slate-100" data-testid={`onboarding-payment-doc-security-${idx}`}>
<div className="flex items-center gap-2 overflow-hidden">
<FileText className="w-3 h-3 text-slate-400" />
<span className="text-[10px] font-medium text-slate-700 truncate">{doc.fileName || doc.name}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px] text-amber-600 hover:text-amber-700 hover:bg-amber-50"
data-testid={`onboarding-payment-doc-view-security-${idx}`}
onClick={() => { setPreviewDoc(doc); setShowPreviewModal(true); }}
>
View
</Button>
</div>
))}
{documents.filter((d: any) => d.documentType?.toLowerCase().includes('security') && d.documentType?.toLowerCase().includes('deposit')).length === 0 && (
<p className="text-[10px] text-slate-400 italic">No proof uploaded</p>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
})()}
{(() => {
const deposit = getDeposit('FIRST_FILL');
const config = paymentConfigs.FIRST_FILL;
const expectedAmount = config?.amount || 1500000;
return (
<Card className={cn(
"border-l-4",
deposit?.status === 'Verified' ? "border-l-green-500" :
deposit?.status === 'Rejected' ? "border-l-red-500" : "border-l-amber-500"
)} data-testid="onboarding-payment-card-first-fill">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded bg-blue-50 flex items-center justify-center text-blue-600">
<ShieldCheck className="w-4 h-4" />
</div>
<span className="font-semibold text-slate-700">First Fill</span>
</div>
<Badge className={cn(
deposit?.status === 'Verified' ? "bg-green-100 text-green-700 hover:bg-green-100" :
deposit?.status === 'Rejected' ? "bg-red-100 text-red-700 hover:bg-red-100" :
"bg-amber-100 text-amber-700 hover:bg-amber-100"
)} data-testid="onboarding-payment-status-first-fill">
{deposit?.status || 'Awaiting'}
</Badge>
</div>
<div className="space-y-3">
<div className="flex justify-between items-baseline">
<span className="text-xs text-slate-500 uppercase font-bold tracking-wider">Amount Received</span>
<span className="text-lg font-bold text-slate-900" data-testid="onboarding-payment-amount-first-fill">{Number(deposit?.amount || 0).toLocaleString()}</span>
</div>
<div className="flex justify-between items-baseline border-t border-slate-100 pt-2">
<span className="text-xs text-slate-500">Expected Total</span>
<span className="text-sm font-medium text-slate-600">{expectedAmount.toLocaleString()}</span>
</div>
{deposit?.paymentReference && (
<div className="bg-slate-50 p-2 rounded text-xs font-mono text-slate-600 flex justify-between items-center" data-testid="onboarding-payment-ref-first-fill">
<span>Ref: {deposit.paymentReference}</span>
{deposit.verifiedAt && <span>{formatDateTime(deposit.verifiedAt)}</span>}
</div>
)}
{deposit?.remarks && (
<div className="text-[11px] text-slate-500 bg-red-50/50 p-2 rounded border border-red-100 italic" data-testid="onboarding-payment-remarks-first-fill">
"{deposit.remarks}"
</div>
)}
<div className="pt-4 mt-2 border-t border-slate-100">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">Verification Documents</p>
<div className="space-y-2">
{documents.filter((d: any) => d.documentType?.toLowerCase().includes('first') && d.documentType?.toLowerCase().includes('fill')).map((doc: any, idx: number) => (
<div key={idx} className="flex items-center justify-between p-2 rounded bg-slate-50/50 border border-slate-100" data-testid={`onboarding-payment-doc-first-fill-${idx}`}>
<div className="flex items-center gap-2 overflow-hidden">
<FileText className="w-3 h-3 text-slate-400" />
<span className="text-[10px] font-medium text-slate-700 truncate">{doc.fileName || doc.name}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50"
data-testid={`onboarding-payment-doc-view-first-fill-${idx}`}
onClick={() => { setPreviewDoc(doc); setShowPreviewModal(true); }}
>
View
</Button>
</div>
))}
{documents.filter((d: any) => d.documentType?.toLowerCase().includes('first') && d.documentType?.toLowerCase().includes('fill')).length === 0 && (
<p className="text-[10px] text-slate-400 italic">No proof uploaded</p>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
})()}
</div>
</TabsContent>
<TabsContent value="audit" data-testid="onboarding-tab-content-audit">
<ScrollArea className="h-[30rem] rounded-md border border-slate-100 bg-slate-50/50">
<div className="space-y-2.5 p-3 pr-4" data-testid="onboarding-audit-logs-container">
{auditLoading ? (
<div className="flex items-center justify-center py-10" data-testid="onboarding-audit-loading">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600" />
<span className="ml-2 text-sm text-slate-500">Loading audit trail</span>
</div>
) : auditLogs.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 bg-white py-10 text-center text-sm text-slate-500" data-testid="onboarding-audit-empty">
No audit logs recorded yet for this application.
</div>
) : (
auditLogs.map((log: any, idx: number) => (
<div
key={log.id}
className="rounded-lg border border-slate-200/90 bg-white p-3 text-sm shadow-sm"
data-testid={`onboarding-audit-log-item-${idx}`}
>
<div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Badge
variant="outline"
className={cn(
'shrink-0 text-[10px] font-semibold uppercase tracking-wide',
auditLogActionBadgeClass(log.action)
)}
data-testid={`onboarding-audit-log-action-${idx}`}
>
{String(log.action || 'EVENT').replace(/_/g, ' ')}
</Badge>
{log.stage ? (
<span
className="max-w-[200px] truncate text-[11px] text-slate-500"
title={log.stage}
data-testid={`onboarding-audit-log-stage-${idx}`}
>
{log.stage}
</span>
) : null}
</div>
<time
className="shrink-0 text-xs tabular-nums text-slate-400"
dateTime={log.timestamp}
data-testid={`onboarding-audit-log-time-${idx}`}
>
{formatDateTime(log.timestamp)}
</time>
</div>
<p className="mt-2 text-[13px] leading-relaxed text-slate-800" data-testid={`onboarding-audit-log-desc-${idx}`}>
{log.description || '—'}
</p>
<div className="mt-2 flex items-center gap-1.5 text-xs text-slate-500">
<User className="h-3.5 w-3.5 shrink-0 text-slate-400" aria-hidden />
<span className="min-w-0 truncate">
<span className="font-medium text-slate-600" data-testid={`onboarding-audit-log-user-${idx}`}>
{log.userName || 'System'}
</span>
{log.userEmail ? (
<span className="text-slate-400" data-testid={`onboarding-audit-log-email-${idx}`}> · {log.userEmail}</span>
) : null}
</span>
</div>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
</CardContent>
</Tabs>
</Card>
);
}