stage names modified and calendar addd in opportunity requests
This commit is contained in:
parent
2f82699572
commit
b357dbdcbb
@ -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'),
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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`);
|
||||
setSelectedIds([]);
|
||||
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"
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
{isSendingReminders ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Send Reminders ({selectedIds.length})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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
|
||||
/>
|
||||
</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" />
|
||||
|
||||
@ -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');
|
||||
@ -216,7 +229,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
if (response && response.success) {
|
||||
// Refresh data from server to ensure correct filtering and pagination
|
||||
await fetchApplications();
|
||||
|
||||
|
||||
setSelectedIds([]);
|
||||
setShowShortlistModal(false);
|
||||
setShortlistRemark('');
|
||||
@ -231,21 +244,34 @@ 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 () => {
|
||||
// Exclude 'Questionnaire Pending' from export as they have no responses yet
|
||||
const validApplications = filteredApplications.filter(app => app.status !== 'Questionnaire Pending');
|
||||
const selectedValidApps = validApplications.filter(app => selectedIds.includes(app.id));
|
||||
|
||||
|
||||
let idsToExport: string[] = [];
|
||||
|
||||
|
||||
if (selectedIds.length > 0) {
|
||||
if (selectedValidApps.length === 0) {
|
||||
toast.error('Selected applications are in "Questionnaire Pending" status and cannot be exported.');
|
||||
@ -258,7 +284,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
} else {
|
||||
idsToExport = validApplications.map(a => a.id);
|
||||
}
|
||||
|
||||
|
||||
if (idsToExport.length === 0) {
|
||||
toast.error('No applications with completed questionnaires available for export');
|
||||
return;
|
||||
@ -268,7 +294,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
const loadingToast = toast.loading('Preparing Excel export...');
|
||||
const data = await onboardingService.exportResponses(idsToExport);
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
toast.error('No response data found');
|
||||
return;
|
||||
@ -278,7 +304,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
const headers = Object.keys(data[0]);
|
||||
const csvRows = [
|
||||
headers.join(','), // Header row
|
||||
...data.map((row: any) =>
|
||||
...data.map((row: any) =>
|
||||
headers.map(header => {
|
||||
const val = row[header] ?? '';
|
||||
// Escape quotes and wrap in quotes for CSV safety
|
||||
@ -297,7 +323,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
|
||||
toast.success(`Exported ${idsToExport.length} records to Excel successfully`);
|
||||
} catch (error: any) {
|
||||
console.error('Export failed:', error);
|
||||
@ -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"
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
@ -516,9 +571,9 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleExport} data-testid="onboarding-opp-requests-export-btn">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
|
||||
{selectedIds.length > 0 && (
|
||||
<>
|
||||
@ -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"
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
{isSendingReminders ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Send Reminders ({selectedIds.length})
|
||||
</Button>
|
||||
|
||||
@ -672,23 +732,23 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
<PaginationPrevious
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
|
||||
{[...Array(paginationMeta.totalPages)].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
// Simple pagination: show first, last, and current +/- 1
|
||||
if (
|
||||
pageNum === 1 ||
|
||||
pageNum === paginationMeta.totalPages ||
|
||||
pageNum === 1 ||
|
||||
pageNum === paginationMeta.totalPages ||
|
||||
(pageNum >= currentPage - 1 && pageNum <= currentPage + 1)
|
||||
) {
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
<PaginationLink
|
||||
isActive={currentPage === pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="cursor-pointer"
|
||||
@ -698,7 +758,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
</PaginationItem>
|
||||
);
|
||||
} else if (
|
||||
pageNum === currentPage - 2 ||
|
||||
pageNum === currentPage - 2 ||
|
||||
pageNum === currentPage + 2
|
||||
) {
|
||||
return (
|
||||
@ -711,7 +771,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
<PaginationNext
|
||||
onClick={() => setCurrentPage(p => Math.min(paginationMeta.totalPages, p + 1))}
|
||||
className={currentPage === paginationMeta.totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 stageMatches =
|
||||
log.details?.stage === 'RBM + DD-ZM Review' ||
|
||||
log.stage === 'RBM + DD-ZM Review' ||
|
||||
(log.remarks || '').includes('Partial approval by');
|
||||
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 =
|
||||
(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'
|
||||
}
|
||||
>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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",
|
||||
|
||||
5
src/lib/terminationDisplay.ts
Normal file
5
src/lib/terminationDisplay.ts
Normal 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');
|
||||
}
|
||||
64
src/lib/terminationJointReviewRound.ts
Normal file
64
src/lib/terminationJointReviewRound.ts
Normal 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;
|
||||
}
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user