stage names modified and calendar addd in opportunity requests

This commit is contained in:
laxmanhalaki 2026-05-04 13:26:51 +05:30
parent 2f82699572
commit b357dbdcbb
12 changed files with 465 additions and 216 deletions

View File

@ -43,6 +43,7 @@ export const API = {
exportApplicationResponses: (params: { applicationIds: string }) => client.get('/onboarding/applications/export-responses', params),
getApplications: (params?: any) => client.get('/onboarding/applications', params),
shortlistApplications: (data: any) => client.post('/onboarding/applications/shortlist', data),
sendBulkReminders: (data: { applicationIds: string[] }) => client.post('/onboarding/applications/reminders', data),
getApplicationById: (id: string) => client.get(`/onboarding/applications/${id}`),
updateApplication: (id: string, data: any) => client.put(`/onboarding/applications/${id}`, data),
getLatestQuestionnaire: () => client.get('/questionnaire/latest'),

View File

@ -26,7 +26,6 @@ import {
Download,
Grid3x3,
List,
Mail,
CheckCircle,
AlertCircle,
Loader2
@ -193,9 +192,6 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
}
};
const handleBulkReminders = () => {
toast.success(`Reminder emails sent to ${selectedIds.length} applicant(s)`);
};
// For DD's All Applications page, only show initial statuses
const statusOptions: ApplicationStatus[] = [
@ -356,16 +352,6 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
{selectedIds.length > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={handleBulkReminders}
data-testid="onboarding-all-apps-reminders-btn"
>
<Mail className="w-4 h-4 mr-2" />
Send Reminders ({selectedIds.length})
</Button>
<Button
size="sm"
onClick={handleShortlist}

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { ApplicationStatus, Application } from '@/lib/mock-data';
import { formatDateTime } from '@/components/ui/utils';
import { onboardingService } from '@/services/onboarding.service';
@ -14,7 +15,8 @@ import {
import {
Search,
Download,
Mail
Mail,
Loader2
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
@ -53,6 +55,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
const [statusFilter, setStatusFilter] = useState<string>(initialFilter || 'all');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isSendingReminders, setIsSendingReminders] = useState(false);
const [sortBy, setSortBy] = useState<'date'>('date');
const [showNewApplicationModal, setShowNewApplicationModal] = useState(false);
const [showMyAssignments, setShowMyAssignments] = useState(false);
@ -153,9 +156,22 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
}
};
const handleBulkReminders = () => {
alert(`Sending reminders to ${selectedIds.length} applicants`);
const handleBulkReminders = async () => {
if (selectedIds.length === 0) return;
try {
setIsSendingReminders(true);
const res = await onboardingService.sendBulkReminders(selectedIds);
if (res.success) {
toast.success(res.message || `Reminder emails sent to ${selectedIds.length} applicant(s)`);
setSelectedIds([]);
}
} catch (error: any) {
console.error('Failed to send reminders:', error);
toast.error(error.message || 'Failed to send reminders');
} finally {
setIsSendingReminders(false);
}
};
const handleExport = () => {
@ -283,9 +299,14 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
variant="outline"
size="sm"
onClick={handleBulkReminders}
disabled={isSendingReminders}
data-testid="onboarding-applications-reminders-button"
>
{isSendingReminders ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Mail className="w-4 h-4 mr-2" />
)}
Send Reminders ({selectedIds.length})
</Button>
)}

View File

