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), exportApplicationResponses: (params: { applicationIds: string }) => client.get('/onboarding/applications/export-responses', params),
getApplications: (params?: any) => client.get('/onboarding/applications', params), getApplications: (params?: any) => client.get('/onboarding/applications', params),
shortlistApplications: (data: any) => client.post('/onboarding/applications/shortlist', data), 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}`), getApplicationById: (id: string) => client.get(`/onboarding/applications/${id}`),
updateApplication: (id: string, data: any) => client.put(`/onboarding/applications/${id}`, data), updateApplication: (id: string, data: any) => client.put(`/onboarding/applications/${id}`, data),
getLatestQuestionnaire: () => client.get('/questionnaire/latest'), getLatestQuestionnaire: () => client.get('/questionnaire/latest'),

View File

@ -26,7 +26,6 @@ import {
Download, Download,
Grid3x3, Grid3x3,
List, List,
Mail,
CheckCircle, CheckCircle,
AlertCircle, AlertCircle,
Loader2 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 // For DD's All Applications page, only show initial statuses
const statusOptions: ApplicationStatus[] = [ const statusOptions: ApplicationStatus[] = [
@ -356,16 +352,6 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
{selectedIds.length > 0 && ( {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 <Button
size="sm" size="sm"
onClick={handleShortlist} onClick={handleShortlist}

View File

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

View File

@ -11,6 +11,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } 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 { import {
Search, Search,
Download, Download,
@ -182,9 +190,12 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
setApplicationsData(mappedApps); setApplicationsData(mappedApps);
// Extract unique locations // Extract unique locations and states from the returned data
const uniqueLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean); const newLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean) as string[];
setLocations(uniqueLocations); 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) { } catch (error) {
console.error('Failed to fetch applications:', error); console.error('Failed to fetch applications:', error);
toast.error('Failed to load non-opportunity requests'); toast.error('Failed to load non-opportunity requests');
@ -247,29 +258,55 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative w-full md:w-36"> <Popover>
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" /> <PopoverTrigger asChild>
<Input <Button
type="date" variant="outline"
value={fromDate} className={cn(
onChange={(e) => setFromDate(e.target.value)} "w-full md:w-36 justify-start text-left font-normal h-10 px-3",
className="pl-10 text-xs" !fromDate && "text-muted-foreground"
placeholder="From" )}
data-testid="onboarding-non-opps-from-date" 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> <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" /> <Popover>
<Input <PopoverTrigger asChild>
type="date" <Button
value={toDate} variant="outline"
onChange={(e) => setToDate(e.target.value)} className={cn(
className="pl-10 text-xs" "w-full md:w-36 justify-start text-left font-normal h-10 px-3",
placeholder="To" !toDate && "text-muted-foreground"
data-testid="onboarding-non-opps-to-date" )}
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> </div>
<Select value={locationFilter} onValueChange={setLocationFilter}> <Select value={locationFilter} onValueChange={setLocationFilter}>
@ -284,6 +321,22 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
</SelectContent> </SelectContent>
</Select> </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}> <Select value={stateFilter} onValueChange={setStateFilter}>
<SelectTrigger className="w-full lg:w-48" data-testid="onboarding-non-opps-state-select"> <SelectTrigger className="w-full lg:w-48" data-testid="onboarding-non-opps-state-select">
<SelectValue placeholder="All States" /> <SelectValue placeholder="All States" />

View File

@ -11,6 +11,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } 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 { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
@ -67,6 +75,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [paginationMeta, setPaginationMeta] = useState<any>(null); const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isSendingReminders, setIsSendingReminders] = useState(false);
const [showShortlistModal, setShowShortlistModal] = useState(false); const [showShortlistModal, setShowShortlistModal] = useState(false);
const [shortlistRemark, setShortlistRemark] = useState(''); const [shortlistRemark, setShortlistRemark] = useState('');
@ -160,9 +169,13 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
setApplicationsData(mappedApps); setApplicationsData(mappedApps);
// Extract unique locations for filtering // Extract unique locations and states from the returned data
const uniqueLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean); // Note: This appends new ones to the existing list to ensure all found locations are selectable
setLocations(uniqueLocations); 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) { } catch (error) {
console.error('Failed to fetch applications:', error); console.error('Failed to fetch applications:', error);
toast.error('Failed to load opportunity requests'); 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) { if (selectedIds.length === 0) {
toast.error('Please select at least one application'); toast.error('Please select at least one application');
return; 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 () => { const handleExport = async () => {
@ -380,20 +406,6 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
return ( return (
<div className="space-y-6"> <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 */} {/* Header with Filters */}
<div className="bg-white rounded-lg border border-slate-200 p-6"> <div className="bg-white rounded-lg border border-slate-200 p-6">
@ -424,6 +436,23 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
</SelectContent> </SelectContent>
</Select> </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}> <Select value={stateFilter} onValueChange={setStateFilter}>
<SelectTrigger className="w-full md:w-48" data-testid="onboarding-opp-requests-state-select"> <SelectTrigger className="w-full md:w-48" data-testid="onboarding-opp-requests-state-select">
<SelectValue placeholder="Filter by state" /> <SelectValue placeholder="Filter by state" />
@ -449,29 +478,55 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
</Select> </Select>
<div className="flex items-center gap-2 flex-1 md:flex-none"> <div className="flex items-center gap-2 flex-1 md:flex-none">
<div className="relative w-full md:w-40"> <Popover>
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" /> <PopoverTrigger asChild>
<Input <Button
type="date" variant="outline"
value={fromDate} className={cn(
onChange={(e) => setFromDate(e.target.value)} "w-full md:w-40 justify-start text-left font-normal h-9 px-3",
className="pl-10 text-xs" !fromDate && "text-muted-foreground"
placeholder="From" )}
data-testid="onboarding-opp-requests-from-date" 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> <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" /> <Popover>
<Input <PopoverTrigger asChild>
type="date" <Button
value={toDate} variant="outline"
onChange={(e) => setToDate(e.target.value)} className={cn(
className="pl-10 text-xs" "w-full md:w-40 justify-start text-left font-normal h-9 px-3",
placeholder="To" !toDate && "text-muted-foreground"
data-testid="onboarding-opp-requests-to-date" )}
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> </div>
<Select value={sortBy} onValueChange={setSortBy}> <Select value={sortBy} onValueChange={setSortBy}>
@ -526,9 +581,14 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleBulkReminders} onClick={handleBulkReminders}
disabled={isSendingReminders}
data-testid="onboarding-opp-requests-bulk-reminder-btn" 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" /> <Mail className="w-4 h-4 mr-2" />
)}
Send Reminders ({selectedIds.length}) Send Reminders ({selectedIds.length})
</Button> </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 TERMINAL_STAGE_LABELS = ['REJECTED', 'Rejected', 'REVOKED', 'Revoked', 'WITHDRAWN', 'Withdrawn'];
const RESIGNATION_STAGE_ALIASES: Record<string, string[]> = { 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'], 'RBM': ['RBM', 'RBM Review', 'Regional Review', 'RBM + DD-ZM Review'],
'ZBH': ['ZBH', 'ZBH Review', 'ZM Review'], 'ZBH': ['ZBH', 'ZBH Review', 'ZM Review'],
'DD Lead': ['DD Lead', 'DD Lead Review', 'DDL 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 // Progress stages logic based on live data
const progressStages = [ const progressStages = [
{ id: 1, name: 'ASM Review', key: 'ASM', description: 'Area Sales Manager review' }, { id: 1, name: 'Request Submitted', key: 'Request Submitted', description: 'Dealer submitted the resignation request' },
{ id: 2, name: 'RBM + DD-ZM Review', key: 'RBM', description: 'Joint approval by Regional Business Manager and DD-ZM' }, { id: 2, name: 'ASM Review', key: 'ASM', description: 'Area Sales Manager review' },
{ id: 3, name: 'ZBH Review', key: 'ZBH', description: 'Zonal Business Head approval' }, { id: 3, name: 'RBM + DD-ZM Review', key: 'RBM', description: 'Joint approval by Regional Business Manager and DD-ZM' },
{ id: 4, name: 'DD Lead Review', key: 'DD Lead', description: 'DD Lead final review' }, { id: 4, name: 'ZBH Review', key: 'ZBH', description: 'Zonal Business Head approval' },
{ id: 5, name: 'NBH Approval', key: 'NBH', description: 'National Business Head approval' }, { id: 5, name: 'DD Lead Review', key: 'DD Lead', description: 'DD Lead final review' },
{ id: 6, name: 'Legal - Resignation Letter', key: 'Legal', description: 'Legal team issues resignation approval letter' }, { id: 6, name: 'NBH Approval', key: 'NBH', description: 'National Business Head approval' },
{ id: 7, name: 'DD Admin Review', key: 'DD Admin', description: 'DD Admin verification and final closure' }, { id: 7, name: 'Legal - Resignation Letter', key: 'Legal', description: 'Legal team issues resignation approval letter' },
{ id: 8, name: 'F&F Settlement', key: 'F&F Initiated', description: 'Full & Final settlement process' }, { id: 8, name: 'DD Admin Review', key: 'DD Admin', description: 'DD Admin verification and final closure' },
{ id: 9, name: 'Completed', key: 'Completed', description: 'Resignation process finalized' } { 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 = (() => { const legalStageApproved = (() => {
if (!resignationData) return false; if (!resignationData) return false;
@ -216,7 +218,18 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
!isSettlementPhase && !isSettlementPhase &&
!hasAlreadyPartiallyApproved && !hasAlreadyPartiallyApproved &&
!(currentStage === 'Legal' && legalStageApproved) && !(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 { return {
canApprove, canApprove,
@ -224,7 +237,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
canWithdraw: userRole === 'Dealer' && !isPastNBH && !isFinalState, canWithdraw: userRole === 'Dealer' && !isPastNBH && !isFinalState,
canRevoke: (userRoleCode === 'SUPER_ADMIN' || userRole === 'DD Admin') && !isFinalState && !isSettlementPhase, canRevoke: (userRoleCode === 'SUPER_ADMIN' || userRole === 'DD Admin') && !isFinalState && !isSettlementPhase,
canPushToFnF: ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin'].includes(userRole) && canPushToFnF: ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin'].includes(userRole) &&
!isSettlementPhase && !isFinalState, !isSettlementPhase && !isFinalState && currentStage === 'Legal' && isLwdReached,
canAssign: userRole !== 'Dealer' && !isFinalState canAssign: userRole !== 'Dealer' && !isFinalState
}; };
}; };
@ -1020,29 +1033,29 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
)} )}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table className="w-full border-collapse">
<TableHeader> <TableHeader>
<TableRow> <TableRow className="bg-slate-50/50">
<TableHead>Stage</TableHead> <TableHead className="min-w-[120px]">Stage</TableHead>
<TableHead>Approver</TableHead> <TableHead className="min-w-[120px]">Approver</TableHead>
<TableHead>Action</TableHead> <TableHead className="min-w-[200px]">Action</TableHead>
<TableHead>Remarks</TableHead> <TableHead className="w-full min-w-[300px]">Remarks</TableHead>
<TableHead>Date</TableHead> <TableHead className="min-w-[180px] text-right">Date</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{(resignationData?.timeline || []).length > 0 ? ( {(resignationData?.timeline || []).length > 0 ? (
resignationData.timeline.map((entry: any, index: number) => ( resignationData.timeline.map((entry: any, index: number) => (
<TableRow key={index}> <TableRow key={index}>
<TableCell className="font-medium whitespace-nowrap">{entry.stage}</TableCell> <TableCell className="font-medium">{entry.stage}</TableCell>
<TableCell> <TableCell>
<Badge variant="outline">{entry.user || 'System'}</Badge> <Badge variant="outline">{entry.user || 'System'}</Badge>
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap">{entry.action}</TableCell> <TableCell className="whitespace-normal break-words">{entry.action}</TableCell>
<TableCell className="max-w-[400px]"> <TableCell className="whitespace-normal break-words">
{entry.remarks || entry.comments || '-'} {entry.remarks || entry.comments || '-'}
</TableCell> </TableCell>
<TableCell className="text-slate-500 whitespace-nowrap"> <TableCell className="text-slate-500 whitespace-nowrap text-right">
{formatDateTime(entry.timestamp || entry.createdAt)} {formatDateTime(entry.timestamp || entry.createdAt)}
</TableCell> </TableCell>
</TableRow> </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 { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; 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 { useNavigate } from 'react-router-dom';
import { API } from '@/api/API'; import { API } from '@/api/API';
import { formatDateTime } from '@/components/ui/utils'; 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 { TERMINATION_DOCUMENT_TYPES, TERMINATION_STAGE_OPTIONS } from '@/lib/offboardingDocumentOptions';
import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal'; import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
import { WIDE_DIALOG_CLASS } from '@/lib/dialogStyles'; import { WIDE_DIALOG_CLASS } from '@/lib/dialogStyles';
@ -27,7 +32,7 @@ interface TerminationDetailsProps {
export function TerminationDetails({ terminationId, onBack, currentUser }: TerminationDetailsProps) { export function TerminationDetails({ terminationId, onBack, currentUser }: TerminationDetailsProps) {
const navigate = useNavigate(); 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 [remarks, setRemarks] = useState('');
const [assignToUser, setAssignToUser] = useState(''); const [assignToUser, setAssignToUser] = useState('');
const [stageDocumentsDialog, setStageDocumentsDialog] = useState<{ open: boolean; stageName: string; documents: any[] }>({ open: false, stageName: '', documents: [] }); 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) // 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 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) // Centralized Permissions Utility for Termination logic (Robust Validation)
const getTerminationPermissions = () => { const getTerminationPermissions = () => {
if (!terminationData || !currentUser) { if (!terminationData || !currentUser) {
@ -164,54 +203,67 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
const currentStage = terminationData.currentStage; const currentStage = terminationData.currentStage;
const status = terminationData.status; const status = terminationData.status;
const userRole = currentUser.role || currentUser.roleCode; 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 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 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 => { const userHasApprovedJointly = auditLogs.some(log => {
if (!isAuditLogInCurrentJointRound(log, jointRoundCutoffMs)) return false;
const logUserId = log.userId || log.user?.id || log.actor?.id || log.actorId; const logUserId = log.userId || log.user?.id || log.actor?.id || log.actorId;
const isThisUser = String(logUserId) === String(currentUser.id); const isThisUser = String(logUserId) === String(currentUser.id);
const actionText = (log.action || log.description || '').toUpperCase(); const actionText = (log.action || log.description || '').toUpperCase();
const isPartialApprove = actionText.includes('PARTIAL_APPROVE') || actionText.includes('PARTIAL APPROVED'); 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 = const stageMatches =
log.details?.stage === 'RBM + DD-ZM Review' || (currentStage === 'RBM + DD-ZM Review' && isRbmReviewLog) ||
log.stage === 'RBM + DD-ZM Review' || (isScnResponseEvalStage && isPersonalHearingLog);
(log.remarks || '').includes('Partial approval by');
const result = isThisUser && isPartialApprove && stageMatches; return isThisUser && isPartialApprove && stageMatches;
if (result) console.log('[TerminationDebug] Found matching partial approval log:', log);
return result;
}); });
if (currentStage === 'RBM + DD-ZM Review' && (userRole === 'RBM' || userRole === 'DD-ZM')) { const isStage = (stageName: string) => {
console.log('[TerminationDebug] Joint Stage Detection:', { const aliases = stageAliases[stageName] || [stageName];
currentStage, return aliases.includes(currentStage);
userRole, };
userId: currentUser.id,
userHasApprovedJointly,
auditLogsCount: auditLogs.length
});
}
const isCurrentlyAssigned = userRole === 'Super Admin' || userRole === 'DD Admin' || ( const isCurrentlyAssigned = userRole === 'Super Admin' || userRole === 'DD Admin' || (
(currentStage === 'RBM + DD-ZM Review' && (userRole === 'RBM' || userRole === 'DD-ZM') && !userHasApprovedJointly) || (isStage('RBM + DD-ZM Review') && (userRole === 'RBM' || userRole === 'DD-ZM') && !userHasApprovedJointly) ||
(currentStage === 'ZBH Review' && userRole === 'ZBH') || (isStage('ZBH Review') && userRole === 'ZBH') ||
(currentStage === 'DD Lead Review' && userRole === 'DD Lead') || (isStage('DD Lead Review') && userRole === 'DD Lead') ||
(currentStage === 'Legal Verification' && userRole === 'Legal Admin') || (isStage('Legal Verification') && userRole === 'Legal Admin') ||
(currentStage === 'DD Head Review' && (userRole === 'DD Head' || userRole === 'DD_HEAD')) || (isStage('DD Head Review') && (userRole === 'DD Head' || userRole === 'DD_HEAD')) ||
(currentStage === 'NBH Evaluation' && userRole === 'NBH') || (isStage('NBH Evaluation') && userRole === 'NBH') ||
(currentStage === 'NBH Final Approval' && userRole === 'NBH') || (isStage('Evaluation of Dealer SCN Response') && ['DD Lead', 'ZBH', 'RBM', 'DD Head', 'DD_HEAD'].includes(userRole) && !userHasApprovedJointly) ||
(currentStage === 'CCO Approval' && userRole === 'CCO') || (isStage('NBH Final Approval') && userRole === 'NBH') ||
(currentStage === 'CEO Final Approval' && userRole === 'CEO') || (isStage('CCO Approval') && userRole === 'CCO') ||
(currentStage === 'Legal - Termination Letter' && userRole === 'Legal Admin') (isStage('CEO Final Approval') && userRole === 'CEO') ||
(isStage('Legal - Termination Letter') && userRole === 'Legal Admin')
); );
return { 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, 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: ( canFinalize: (
(currentStage === 'NBH Final Approval' && userRole === 'NBH') || (currentStage === 'NBH Final Approval' && userRole === 'NBH') ||
(currentStage === 'CCO Approval' && userRole === 'CCO') || (currentStage === 'CCO Approval' && userRole === 'CCO') ||
@ -231,39 +283,6 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
const request = terminationData || {}; const request = terminationData || {};
const isScnStage = ['Show Cause Notice', 'Show Cause Notice (SCN)', 'SCN'].includes(request.currentStage); 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) => { const resolveCanonicalStage = (currentStage?: string) => {
if (!currentStage) return ''; if (!currentStage) return '';
@ -396,9 +415,9 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
}, },
{ {
id: 9, id: 9,
name: 'Personal Hearing', name: 'Evaluation of Dealer SCN Response',
status: getProgressStatus('Personal Hearing'), status: getProgressStatus('Evaluation of Dealer SCN Response'),
description: 'Evaluation of SCN response & Hearing' description: 'Joint evaluation of SCN response by DD-Lead, ZBH, RBM, and DD-Head'
}, },
{ {
id: 10, id: 10,
@ -442,7 +461,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
setStageDocumentsDialog({ open: true, stageName, documents }); 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 }); setActionDialog({ open: true, type });
}; };
@ -471,7 +490,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
setIsProcessing(true); setIsProcessing(true);
try { try {
let response: any; 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); response = await terminationService.updateTerminationStatus(terminationId, effectiveAction, remarks);
} else if (actionType === 'pushfnf') { } else if (actionType === 'pushfnf') {
response = await terminationService.updateTerminationStatus(terminationId, 'pushfnf', remarks); 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-red-100 text-red-700 border-red-300'
: 'bg-yellow-100 text-yellow-700 border-yellow-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> </Badge>
</div> </div>
</div> </div>
@ -565,6 +584,15 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<Card className="border-amber-200 shadow-sm"> <Card className="border-amber-200 shadow-sm">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex flex-col gap-4"> <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 */} {/* Primary Actions Row */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <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 && ( {permissions.canFinalize && (
<Button <Button
size="sm" size="sm"
@ -945,7 +984,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
{entry.action || 'Action'} {entry.action || 'Action'}
</Badge> </Badge>
<span className="text-[10px] text-slate-500 font-medium"> <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> </span>
</div> </div>
<div className={` <div className={`
@ -1123,6 +1162,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
{actionDialog.type === 'revoke' && 'Revoke Termination Request'} {actionDialog.type === 'revoke' && 'Revoke Termination Request'}
{actionDialog.type === 'assign' && 'Assign to User'} {actionDialog.type === 'assign' && 'Assign to User'}
{actionDialog.type === 'pushfnf' && 'Push to Full & Final Settlement'} {actionDialog.type === 'pushfnf' && 'Push to Full & Final Settlement'}
{actionDialog.type === 'hold' && 'Hold Termination Case'}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{actionDialog.type === 'assign' {actionDialog.type === 'assign'
@ -1185,6 +1225,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
className={ className={
actionDialog.type === 'approve' ? 'bg-green-600 hover:bg-green-700' : actionDialog.type === 'approve' ? 'bg-green-600 hover:bg-green-700' :
actionDialog.type === 'withdrawal' ? 'bg-red-600 hover:bg-red-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' 'bg-blue-600 hover:bg-blue-700'
} }
> >

View File

@ -23,6 +23,7 @@ import {
} from "@/components/ui/pagination"; } from "@/components/ui/pagination";
import { User } from '@/lib/mock-data'; import { User } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { formatTerminationStatusLabel } from '@/lib/terminationDisplay';
interface TerminationPageProps { interface TerminationPageProps {
currentUser: User | null; currentUser: User | null;
@ -51,6 +52,8 @@ const getStatusColor = (status: string) => {
return 'bg-blue-100 text-blue-700 border-blue-300'; return 'bg-blue-100 text-blue-700 border-blue-300';
}; };
const formatStatus = formatTerminationStatusLabel;
export function TerminationPage({ currentUser, onViewDetails }: TerminationPageProps) { export function TerminationPage({ currentUser, onViewDetails }: TerminationPageProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [dealers, setDealers] = useState<any[]>([]); const [dealers, setDealers] = useState<any[]>([]);
@ -103,7 +106,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
}; };
useEffect(() => { useEffect(() => {
if (!isDialogOpen || !isDDLead) return; if (!isDialogOpen || !canCreateTermination) return;
let cancelled = false; let cancelled = false;
(async () => { (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) // Map terminations to tab-specific views (already filtered by backend, but need variables for render)
const openRequests = activeTab === 'open' || activeTab === 'all' ? terminations : []; const openRequests = activeTab === 'open' || activeTab === 'all' ? terminations : [];
@ -312,14 +316,9 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<CardTitle>Termination Requests</CardTitle> <CardTitle>Termination Requests</CardTitle>
<CardDescription> <CardDescription>
Manage dealer termination proceedings and legal compliance 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> </CardDescription>
</div> </div>
{isDDLead && ( {canCreateTermination && (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="bg-red-600 hover:bg-red-700"> <Button className="bg-red-600 hover:bg-red-700">
@ -408,7 +407,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<SelectContent> <SelectContent>
<SelectItem value="Working Capital">Working Capital</SelectItem> <SelectItem value="Working Capital">Working Capital</SelectItem>
<SelectItem value="Performance Issues">Performance Issues</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="Unforeseen Circumstances">Unforeseen Circumstances</SelectItem>
<SelectItem value="Others">Others</SelectItem> <SelectItem value="Others">Others</SelectItem>
</SelectContent> </SelectContent>
@ -495,12 +494,12 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold">{request.requestId || (request.dealer?.dealerCode?.code || 'N/A')}</h3> <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')}> <Badge className={getSeverityColor(request.severity || 'Medium')}>
{request.severity || 'Normal'} {request.severity || 'Normal'}
</Badge> </Badge>
<Badge className={getStatusColor(request.status)}> <Badge className={getStatusColor(request.status)}>
{request.status} {formatStatus(request.status)}
</Badge> </Badge>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <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>
<div> <div>
<p className="text-slate-600">Current Stage</p> <p className="text-slate-600">Current Stage</p>
<p>{request.currentStage}</p> <p>{formatStatus(request.currentStage)}</p>
</div> </div>
<div> <div>
<p className="text-slate-600">Proposed LWD</p> <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-1">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold">{request.requestId || (request.dealer?.dealerCode?.code || 'N/A')}</h3> <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)}> <Badge className={getStatusColor(request.status)}>
{request.status} {formatStatus(request.status)}
</Badge> </Badge>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <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>
<div> <div>
<p className="text-slate-600">Current Stage</p> <p className="text-slate-600">Current Stage</p>
<p>{request.currentStage}</p> <p>{formatStatus(request.currentStage)}</p>
</div> </div>
<div> <div>
<p className="text-slate-600">Submitted On</p> <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-1">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold">{request.requestId || (request.dealer?.dealerCode?.code || 'N/A')}</h3> <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)}> <Badge className={getStatusColor(request.status)}>
{request.status} {formatStatus(request.status)}
</Badge> </Badge>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <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", "Resignation Letter",
"Dealer Undertaking", "Dealer Undertaking",
"Approval Note", "Approval Note",
"Legal Communication", "Resignation Acceptance Letter",
"Handover Document", "Handover Document",
"Settlement Supporting Document", "Settlement Supporting Document",
"PPT Presentation", "PPT Presentation",
@ -10,6 +10,7 @@ export const RESIGNATION_DOCUMENT_TYPES = [
] as const; ] as const;
export const RESIGNATION_STAGE_OPTIONS = [ export const RESIGNATION_STAGE_OPTIONS = [
"Request Submitted",
"ASM", "ASM",
"RBM", "RBM",
"ZBH", "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'); if (!response.ok) throw new Error(response.data?.message || 'Failed to perform bulk conversion');
return response.data; 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) => { createDealer: async (data: any) => {
const response: any = await API.createDealer(data); const response: any = await API.createDealer(data);
if (!response.ok) throw new Error(response.data?.message || 'Failed to create dealer profile'); if (!response.ok) throw new Error(response.data?.message || 'Failed to create dealer profile');