@ -11,6 +11,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Calendar as CalendarPicker } from '@/components/ui/calendar';
import { format } from 'date-fns';
import { cn } from '@/components/ui/utils';
import {
Search,
Download,
@ -182,9 +190,12 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
setApplicationsData(mappedApps);
// Extract unique locations
const uniqueLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean);
setLocations(uniqueLocations);
// Extract unique locations and states from the returned data
const newLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean) as string[];
setLocations(prev => Array.from(new Set([...prev, ...newLocations])));
const newStates = Array.from(new Set(mappedApps.map(app => app.state))).filter(Boolean) as string[];
setStates(prev => Array.from(new Set([...prev, ...newStates])));
} catch (error) {
console.error('Failed to fetch applications:', error);
toast.error('Failed to load non-opportunity requests');
@ -247,29 +258,55 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
</div>
<div className="flex items-center gap-2">
<div className="relative w-full md:w-36">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<Input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="pl-10 text-xs"
placeholder="From"
data-testid="onboarding-non-opps-from-date"
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-36 justify-start text-left font-normal h-10 px-3",
!fromDate && "text-muted-foreground"
)}
data-testid="onboarding-non-opps-from-date-trigger"
>
<Calendar className="mr-2 h-4 w-4 text-slate-400" />
{fromDate ? format(new Date(fromDate), "PP") : <span className="text-xs text-slate-500">From Date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarPicker
mode="single"
selected={fromDate ? new Date(fromDate) : undefined}
onSelect={(date) => setFromDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</div>
</PopoverContent>
</Popover>
<span className="text-slate-400">to</span>
<div className="relative w-full md:w-36">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<Input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="pl-10 text-xs"
placeholder="To"
data-testid="onboarding-non-opps-to-date"
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-36 justify-start text-left font-normal h-10 px-3",
!toDate && "text-muted-foreground"
)}
data-testid="onboarding-non-opps-to-date-trigger"
>
<Calendar className="mr-2 h-4 w-4 text-slate-400" />
{toDate ? format(new Date(toDate), "PP") : <span className="text-xs text-slate-500">To Date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarPicker
mode="single"
selected={toDate ? new Date(toDate) : undefined}
onSelect={(date) => setToDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</div>
</PopoverContent>
</Popover>
</div>
<Select value={locationFilter} onValueChange={setLocationFilter}>
@ -284,6 +321,22 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="text-slate-500 hover:text-slate-700 h-10 px-3"
onClick={() => {
setFromDate('');
setToDate('');
setLocationFilter('all');
setStateFilter('all');
setSearchQuery('');
}}
data-testid="onboarding-non-opps-clear-filters"
>
Clear Filters
</Button>
<Select value={stateFilter} onValueChange={setStateFilter}>
<SelectTrigger className="w-full lg:w-48" data-testid="onboarding-non-opps-state-select">
<SelectValue placeholder="All States" />

View File

@ -11,6 +11,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Calendar as CalendarPicker } from '@/components/ui/calendar';
import { format } from 'date-fns';
import { cn } from '@/components/ui/utils';
import {
Pagination,
PaginationContent,
@ -67,6 +75,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
const [currentPage, setCurrentPage] = useState(1);
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isSendingReminders, setIsSendingReminders] = useState(false);
const [showShortlistModal, setShowShortlistModal] = useState(false);
const [shortlistRemark, setShortlistRemark] = useState('');
@ -160,9 +169,13 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
setApplicationsData(mappedApps);
// Extract unique locations for filtering
const uniqueLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean);
setLocations(uniqueLocations);
// Extract unique locations and states from the returned data
// Note: This appends new ones to the existing list to ensure all found locations are selectable
const newLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean) as string[];
setLocations(prev => Array.from(new Set([...prev, ...newLocations])));
const newStates = Array.from(new Set(mappedApps.map(app => app.state))).filter(Boolean) as string[];
setStates(prev => Array.from(new Set([...prev, ...newStates])));
} catch (error) {
console.error('Failed to fetch applications:', error);
toast.error('Failed to load opportunity requests');
@ -231,12 +244,25 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
}
};
const handleBulkReminders = () => {
const handleBulkReminders = async () => {
if (selectedIds.length === 0) {
toast.error('Please select at least one application');
return;
}
toast.success(`Reminder emails sent to ${selectedIds.length} applicant(s)`);
try {
setIsSendingReminders(true);
const res = await onboardingService.sendBulkReminders(selectedIds);
if (res.success) {
toast.success(res.message || `Reminder emails sent to ${selectedIds.length} applicant(s)`);
setSelectedIds([]);
}
} catch (error: any) {
console.error('Failed to send reminders:', error);
toast.error(error.message || 'Failed to send reminders');
} finally {
setIsSendingReminders(false);
}
};
const handleExport = async () => {
@ -380,20 +406,6 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
return (
<div className="space-y-6">
{/* Info Banner */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4" data-testid="onboarding-opp-requests-banner">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-amber-900 mb-1">DD Lead Workflow - Opportunity Requests</h3>
<p className="text-amber-800">
This page shows <strong>applications where dealerships are being offered</strong> at the applicant's preferred location.
These have been shortlisted by DD and are waiting for your review. Select and <strong>Shortlist</strong> promising candidates
to move them to the <strong>Dealership Requests</strong> page for further processing.
</p>
</div>
</div>
</div>
{/* Header with Filters */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
@ -424,6 +436,23 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="text-slate-500 hover:text-slate-700 h-9"
onClick={() => {
setFromDate('');
setToDate('');
setStatusFilter('all');
setLocationFilter('all');
setStateFilter('all');
setSearchQuery('');
}}
data-testid="onboarding-opp-requests-clear-filters"
>
Clear Filters
</Button>
<Select value={stateFilter} onValueChange={setStateFilter}>
<SelectTrigger className="w-full md:w-48" data-testid="onboarding-opp-requests-state-select">
<SelectValue placeholder="Filter by state" />
@ -449,29 +478,55 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
</Select>
<div className="flex items-center gap-2 flex-1 md:flex-none">
<div className="relative w-full md:w-40">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<Input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="pl-10 text-xs"
placeholder="From"
data-testid="onboarding-opp-requests-from-date"
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-40 justify-start text-left font-normal h-9 px-3",
!fromDate && "text-muted-foreground"
)}
data-testid="onboarding-opp-requests-from-date-trigger"
>
<Calendar className="mr-2 h-4 w-4 text-slate-400" />
{fromDate ? format(new Date(fromDate), "PPP") : <span className="text-xs">From Date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarPicker
mode="single"
selected={fromDate ? new Date(fromDate) : undefined}
onSelect={(date) => setFromDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</div>
</PopoverContent>
</Popover>
<span className="text-slate-400">to</span>
<div className="relative w-full md:w-40">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<Input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="pl-10 text-xs"
placeholder="To"
data-testid="onboarding-opp-requests-to-date"
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-40 justify-start text-left font-normal h-9 px-3",
!toDate && "text-muted-foreground"
)}
data-testid="onboarding-opp-requests-to-date-trigger"
>
<Calendar className="mr-2 h-4 w-4 text-slate-400" />
{toDate ? format(new Date(toDate), "PPP") : <span className="text-xs">To Date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarPicker
mode="single"
selected={toDate ? new Date(toDate) : undefined}
onSelect={(date) => setToDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</div>
</PopoverContent>
</Popover>
</div>
<Select value={sortBy} onValueChange={setSortBy}>
@ -526,9 +581,14 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
variant="outline"
size="sm"
onClick={handleBulkReminders}
disabled={isSendingReminders}
data-testid="onboarding-opp-requests-bulk-reminder-btn"
>
{isSendingReminders ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Mail className="w-4 h-4 mr-2" />
)}
Send Reminders ({selectedIds.length})
</Button>

View File

@ -44,7 +44,8 @@ const STAGE_TO_ROLE_MAP: Record<string, string> = {
const TERMINAL_STAGE_LABELS = ['REJECTED', 'Rejected', 'REVOKED', 'Revoked', 'WITHDRAWN', 'Withdrawn'];
const RESIGNATION_STAGE_ALIASES: Record<string, string[]> = {
'ASM': ['ASM', 'ASM Review', 'Submission', 'Submitted'],
'Request Submitted': ['Submission', 'Submitted', 'Initiation'],
'ASM': ['ASM', 'ASM Review'],
'RBM': ['RBM', 'RBM Review', 'Regional Review', 'RBM + DD-ZM Review'],
'ZBH': ['ZBH', 'ZBH Review', 'ZM Review'],
'DD Lead': ['DD Lead', 'DD Lead Review', 'DDL Review'],
@ -137,18 +138,19 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
// Progress stages logic based on live data
const progressStages = [
{ id: 1, name: 'ASM Review', key: 'ASM', description: 'Area Sales Manager review' },
{ id: 2, name: 'RBM + DD-ZM Review', key: 'RBM', description: 'Joint approval by Regional Business Manager and DD-ZM' },
{ id: 3, name: 'ZBH Review', key: 'ZBH', description: 'Zonal Business Head approval' },
{ id: 4, name: 'DD Lead Review', key: 'DD Lead', description: 'DD Lead final review' },
{ id: 5, name: 'NBH Approval', key: 'NBH', description: 'National Business Head approval' },
{ id: 6, name: 'Legal - Resignation Letter', key: 'Legal', description: 'Legal team issues resignation approval letter' },
{ id: 7, name: 'DD Admin Review', key: 'DD Admin', description: 'DD Admin verification and final closure' },
{ id: 8, name: 'F&F Settlement', key: 'F&F Initiated', description: 'Full & Final settlement process' },
{ id: 9, name: 'Completed', key: 'Completed', description: 'Resignation process finalized' }
{ id: 1, name: 'Request Submitted', key: 'Request Submitted', description: 'Dealer submitted the resignation request' },
{ id: 2, name: 'ASM Review', key: 'ASM', description: 'Area Sales Manager review' },
{ id: 3, name: 'RBM + DD-ZM Review', key: 'RBM', description: 'Joint approval by Regional Business Manager and DD-ZM' },
{ id: 4, name: 'ZBH Review', key: 'ZBH', description: 'Zonal Business Head approval' },
{ id: 5, name: 'DD Lead Review', key: 'DD Lead', description: 'DD Lead final review' },
{ id: 6, name: 'NBH Approval', key: 'NBH', description: 'National Business Head approval' },
{ id: 7, name: 'Legal - Resignation Letter', key: 'Legal', description: 'Legal team issues resignation approval letter' },
{ id: 8, name: 'DD Admin Review', key: 'DD Admin', description: 'DD Admin verification and final closure' },
{ id: 9, name: 'F&F Settlement', key: 'F&F Initiated', description: 'Full & Final settlement process' },
{ id: 10, name: 'Completed', key: 'Completed', description: 'Resignation process finalized' }
];
const stagesOrdered = ['ASM', 'RBM', 'ZBH', 'DD Lead', 'NBH', 'Legal', 'DD Admin', 'F&F Initiated', 'Completed'];
const stagesOrdered = ['Request Submitted', 'ASM', 'RBM', 'ZBH', 'DD Lead', 'NBH', 'Legal', 'DD Admin', 'F&F Initiated', 'Completed'];
const legalStageApproved = (() => {
if (!resignationData) return false;
@ -216,7 +218,18 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
!isSettlementPhase &&
!hasAlreadyPartiallyApproved &&
!(currentStage === 'Legal' && legalStageApproved) &&
!(isDDLead && isDDLeadStage && !hasUploadedPPT);
!(isDDLead && isDDLeadStage && !hasUploadedPPT) &&
!(currentStage === 'DD Admin' && !isLwdReached);
const isLwdReached = (() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const lwdString = resignationData.lastOperationalDateServices || resignationData.lastOperationalDateSales;
if (!lwdString) return true;
const lwd = new Date(lwdString);
lwd.setHours(0, 0, 0, 0);
return today >= lwd;
})();
return {
canApprove,
@ -224,7 +237,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
canWithdraw: userRole === 'Dealer' && !isPastNBH && !isFinalState,
canRevoke: (userRoleCode === 'SUPER_ADMIN' || userRole === 'DD Admin') && !isFinalState && !isSettlementPhase,
canPushToFnF: ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin'].includes(userRole) &&
!isSettlementPhase && !isFinalState,
!isSettlementPhase && !isFinalState && currentStage === 'Legal' && isLwdReached,
canAssign: userRole !== 'Dealer' && !isFinalState
};
};
@ -1020,29 +1033,29 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
)}
</CardHeader>
<CardContent>
<Table>
<Table className="w-full border-collapse">
<TableHeader>
<TableRow>
<TableHead>Stage</TableHead>
<TableHead>Approver</TableHead>
<TableHead>Action</TableHead>
<TableHead>Remarks</TableHead>
<TableHead>Date</TableHead>
<TableRow className="bg-slate-50/50">
<TableHead className="min-w-[120px]">Stage</TableHead>
<TableHead className="min-w-[120px]">Approver</TableHead>
<TableHead className="min-w-[200px]">Action</TableHead>
<TableHead className="w-full min-w-[300px]">Remarks</TableHead>
<TableHead className="min-w-[180px] text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(resignationData?.timeline || []).length > 0 ? (
resignationData.timeline.map((entry: any, index: number) => (
<TableRow key={index}>
<TableCell className="font-medium whitespace-nowrap">{entry.stage}</TableCell>
<TableCell className="font-medium">{entry.stage}</TableCell>
<TableCell>
<Badge variant="outline">{entry.user || 'System'}</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">{entry.action}</TableCell>
<TableCell className="max-w-[400px]">
<TableCell className="whitespace-normal break-words">{entry.action}</TableCell>
<TableCell className="whitespace-normal break-words">
{entry.remarks || entry.comments || '-'}
</TableCell>
<TableCell className="text-slate-500 whitespace-nowrap">
<TableCell className="text-slate-500 whitespace-nowrap text-right">
{formatDateTime(entry.timestamp || entry.createdAt)}
</TableCell>
</TableRow>

View File

@ -1,4 +1,4 @@
import { ArrowLeft, Check, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, AlertTriangle, Send, ShieldCheck, Loader2, Upload } from 'lucide-react';
import { ArrowLeft, Check, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, AlertTriangle, Send, ShieldCheck, Loader2, Upload, PauseCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@ -16,6 +16,11 @@ import { terminationService } from '@/services/termination.service';
import { useNavigate } from 'react-router-dom';
import { API } from '@/api/API';
import { formatDateTime } from '@/components/ui/utils';
import { formatTerminationStatusLabel } from '@/lib/terminationDisplay';
import {
getJointRoundCutoffMsFromTimeline,
isAuditLogInCurrentJointRound
} from '@/lib/terminationJointReviewRound';
import { TERMINATION_DOCUMENT_TYPES, TERMINATION_STAGE_OPTIONS } from '@/lib/offboardingDocumentOptions';
import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
import { WIDE_DIALOG_CLASS } from '@/lib/dialogStyles';
@ -27,7 +32,7 @@ interface TerminationDetailsProps {
export function TerminationDetails({ terminationId, onBack, currentUser }: TerminationDetailsProps) {
const navigate = useNavigate();
const [actionDialog, setActionDialog] = useState<{ open: boolean; type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | null }>({ open: false, type: null });
const [actionDialog, setActionDialog] = useState<{ open: boolean; type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | 'hold' | null }>({ open: false, type: null });
const [remarks, setRemarks] = useState('');
const [assignToUser, setAssignToUser] = useState('');
const [stageDocumentsDialog, setStageDocumentsDialog] = useState<{ open: boolean; stageName: string; documents: any[] }>({ open: false, stageName: '', documents: [] });
@ -155,6 +160,40 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
// Check if user can push to F&F (DD Lead and above)
const canPushToFnF = currentUser && ['DD Lead', 'DD Head', 'DD_HEAD', 'NBH', 'DD Admin', 'Super Admin'].includes(currentUser.role || currentUser.roleCode);
const stageAliases: Record<string, string[]> = {
'Submitted': ['Submitted'],
'RBM + DD-ZM Review': ['RBM + DD-ZM Review'],
'ZBH Review': ['ZBH Review'],
'DD Lead Review': ['DD Lead Review'],
'Legal Verification': ['Legal Verification'],
'DD Head Review': ['DD Head Review'],
'NBH Evaluation': ['NBH Evaluation'],
'Show Cause Notice (SCN)': ['Show Cause Notice', 'Show Cause Notice (SCN)', 'SCN'],
'Evaluation of Dealer SCN Response': ['Evaluation of Dealer SCN Response', '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 stageSequence = [
'Submitted',
'RBM + DD-ZM Review',
'ZBH Review',
'DD Lead Review',
'Legal Verification',
'DD Head Review',
'NBH Evaluation',
'Show Cause Notice (SCN)',
'Evaluation of Dealer SCN Response',
'NBH Final Approval',
'CCO Approval',
'CEO Final Approval',
'Legal - Termination Letter',
'Dealer Terminated'
];
// Centralized Permissions Utility for Termination logic (Robust Validation)
const getTerminationPermissions = () => {
if (!terminationData || !currentUser) {
@ -164,54 +203,67 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
const currentStage = terminationData.currentStage;
const status = terminationData.status;
const userRole = currentUser.role || currentUser.roleCode;
const isScnStage = ['Show Cause Notice', 'Show Cause Notice (SCN)', 'SCN'].includes(currentStage);
const isScnStage = ['Show Cause Notice (SCN)', 'SCN'].includes(currentStage);
const isFinalState = ['Completed', 'Rejected', 'Withdrawn', 'Terminated'].includes(status) || currentStage === 'Terminated';
const isSettlementPhase = status === 'F&F Initiated' || currentStage === 'F&F Initiated' || status === 'Settled' || status === 'FNF_INITIATED';
const scnJointRoundCutoffMs = getJointRoundCutoffMsFromTimeline(terminationData.timeline, 'scn_response_eval');
const rbmJointRoundCutoffMs = getJointRoundCutoffMsFromTimeline(terminationData.timeline, 'rbm_review');
const isScnResponseEvalStage =
currentStage === 'Evaluation of Dealer SCN Response' || currentStage === 'Personal Hearing';
const jointRoundCutoffMs =
currentStage === 'RBM + DD-ZM Review' ? rbmJointRoundCutoffMs : isScnResponseEvalStage ? scnJointRoundCutoffMs : null;
const userHasApprovedJointly = auditLogs.some(log => {
if (!isAuditLogInCurrentJointRound(log, jointRoundCutoffMs)) return false;
const logUserId = log.userId || log.user?.id || log.actor?.id || log.actorId;
const isThisUser = String(logUserId) === String(currentUser.id);
const actionText = (log.action || log.description || '').toUpperCase();
const isPartialApprove = actionText.includes('PARTIAL_APPROVE') || actionText.includes('PARTIAL APPROVED');
const logStage = log.details?.stage || log.stage || '';
const isRbmReviewLog = logStage === 'RBM + DD-ZM Review' || (log.remarks || '').includes('Partial approval by');
const isPersonalHearingLog =
logStage === 'Evaluation of Dealer SCN Response' ||
logStage === 'Personal Hearing' ||
(log.remarks || '').includes('SCN Response Review by');
const stageMatches =
log.details?.stage === 'RBM + DD-ZM Review' ||
log.stage === 'RBM + DD-ZM Review' ||
(log.remarks || '').includes('Partial approval by');
(currentStage === 'RBM + DD-ZM Review' && isRbmReviewLog) ||
(isScnResponseEvalStage && isPersonalHearingLog);
const result = isThisUser && isPartialApprove && stageMatches;
if (result) console.log('[TerminationDebug] Found matching partial approval log:', log);
return result;
return isThisUser && isPartialApprove && stageMatches;
});
if (currentStage === 'RBM + DD-ZM Review' && (userRole === 'RBM' || userRole === 'DD-ZM')) {
console.log('[TerminationDebug] Joint Stage Detection:', {
currentStage,
userRole,
userId: currentUser.id,
userHasApprovedJointly,
auditLogsCount: auditLogs.length
});
}
const isStage = (stageName: string) => {
const aliases = stageAliases[stageName] || [stageName];
return aliases.includes(currentStage);
};
const isCurrentlyAssigned = userRole === 'Super Admin' || userRole === 'DD Admin' || (
(currentStage === 'RBM + DD-ZM Review' && (userRole === 'RBM' || userRole === 'DD-ZM') && !userHasApprovedJointly) ||
(currentStage === 'ZBH Review' && userRole === 'ZBH') ||
(currentStage === 'DD Lead Review' && userRole === 'DD Lead') ||
(currentStage === 'Legal Verification' && userRole === 'Legal Admin') ||
(currentStage === 'DD Head Review' && (userRole === 'DD Head' || userRole === 'DD_HEAD')) ||
(currentStage === 'NBH Evaluation' && userRole === 'NBH') ||
(currentStage === 'NBH Final Approval' && userRole === 'NBH') ||
(currentStage === 'CCO Approval' && userRole === 'CCO') ||
(currentStage === 'CEO Final Approval' && userRole === 'CEO') ||
(currentStage === 'Legal - Termination Letter' && userRole === 'Legal Admin')
(isStage('RBM + DD-ZM Review') && (userRole === 'RBM' || userRole === 'DD-ZM') && !userHasApprovedJointly) ||
(isStage('ZBH Review') && userRole === 'ZBH') ||
(isStage('DD Lead Review') && userRole === 'DD Lead') ||
(isStage('Legal Verification') && userRole === 'Legal Admin') ||
(isStage('DD Head Review') && (userRole === 'DD Head' || userRole === 'DD_HEAD')) ||
(isStage('NBH Evaluation') && userRole === 'NBH') ||
(isStage('Evaluation of Dealer SCN Response') && ['DD Lead', 'ZBH', 'RBM', 'DD Head', 'DD_HEAD'].includes(userRole) && !userHasApprovedJointly) ||
(isStage('NBH Final Approval') && userRole === 'NBH') ||
(isStage('CCO Approval') && userRole === 'CCO') ||
(isStage('CEO Final Approval') && userRole === 'CEO') ||
(isStage('Legal - Termination Letter') && userRole === 'Legal Admin')
);
return {
canApprove: isCurrentlyAssigned && !isFinalState && !isSettlementPhase && ![...['NBH Final Approval', 'CCO Approval', 'CEO Final Approval'], 'Show Cause Notice', 'Show Cause Notice (SCN)', 'SCN'].includes(currentStage),
canApprove: isCurrentlyAssigned && !isFinalState && !isSettlementPhase && ![...['NBH Final Approval', 'CCO Approval', 'CEO Final Approval'], 'Show Cause Notice (SCN)', 'SCN'].includes(currentStage),
canIssueSCN: currentStage === 'NBH Evaluation' && (userRole === 'NBH' || userRole === 'Super Admin') && !isFinalState,
canUploadSCNResponse: isScnStage && (['Legal Admin', 'DD Admin', 'Super Admin'].includes(userRole)) && !isFinalState,
canUploadSCNResponse: isScnStage && (['Legal Admin', 'DD Admin', 'DD Lead', 'Super Admin'].includes(userRole)) && !isFinalState,
canHold:
(isStage('NBH Evaluation') || isStage('NBH Final Approval')) &&
(userRole === 'NBH' || userRole === 'Super Admin') &&
status !== 'On Hold' &&
!isFinalState,
canFinalize: (
(currentStage === 'NBH Final Approval' && userRole === 'NBH') ||
(currentStage === 'CCO Approval' && userRole === 'CCO') ||
@ -231,39 +283,6 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
const request = terminationData || {};
const isScnStage = ['Show Cause Notice', 'Show Cause Notice (SCN)', 'SCN'].includes(request.currentStage);
const stageAliases: Record<string, string[]> = {
'Submitted': ['Submitted'],
'RBM + DD-ZM Review': ['RBM + DD-ZM Review'],
'ZBH Review': ['ZBH Review'],
'DD Lead Review': ['DD Lead Review'],
'Legal Verification': ['Legal Verification'],
'DD Head Review': ['DD Head Review'],
'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 stageSequence = [
'Submitted',
'RBM + DD-ZM Review',
'ZBH Review',
'DD Lead Review',
'Legal Verification',
'DD Head Review',
'NBH Evaluation',
'Show Cause Notice (SCN)',
'Personal Hearing',
'NBH Final Approval',
'CCO Approval',
'CEO Final Approval',
'Legal - Termination Letter',
'Dealer Terminated'
];
const resolveCanonicalStage = (currentStage?: string) => {
if (!currentStage) return '';
@ -396,9 +415,9 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
},
{
id: 9,
name: 'Personal Hearing',
status: getProgressStatus('Personal Hearing'),
description: 'Evaluation of SCN response & Hearing'
name: 'Evaluation of Dealer SCN Response',
status: getProgressStatus('Evaluation of Dealer SCN Response'),
description: 'Joint evaluation of SCN response by DD-Lead, ZBH, RBM, and DD-Head'
},
{
id: 10,
@ -442,7 +461,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
setStageDocumentsDialog({ open: true, stageName, documents });
};
const handleAction = (type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke') => {
const handleAction = (type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | 'hold') => {
setActionDialog({ open: true, type });
};
@ -471,7 +490,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
setIsProcessing(true);
try {
let response: any;
if (actionType === 'approve' || actionType === 'sendBack' || actionType === 'withdrawal' || actionType === 'revoke') {
if (actionType === 'approve' || actionType === 'sendBack' || actionType === 'withdrawal' || actionType === 'revoke' || actionType === 'hold') {
response = await terminationService.updateTerminationStatus(terminationId, effectiveAction, remarks);
} else if (actionType === 'pushfnf') {
response = await terminationService.updateTerminationStatus(terminationId, 'pushfnf', remarks);
@ -556,7 +575,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
? 'bg-red-100 text-red-700 border-red-300'
: 'bg-yellow-100 text-yellow-700 border-yellow-300'
}>
{request.status === 'Settled' ? 'Completed' : (request.status || 'Pending')}
{request.status === 'Settled' ? 'Completed' : formatTerminationStatusLabel(request.status || 'Pending')}
</Badge>
</div>
</div>
@ -565,6 +584,15 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<Card className="border-amber-200 shadow-sm">
<CardContent className="pt-6">
<div className="flex flex-col gap-4">
{(request.currentStage === 'Evaluation of Dealer SCN Response' || request.currentStage === 'Personal Hearing') && (
<Alert className="mb-4 bg-blue-50 border-blue-200">
<AlertTitle className="text-blue-800 text-sm font-semibold">Joint Review Stage</AlertTitle>
<AlertDescription className="text-blue-700 text-xs">
This stage requires a joint evaluation of the SCN response by the <strong>DD-Lead, ZBH, RBM, and DD-Head</strong>.
The case will only advance to NBH Final Approval once all four stakeholders have recorded their review.
</AlertDescription>
</Alert>
)}
{/* Primary Actions Row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@ -619,6 +647,17 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
)}
</>
)}
{permissions.canHold && (
<Button
size="sm"
variant="outline"
className="border-orange-200 text-orange-700 hover:bg-orange-50"
onClick={() => handleAction('hold')}
>
<PauseCircle className="w-4 h-4 mr-2" />
Hold Decision
</Button>
)}
{permissions.canFinalize && (
<Button
size="sm"
@ -945,7 +984,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
{entry.action || 'Action'}
</Badge>
<span className="text-[10px] text-slate-500 font-medium">
by {entry.user || 'System'} {formatDateTime(entry.timestamp)}
by {entry.user || 'System'}{entry.role ? ` (${entry.role})` : ''} {formatDateTime(entry.timestamp)}
</span>
</div>
<div className={`
@ -1123,6 +1162,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
{actionDialog.type === 'revoke' && 'Revoke Termination Request'}
{actionDialog.type === 'assign' && 'Assign to User'}
{actionDialog.type === 'pushfnf' && 'Push to Full & Final Settlement'}
{actionDialog.type === 'hold' && 'Hold Termination Case'}
</DialogTitle>
<DialogDescription>
{actionDialog.type === 'assign'
@ -1185,6 +1225,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
className={
actionDialog.type === 'approve' ? 'bg-green-600 hover:bg-green-700' :
actionDialog.type === 'withdrawal' ? 'bg-red-600 hover:bg-red-700' :
actionDialog.type === 'hold' ? 'bg-orange-600 hover:bg-orange-700' :
'bg-blue-600 hover:bg-blue-700'
}
>

View File

@ -23,6 +23,7 @@ import {
} from "@/components/ui/pagination";
import { User } from '@/lib/mock-data';
import { toast } from 'sonner';
import { formatTerminationStatusLabel } from '@/lib/terminationDisplay';
interface TerminationPageProps {
currentUser: User | null;
@ -51,6 +52,8 @@ const getStatusColor = (status: string) => {
return 'bg-blue-100 text-blue-700 border-blue-300';
};
const formatStatus = formatTerminationStatusLabel;
export function TerminationPage({ currentUser, onViewDetails }: TerminationPageProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [dealers, setDealers] = useState<any[]>([]);
@ -103,7 +106,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
};
useEffect(() => {
if (!isDialogOpen || !isDDLead) return;
if (!isDialogOpen || !canCreateTermination) return;
let cancelled = false;
(async () => {
@ -254,7 +257,8 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
}
};
const isDDLead = currentUser?.role === 'DD Lead';
const allowedRoles = ['DD Lead', 'ASM', 'DD Admin', 'DD AM', 'Super Admin'];
const canCreateTermination = currentUser?.role && allowedRoles.includes(currentUser.role);
// Map terminations to tab-specific views (already filtered by backend, but need variables for render)
const openRequests = activeTab === 'open' || activeTab === 'all' ? terminations : [];
@ -312,14 +316,9 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<CardTitle>Termination Requests</CardTitle>
<CardDescription>
Manage dealer termination proceedings and legal compliance
{!isDDLead && (
<span className="block mt-1 text-red-600">
Note: Only DD Lead can create termination requests. Current role: {currentUser?.role || 'Not logged in'}
</span>
)}
</CardDescription>
</div>
{isDDLead && (
{canCreateTermination && (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-red-600 hover:bg-red-700">
@ -408,7 +407,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<SelectContent>
<SelectItem value="Working Capital">Working Capital</SelectItem>
<SelectItem value="Performance Issues">Performance Issues</SelectItem>
<SelectItem value="Unethical Practical">Unethical Practical</SelectItem>
<SelectItem value="Unethical Practice">Unethical Practice</SelectItem>
<SelectItem value="Unforeseen Circumstances">Unforeseen Circumstances</SelectItem>
<SelectItem value="Others">Others</SelectItem>
</SelectContent>
@ -495,12 +494,12 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold">{request.requestId || (request.dealer?.dealerCode?.code || 'N/A')}</h3>
<span className="text-slate-400 text-xs">#{request.id.substring(0, 8)}</span>
<Badge className={getSeverityColor(request.severity || 'Medium')}>
{request.severity || 'Normal'}
</Badge>
<Badge className={getStatusColor(request.status)}>
{request.status}
{formatStatus(request.status)}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
@ -518,7 +517,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
</div>
<div>
<p className="text-slate-600">Current Stage</p>
<p>{request.currentStage}</p>
<p>{formatStatus(request.currentStage)}</p>
</div>
<div>
<p className="text-slate-600">Proposed LWD</p>
@ -570,9 +569,9 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold">{request.requestId || (request.dealer?.dealerCode?.code || 'N/A')}</h3>
<span className="text-slate-400 text-xs">#{request.id.substring(0, 8)}</span>
<Badge className={getStatusColor(request.status)}>
{request.status}
{formatStatus(request.status)}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
@ -586,7 +585,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
</div>
<div>
<p className="text-slate-600">Current Stage</p>
<p>{request.currentStage}</p>
<p>{formatStatus(request.currentStage)}</p>
</div>
<div>
<p className="text-slate-600">Submitted On</p>
@ -632,9 +631,9 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold">{request.requestId || (request.dealer?.dealerCode?.code || 'N/A')}</h3>
<span className="text-slate-400 text-xs">#{request.id.substring(0, 8)}</span>
<Badge className={getStatusColor(request.status)}>
{request.status}
{formatStatus(request.status)}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">

View File

@ -2,7 +2,7 @@ export const RESIGNATION_DOCUMENT_TYPES = [
"Resignation Letter",
"Dealer Undertaking",
"Approval Note",
"Legal Communication",
"Resignation Acceptance Letter",
"Handover Document",
"Settlement Supporting Document",
"PPT Presentation",
@ -10,6 +10,7 @@ export const RESIGNATION_DOCUMENT_TYPES = [
] as const;
export const RESIGNATION_STAGE_OPTIONS = [
"Request Submitted",
"ASM",
"RBM",
"ZBH",

View File

@ -0,0 +1,5 @@
/** Legacy workflow used "Personal Hearing"; UI and newer APIs use "SCN Response Evaluation" wording. */
export function formatTerminationStatusLabel(value: string | null | undefined): string {
if (!value) return 'Pending';
return value.replace(/Personal Hearing/gi, 'SCN Response Evaluation');
}

View File

@ -0,0 +1,64 @@
/** Mirrors backend terminationJointReviewRound.util.ts — keep send-back / reconsider detection aligned. */
const norm = (s: string | undefined | null) =>
String(s || '')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
const SCN_CANONICAL = 'Evaluation of Dealer SCN Response';
export const isScnResponseJointTargetStage = (targetStage: string | undefined | null): boolean => {
const n = norm(targetStage);
if (!n) return false;
if (n === norm(SCN_CANONICAL)) return true;
if (n.includes('evaluation') && n.includes('scn') && n.includes('response')) return true;
if (n.includes('personal hearing')) return true;
return false;
};
export const isRbmJointTargetStage = (targetStage: string | undefined | null): boolean => {
const n = norm(targetStage);
return n.includes('rbm') && (n.includes('dd-zm') || n.includes('dd zm'));
};
function isSendBackOrReconsiderTimelineAction(action: string | undefined | null): boolean {
const a = norm(action);
return (
a.includes('sent back') ||
a.includes('send back') ||
a.includes('reconsider') ||
a.includes('reconsideration')
);
}
export type JointRoundTimelineMode = 'scn_response_eval' | 'rbm_review';
export function getJointRoundCutoffMsFromTimeline(
timeline: unknown,
mode: JointRoundTimelineMode
): number | null {
if (!Array.isArray(timeline) || timeline.length === 0) return null;
const matcher = mode === 'scn_response_eval' ? isScnResponseJointTargetStage : isRbmJointTargetStage;
const arr = timeline as Record<string, unknown>[];
for (let i = arr.length - 1; i >= 0; i--) {
const e = arr[i];
if (!isSendBackOrReconsiderTimelineAction(e?.action as string)) continue;
if (!matcher(e?.targetStage as string)) continue;
const t = e?.timestamp != null ? new Date(e.timestamp as string | number | Date).getTime() : NaN;
if (!Number.isNaN(t)) return t;
}
return null;
}
export function auditLogTimestampMs(log: { createdAt?: string | Date; timestamp?: string | Date }): number {
const raw = log.createdAt ?? log.timestamp;
if (raw == null) return 0;
const t = new Date(raw).getTime();
return Number.isNaN(t) ? 0 : t;
}
export function isAuditLogInCurrentJointRound(log: { createdAt?: string | Date; timestamp?: string | Date }, cutoffMs: number | null): boolean {
if (cutoffMs == null) return true;
return auditLogTimestampMs(log) >= cutoffMs;
}

View File

@ -115,6 +115,11 @@ export const onboardingService = {
if (!response.ok) throw new Error(response.data?.message || 'Failed to perform bulk conversion');
return response.data;
},
sendBulkReminders: async (applicationIds: string[]) => {
const response: any = await API.sendBulkReminders({ applicationIds });
if (!response.ok) throw new Error(response.data?.message || 'Failed to send reminders');
return response.data;
},
createDealer: async (data: any) => {
const response: any = await API.createDealer(data);
if (!response.ok) throw new Error(response.data?.message || 'Failed to create dealer profile');