ication service enhanced even more detailed way added more templates documentented i splitted based on modulewise joint approval added for resignation flow, upload ppt document with new docment type add for DD Lead user
This commit is contained in:
laxman h 2026-04-30 18:52:17 +05:30
parent 95032cf2a7
commit 2f82699572
12 changed files with 460 additions and 260 deletions

View File

@ -78,10 +78,15 @@ export default function App() {
const location = useLocation();
const currentRole = currentUser?.role || currentUser?.roleCode || '';
const normalizedRole = String(currentRole).trim().toLowerCase();
const hasRole = (roles: string[]) => roles.map((r) => r.toLowerCase()).includes(normalizedRole);
const resignationRoles = ['DD Admin', 'ASM', 'RBM', 'DD Lead', 'ZBH', 'NBH', 'Legal', 'Legal Admin', 'Super Admin'];
const terminationRoles = ['ASM', 'RBM', 'ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'Legal', 'DD Admin', 'CCO', 'CEO', 'Super Admin'];
const fnfRoles = ['DD Admin', 'DD Lead', 'NBH', 'Finance', 'Finance Admin', 'Super Admin'];
const hasRole = (roles: string[]) => {
const normalizedTargetRoles = roles.map((r) => r.toLowerCase());
const userRole = String(currentUser?.role || '').toLowerCase();
const userRoleCode = String(currentUser?.roleCode || '').toLowerCase();
return normalizedTargetRoles.includes(userRole) || normalizedTargetRoles.includes(userRoleCode);
};
const resignationRoles = ['DD Admin', 'DD_ADMIN', 'ASM', 'RBM', 'DD-ZM', 'DD_ZM', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'ZBH', 'NBH', 'Legal', 'Legal Admin', 'LEGAL_ADMIN', 'Super Admin', 'SUPER_ADMIN'];
const terminationRoles = ['ASM', 'RBM', 'DD-ZM', 'DD_ZM', 'ZBH', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'NBH', 'Legal Admin', 'LEGAL_ADMIN', 'Legal', 'DD Admin', 'DD_ADMIN', 'CCO', 'CEO', 'Super Admin', 'SUPER_ADMIN'];
const fnfRoles = ['DD Admin', 'DD_ADMIN', 'DD-ZM', 'DD_ZM', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'NBH', 'Finance', 'Finance Admin', 'FINANCE_ADMIN', 'Super Admin', 'SUPER_ADMIN'];
const financeRoles = ['Finance', 'Finance Admin'];
useEffect(() => {

View File

@ -48,11 +48,16 @@ export function Sidebar({ onLogout }: SidebarProps) {
const currentRole = currentUser?.role || currentUser?.roleCode || '';
const normalizedRole = String(currentRole).trim().toLowerCase();
const hasRole = (roles: string[]) => roles.map((r) => r.toLowerCase()).includes(normalizedRole);
const hasRole = (roles: string[]) => {
const normalizedTargetRoles = roles.map((r) => r.toLowerCase());
const userRole = String(currentUser?.role || '').toLowerCase();
const userRoleCode = String(currentUser?.roleCode || '').toLowerCase();
return normalizedTargetRoles.includes(userRole) || normalizedTargetRoles.includes(userRoleCode);
};
const resignationRoles = ['DD Admin', 'ASM', 'RBM', 'DD Lead', 'ZBH', 'NBH', 'Legal', 'Legal Admin', 'Super Admin'];
const terminationRoles = ['ASM', 'RBM', 'ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'Legal', 'DD Admin', 'CCO', 'CEO', 'Super Admin'];
const fnfRoles = ['DD Admin', 'DD Lead', 'NBH', 'Finance', 'Finance Admin', 'Super Admin'];
const resignationRoles = ['DD Admin', 'DD_ADMIN', 'ASM', 'RBM', 'DD-ZM', 'DD_ZM', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'ZBH', 'NBH', 'Legal', 'Legal Admin', 'LEGAL_ADMIN', 'Super Admin', 'SUPER_ADMIN'];
const terminationRoles = ['ASM', 'RBM', 'DD-ZM', 'DD_ZM', 'ZBH', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'NBH', 'Legal Admin', 'LEGAL_ADMIN', 'Legal', 'DD Admin', 'DD_ADMIN', 'CCO', 'CEO', 'Super Admin', 'SUPER_ADMIN'];
const fnfRoles = ['DD Admin', 'DD_ADMIN', 'DD-ZM', 'DD_ZM', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'NBH', 'Finance', 'Finance Admin', 'FINANCE_ADMIN', 'Super Admin', 'SUPER_ADMIN'];
const canSeeResignation = hasRole(resignationRoles);
const canSeeTermination = hasRole(terminationRoles);
const canSeeFnF = hasRole(fnfRoles);

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
import {
Tabs, TabsContent, TabsList, TabsTrigger
import {
Tabs, TabsContent, TabsList, TabsTrigger
} from '@/components/ui/tabs';
import { Globe, Shield, Mail, MapPin, SlidersHorizontal, Settings, FileText, Settings2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
@ -38,13 +38,13 @@ import { RootState } from '@/store';
export const MasterPage: React.FC = () => {
const { fetchInitialData, fetchAreas } = useMasterData();
const {
const {
asms, zonalManagerMappings,
allStates,
allDistricts,
allDistricts,
users,
roles,
loading
loading
} = useSelector((state: RootState) => state.master);
// Tab & Selection State
@ -67,7 +67,7 @@ export const MasterPage: React.FC = () => {
const [selectedASMStates, setSelectedASMStates] = useState<string[]>([]);
const [selectedASMDistricts, setSelectedASMDistricts] = useState<string[]>([]);
const [asmRoleCode, setAsmRoleCode] = useState<'ASM' | 'DD-AM'>('DD-AM');
// ZM Management State
const [showZMDialog, setShowZMDialog] = useState(false);
const [editingZMId, setEditingZMId] = useState<string | null>(null);
@ -161,11 +161,11 @@ export const MasterPage: React.FC = () => {
return;
}
try {
const payload = {
userId: asmManagerId,
const payload = {
userId: asmManagerId,
roleCode: asmRoleCode,
districts: selectedASMDistricts,
status: asmStatus
districts: selectedASMDistricts,
status: asmStatus
};
const res = await masterService.saveASM(payload) as any;
if (res.success) {
@ -175,9 +175,9 @@ export const MasterPage: React.FC = () => {
} else {
toast.error(res.message || 'Failed to save ASM');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Failed to save ASM';
toast.error(msg);
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Failed to save ASM';
toast.error(msg);
}
};
@ -209,13 +209,13 @@ export const MasterPage: React.FC = () => {
return;
}
try {
const payload = {
userId: zmManagerId,
const payload = {
userId: zmManagerId,
zoneId: selectedZMZone,
regionIds: selectedZMRegions,
regionIds: selectedZMRegions,
status: zmStatus
};
const res = await (masterService as any).saveZonalManager(payload) as any;
if (res.success) {
toast.success(`Zonal Manager ${editingZMId ? 'updated' : 'assigned'} successfully`);
@ -224,9 +224,9 @@ export const MasterPage: React.FC = () => {
} else {
toast.error(res.message || 'Failed to save Zonal Manager');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Failed to save Zonal Manager';
toast.error(msg);
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Failed to save Zonal Manager';
toast.error(msg);
}
};
@ -234,99 +234,99 @@ export const MasterPage: React.FC = () => {
const handleSaveZone = async () => {
try {
const payload = {
id: editingZoneId,
name: zoneName,
code: zoneCode,
description: zoneDescription,
managerId: zonalBusinessHeadId === 'none' ? null : zonalBusinessHeadId
};
const res = await masterService.saveZone(payload) as any;
if (res.success) {
toast.success('Zone saved successfully');
setShowZoneDialog(false);
fetchInitialData();
} else {
toast.error(res.message || 'Error saving zone');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Error saving zone';
toast.error(msg);
const payload = {
id: editingZoneId,
name: zoneName,
code: zoneCode,
description: zoneDescription,
managerId: zonalBusinessHeadId === 'none' ? null : zonalBusinessHeadId
};
const res = await masterService.saveZone(payload) as any;
if (res.success) {
toast.success('Zone saved successfully');
setShowZoneDialog(false);
fetchInitialData();
} else {
toast.error(res.message || 'Error saving zone');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Error saving zone';
toast.error(msg);
}
};
const handleSaveRegion = async () => {
try {
const payload = {
...(editingRegionId ? { id: editingRegionId } : {}),
name: regionName,
description: regionDescription,
parentId: selectedRegionZone,
managerId: regionalManagerId,
districts: selectedRegionDistricts,
status: 'Active'
};
const res = await masterService.saveRegion(payload) as any;
if (res.success) {
toast.success('Region saved successfully');
setShowRegionDialog(false);
fetchInitialData();
} else {
toast.error(res.message || 'Error saving region');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Error saving region';
toast.error(msg);
const payload = {
...(editingRegionId ? { id: editingRegionId } : {}),
name: regionName,
description: regionDescription,
parentId: selectedRegionZone,
managerId: regionalManagerId,
districts: selectedRegionDistricts,
status: 'Active'
};
const res = await masterService.saveRegion(payload) as any;
if (res.success) {
toast.success('Region saved successfully');
setShowRegionDialog(false);
fetchInitialData();
} else {
toast.error(res.message || 'Error saving region');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Error saving region';
toast.error(msg);
}
};
const handleSaveTemplate = async (body: string) => {
try {
if (!editingTemplate?.id) {
toast.error('Open a template from the list to edit.');
return;
}
const res = await masterService.updateEmailTemplate(editingTemplate.id, {
...editingTemplate,
body
}) as any;
if (res.success) {
toast.success('Template saved');
setShowTemplateDialog(false);
fetchInitialData();
} else {
toast.error(res.message || 'Error saving template');
}
if (!editingTemplate?.id) {
toast.error('Open a template from the list to edit.');
return;
}
const res = await masterService.updateEmailTemplate(editingTemplate.id, {
...editingTemplate,
body
}) as any;
if (res.success) {
toast.success('Template saved');
setShowTemplateDialog(false);
fetchInitialData();
} else {
toast.error(res.message || 'Error saving template');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Error saving template';
toast.error(msg);
const msg = error?.response?.data?.message || error?.message || 'Error saving template';
toast.error(msg);
}
};
const handlePreviewTemplate = async (body: string) => {
setPreviewLoading(true);
try {
let data: Record<string, unknown>;
try {
data = JSON.parse(testDataInput) as Record<string, unknown>;
} catch {
toast.error('Mock test data must be valid JSON');
return;
}
const res = await masterService.previewEmailTemplate({
subject: editingTemplate?.subject,
body,
data
}) as any;
if (res.success) {
setPreviewContent(res.data);
} else {
toast.error(res.message || 'Preview failed');
}
let data: Record<string, unknown>;
try {
data = JSON.parse(testDataInput) as Record<string, unknown>;
} catch {
toast.error('Mock test data must be valid JSON');
return;
}
const res = await masterService.previewEmailTemplate({
subject: editingTemplate?.subject,
body,
data
}) as any;
if (res.success) {
setPreviewContent(res.data);
} else {
toast.error(res.message || 'Preview failed');
}
} catch (error: any) {
const d = error?.response?.data;
const detail = d?.error || d?.message;
toast.error(detail || error?.message || 'Preview failed');
const d = error?.response?.data;
const detail = d?.error || d?.message;
toast.error(detail || error?.message || 'Preview failed');
} finally { setPreviewLoading(false); }
};
@ -340,9 +340,9 @@ export const MasterPage: React.FC = () => {
} else {
toast.error(res.message || 'Error saving role permissions');
}
} catch (error: any) {
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Error saving role permissions';
toast.error(msg);
toast.error(msg);
}
};
@ -385,48 +385,48 @@ export const MasterPage: React.FC = () => {
const handleSaveLocation = async () => {
try {
if (!locationState) {
toast.error('Please select a state');
return;
}
if (!locationDistrict) {
toast.error('Please select a district');
return;
}
if (!locationState) {
toast.error('Please select a state');
return;
}
if (!locationDistrict) {
toast.error('Please select a district');
return;
}
const selectedState = allStates.find((s: any) => s.id === locationState);
const selectedDistrict = allDistricts.find((d: any) => d.id === locationDistrict);
const payload = {
id: editingLocationId,
stateId: locationState,
stateName: (selectedState as any)?.name || (selectedState as any)?.stateName || '',
districtId: locationDistrict,
name: locationCity || selectedDistrict?.name || 'New Location',
city: locationCity,
status: locationStatus,
openFrom: locationActiveFrom,
openTo: locationActiveTo,
isOpportunity: locationStatus === 'active'
};
const res = await (editingLocationId
? masterService.updateArea(editingLocationId, payload)
: masterService.createArea(payload)) as any;
if (res.success) {
toast.success('Location saved');
setShowLocationDialog(false);
fetchAreas({ search: districtsSearch, page: districtsPage });
}
const selectedState = allStates.find((s: any) => s.id === locationState);
const selectedDistrict = allDistricts.find((d: any) => d.id === locationDistrict);
const payload = {
id: editingLocationId,
stateId: locationState,
stateName: (selectedState as any)?.name || (selectedState as any)?.stateName || '',
districtId: locationDistrict,
name: locationCity || selectedDistrict?.name || 'New Location',
city: locationCity,
status: locationStatus,
openFrom: locationActiveFrom,
openTo: locationActiveTo,
isOpportunity: locationStatus === 'active'
};
const res = await (editingLocationId
? masterService.updateArea(editingLocationId, payload)
: masterService.createArea(payload)) as any;
if (res.success) {
toast.success('Location saved');
setShowLocationDialog(false);
fetchAreas({ search: districtsSearch, page: districtsPage });
}
} catch (error) { toast.error('Error saving location'); }
};
useEffect(() => {
const handler = setTimeout(() => {
fetchAreas({
search: districtsSearch,
page: districtsPage,
stateId: locationStateFilter === 'all' ? undefined : locationStateFilter,
isOpportunity: locationStatusFilter === 'all' ? undefined : (locationStatusFilter === 'active' ? 'true' : 'false')
});
fetchAreas({
search: districtsSearch,
page: districtsPage,
stateId: locationStateFilter === 'all' ? undefined : locationStateFilter,
isOpportunity: locationStatusFilter === 'all' ? undefined : (locationStatusFilter === 'active' ? 'true' : 'false')
});
}, 500);
return () => clearTimeout(handler);
}, [districtsSearch, districtsPage, locationStateFilter, locationStatusFilter, fetchAreas]);
@ -438,7 +438,6 @@ export const MasterPage: React.FC = () => {
<h1 className="text-slate-900 mb-2 font-bold text-2xl">Master Configuration</h1>
<p className="text-slate-600">Centralized governance for locations, roles, and operational policies</p>
</div>
<Badge className="bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-1">Admin Control Panel</Badge>
</div>
{loading ? (
@ -477,28 +476,28 @@ export const MasterPage: React.FC = () => {
<TabsContent value="hierarchy" className="space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-300">
<ZonesOverview selectedZone={selectedZone} onZoneClick={(id) => setSelectedZone(selectedZone === id ? 'all' : id)} />
<ZoneDetails selectedZone={selectedZone} onAddZone={() => { setEditingZoneId(null); setZoneName(''); setZoneCode(''); setZoneDescription(''); setZonalBusinessHeadId('none'); setShowZoneDialog(true); }}
<ZoneDetails selectedZone={selectedZone} onAddZone={() => { setEditingZoneId(null); setZoneName(''); setZoneCode(''); setZoneDescription(''); setZonalBusinessHeadId('none'); setShowZoneDialog(true); }}
onEditZone={(z) => { setEditingZoneId(z.id); setZoneName(z.name); setZoneCode(z.code); setZoneDescription(z.description); setZonalBusinessHeadId(z.zonalBusinessHead?.id || 'none'); setShowZoneDialog(true); }} />
<RegionalManagement selectedZone={selectedZone} onAddRegion={() => { setEditingRegionId(null); setRegionName(''); setSelectedRegionZone(selectedZone === 'all' ? '' : selectedZone); setRegionalManagerId(''); setSelectedRegionDistricts([]); setShowRegionDialog(true); }}
onEditRegion={(r) => {
setEditingRegionId(r.id);
setRegionName(r.name);
setSelectedRegionZone(r.zoneId);
setRegionalManagerId(r.regionalManager?.id || '');
setSelectedRegionDistricts(r.districts?.map((d: any) => d.id) || []);
setShowRegionDialog(true);
onEditRegion={(r) => {
setEditingRegionId(r.id);
setRegionName(r.name);
setSelectedRegionZone(r.zoneId);
setRegionalManagerId(r.regionalManager?.id || '');
setSelectedRegionDistricts(r.districts?.map((d: any) => d.id) || []);
setShowRegionDialog(true);
}}
onDeleteRegion={() => toast.error('Regional office deletion is restricted via portal')} />
<ZMManagement selectedZone={selectedZone}
onAddZM={() => {
<ZMManagement selectedZone={selectedZone}
onAddZM={() => {
setEditingZMId(null); setZmManagerId('');
setZmStatus('active'); setSelectedZMZone(selectedZone === 'all' ? '' : selectedZone);
setSelectedZMRegions([]);
setShowZMDialog(true);
}}
onEditZM={handleEditZM}
setShowZMDialog(true);
}}
onEditZM={handleEditZM}
onDeleteZM={() => toast.error('ZM deletion restricted')} />
<ASMManagement selectedZone={selectedZone} onAddASM={() => { setEditingASMId(null); setAsmManagerId(''); setSelectedASMZone(selectedZone === 'all' ? '' : selectedZone); setSelectedASMRegion(''); setSelectedASMStates([]); setSelectedASMDistricts([]); setAsmRoleCode('DD-AM'); setShowASMDialog(true); }}
@ -529,13 +528,13 @@ export const MasterPage: React.FC = () => {
setTestDataInput('{}');
}
setShowTemplateDialog(true);
}}
onDeleteTemplate={() => toast.error('Delete Template restricted')}
}}
onDeleteTemplate={() => toast.error('Delete Template restricted')}
/>
</TabsContent>
<TabsContent value="locations" className="animate-in fade-in duration-300">
<LocationManagement
<LocationManagement
states={allStates}
stateFilter={locationStateFilter}
onStateFilterChange={(val: string) => {
@ -557,21 +556,21 @@ export const MasterPage: React.FC = () => {
setLocationStatus('active');
setShowLocationDialog(true);
}}
onEditLocation={handleEditLocation}
onEditLocation={handleEditLocation}
onDeleteLocation={(id) => {
if (window.confirm('Are you sure you want to delete this location?')) {
(masterService as any).deleteArea(id).then((res: any) => {
if (res.success) {
toast.success('Location deleted');
fetchAreas({
search: districtsSearch,
page: districtsPage,
stateId: locationStateFilter === 'all' ? undefined : locationStateFilter
});
toast.success('Location deleted');
fetchAreas({
search: districtsSearch,
page: districtsPage,
stateId: locationStateFilter === 'all' ? undefined : locationStateFilter
});
}
});
}
}}
}}
onSearch={(term) => {
setDistrictsSearch(term);
setDistrictsPage(1); // Reset to first page on search
@ -582,41 +581,41 @@ export const MasterPage: React.FC = () => {
</TabsContent>
<TabsContent value="approvals" className="animate-in fade-in duration-300">
<ApprovalPoliciesPage />
<ApprovalPoliciesPage />
</TabsContent>
<TabsContent value="documents" className="animate-in fade-in duration-300">
<DocumentConfigManagement />
<DocumentConfigManagement />
</TabsContent>
<TabsContent value="governance" className="animate-in fade-in duration-300">
<AutoAssignmentSettings />
<AutoAssignmentSettings />
</TabsContent>
<TabsContent value="settings" className="animate-in fade-in duration-300">
<SecurityDepositMaster />
<SecurityDepositMaster />
</TabsContent>
</Tabs>
)}
{/* Main Dialogs */}
<ZoneDialog isOpen={showZoneDialog} onOpenChange={setShowZoneDialog} editingZoneId={editingZoneId} zoneName={zoneName} setZoneName={setZoneName} zoneCode={zoneCode} setZoneCode={setZoneCode} zoneDescription={zoneDescription} setZoneDescription={setZoneDescription} zonalBusinessHeadId={zonalBusinessHeadId} setZonalBusinessHeadId={setZonalBusinessHeadId} onSave={handleSaveZone} userAssignedData={users.length > 0 ? users.map(u => ({...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles})) : asms} />
<RegionDialog isOpen={showRegionDialog} onOpenChange={setShowRegionDialog} editingRegionId={editingRegionId} regionName={regionName} setRegionName={setRegionName} regionDescription={regionDescription} setRegionDescription={setRegionDescription} selectedRegionZone={selectedRegionZone} setSelectedRegionZone={setSelectedRegionZone} regionalManagerId={regionalManagerId} setRegionalManagerId={setRegionalManagerId} selectedRegionStates={selectedRegionDistricts} setSelectedRegionStates={setSelectedRegionDistricts} onSave={handleSaveRegion} userAssignedData={users.length > 0 ? users.map(u => ({...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles})) : asms} />
<ASMDialog isOpen={showASMDialog} onOpenChange={setShowASMDialog} editingASMId={editingASMId} asmManagerId={asmManagerId} setAsmManagerId={setAsmManagerId} asmStatus={asmStatus} setAsmStatus={setAsmStatus} selectedASMZone={selectedASMZone} setSelectedASMZone={setSelectedASMZone} selectedASMRegion={selectedASMRegion} setSelectedASMRegion={setSelectedASMRegion} selectedASMStates={selectedASMStates} setSelectedASMStates={setSelectedASMStates} selectedASMDistricts={selectedASMDistricts} setSelectedASMDistricts={setSelectedASMDistricts} onSave={handleSaveASM} asmRoleCode={asmRoleCode} setAsmRoleCode={setAsmRoleCode} userAssignedData={users.length > 0 ? users.map(u => ({...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles})) : asms} districtsAssignedToOthers={districtsAssignedToOthers} getDistrictsForSelectedState={(state) => getDistrictsForSelectedState(state, selectedASMRegion || undefined)} />
<ZMDialog
isOpen={showZMDialog}
onOpenChange={setShowZMDialog}
editingZMId={editingZMId}
zmManagerId={zmManagerId}
setZmManagerId={setZmManagerId}
zmStatus={zmStatus}
setZmStatus={setZmStatus}
selectedZone={selectedZMZone}
setSelectedZone={setSelectedZMZone}
selectedRegions={selectedZMRegions}
setSelectedRegions={setSelectedZMRegions}
onSave={handleSaveZM}
userAssignedData={users.length > 0 ? users.map(u => ({...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles})) : asms}
<ZoneDialog isOpen={showZoneDialog} onOpenChange={setShowZoneDialog} editingZoneId={editingZoneId} zoneName={zoneName} setZoneName={setZoneName} zoneCode={zoneCode} setZoneCode={setZoneCode} zoneDescription={zoneDescription} setZoneDescription={setZoneDescription} zonalBusinessHeadId={zonalBusinessHeadId} setZonalBusinessHeadId={setZonalBusinessHeadId} onSave={handleSaveZone} userAssignedData={users.length > 0 ? users.map(u => ({ ...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles })) : asms} />
<RegionDialog isOpen={showRegionDialog} onOpenChange={setShowRegionDialog} editingRegionId={editingRegionId} regionName={regionName} setRegionName={setRegionName} regionDescription={regionDescription} setRegionDescription={setRegionDescription} selectedRegionZone={selectedRegionZone} setSelectedRegionZone={setSelectedRegionZone} regionalManagerId={regionalManagerId} setRegionalManagerId={setRegionalManagerId} selectedRegionStates={selectedRegionDistricts} setSelectedRegionStates={setSelectedRegionDistricts} onSave={handleSaveRegion} userAssignedData={users.length > 0 ? users.map(u => ({ ...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles })) : asms} />
<ASMDialog isOpen={showASMDialog} onOpenChange={setShowASMDialog} editingASMId={editingASMId} asmManagerId={asmManagerId} setAsmManagerId={setAsmManagerId} asmStatus={asmStatus} setAsmStatus={setAsmStatus} selectedASMZone={selectedASMZone} setSelectedASMZone={setSelectedASMZone} selectedASMRegion={selectedASMRegion} setSelectedASMRegion={setSelectedASMRegion} selectedASMStates={selectedASMStates} setSelectedASMStates={setSelectedASMStates} selectedASMDistricts={selectedASMDistricts} setSelectedASMDistricts={setSelectedASMDistricts} onSave={handleSaveASM} asmRoleCode={asmRoleCode} setAsmRoleCode={setAsmRoleCode} userAssignedData={users.length > 0 ? users.map(u => ({ ...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles })) : asms} districtsAssignedToOthers={districtsAssignedToOthers} getDistrictsForSelectedState={(state) => getDistrictsForSelectedState(state, selectedASMRegion || undefined)} />
<ZMDialog
isOpen={showZMDialog}
onOpenChange={setShowZMDialog}
editingZMId={editingZMId}
zmManagerId={zmManagerId}
setZmManagerId={setZmManagerId}
zmStatus={zmStatus}
setZmStatus={setZmStatus}
selectedZone={selectedZMZone}
setSelectedZone={setSelectedZMZone}
selectedRegions={selectedZMRegions}
setSelectedRegions={setSelectedZMRegions}
onSave={handleSaveZM}
userAssignedData={users.length > 0 ? users.map(u => ({ ...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles })) : asms}
/>
<TemplateDialog isOpen={showTemplateDialog} onOpenChange={setShowTemplateDialog} editingTemplate={editingTemplate} setEditingTemplate={setEditingTemplate} testDataInput={testDataInput} setTestDataInput={setTestDataInput} previewLoading={previewLoading} handlePreviewTemplate={handlePreviewTemplate} previewContent={previewContent} handleSaveTemplate={handleSaveTemplate} />
<LocationDialog isOpen={showLocationDialog} onOpenChange={setShowLocationDialog} editingLocationId={editingLocationId} locationState={locationState} setLocationState={setLocationState} locationDistrict={locationDistrict} setLocationDistrict={setLocationDistrict} locationCity={locationCity} setLocationCity={setLocationCity} locationActiveFrom={locationActiveFrom} setLocationActiveFrom={setLocationActiveFrom} locationActiveTo={locationActiveTo} setLocationActiveTo={setLocationActiveTo} locationStatus={locationStatus} setLocationStatus={setLocationStatus} allStates={allStates} allDistricts={allDistricts} onSave={handleSaveLocation} />

View File

@ -35,6 +35,8 @@ interface ApplicationDetailsActionModalsProps {
setInterviewIdToCancel: (value: string) => void;
isCancellingInterview: boolean;
handleConfirmCancelInterview: () => void;
interviewToReschedule: any;
setInterviewToReschedule: (value: any) => void;
interviewType: string;
setInterviewType: (value: string) => void;
interviewMode: string;
@ -99,6 +101,8 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
setInterviewIdToCancel,
isCancellingInterview,
handleConfirmCancelInterview,
interviewToReschedule,
setInterviewToReschedule,
interviewType,
setInterviewType,
interviewMode,
@ -252,10 +256,13 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
</DialogContent>
</Dialog>
<Dialog open={showScheduleModal} onOpenChange={setShowScheduleModal}>
<Dialog open={showScheduleModal} onOpenChange={(open) => {
setShowScheduleModal(open);
if (!open) setInterviewToReschedule(null);
}}>
<DialogContent data-testid="onboarding-schedule-modal">
<DialogHeader>
<DialogTitle>Schedule Interview</DialogTitle>
<DialogTitle>{interviewToReschedule ? 'Reschedule Interview' : 'Schedule Interview'}</DialogTitle>
<DialogDescription>Set up an interview session with the applicant and relevant team members.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@ -304,10 +311,15 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
</div>
)}
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowScheduleModal(false)} disabled={isScheduling} data-testid="onboarding-schedule-cancel-button">Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700" onClick={handleScheduleInterview} disabled={isScheduling} data-testid="onboarding-schedule-submit-button">{isScheduling ? 'Scheduling...' : 'Schedule'}</Button>
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => {
setShowScheduleModal(false);
setInterviewToReschedule(null);
}} disabled={isScheduling} data-testid="onboarding-schedule-cancel-button">Cancel</Button>
<Button className="flex-1 bg-primary-600 hover:bg-primary-700" onClick={handleScheduleInterview} disabled={isScheduling} data-testid="onboarding-schedule-submit-button">
{isScheduling ? (interviewToReschedule ? 'Rescheduling...' : 'Scheduling...') : (interviewToReschedule ? 'Reschedule' : 'Schedule')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>

View File

@ -51,6 +51,7 @@ interface ApplicationDetailsTabsProps {
setShowUploadForm: (value: boolean) => void;
handleRetriggerEvaluators: () => void;
handleCancelInterview: (interviewId: any) => void;
handleRescheduleInterview: (interview: any) => void;
setSelectedEvaluationForView: (value: any) => void;
setShowFeedbackDetailsModal: (value: boolean) => void;
renderFddAuditContent: () => React.ReactNode;
@ -86,6 +87,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
setShowUploadForm,
handleRetriggerEvaluators,
handleCancelInterview,
handleRescheduleInterview,
setSelectedEvaluationForView,
setShowFeedbackDetailsModal,
renderFddAuditContent,
@ -627,11 +629,11 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700 hover:bg-red-50 h-8 px-2"
data-testid={`onboarding-interview-cancel-${idx}`}
onClick={() => handleCancelInterview(interview.id)}
className="text-primary-600 hover:text-primary-700 hover:bg-primary-50 h-8 px-2"
data-testid={`onboarding-interview-reschedule-${idx}`}
onClick={() => handleRescheduleInterview(interview)}
>
Cancel
Reschedule
</Button>
)}
</TableCell>

View File

@ -17,10 +17,15 @@ interface UseApplicationDetailsAdminActionsParams {
participantType: string;
users: any[];
interviewDate: string;
setInterviewDate: Dispatch<SetStateAction<string>>;
interviewType: string;
setInterviewType: Dispatch<SetStateAction<string>>;
interviewMode: string;
setInterviewMode: Dispatch<SetStateAction<string>>;
meetingLink: string;
setMeetingLink: Dispatch<SetStateAction<string>>;
location: string;
setLocation: Dispatch<SetStateAction<string>>;
scheduledInterviewParticipants: any[];
uploadFile: File | null;
uploadDocType: string;
@ -45,6 +50,8 @@ interface UseApplicationDetailsAdminActionsParams {
setShowCancelInterviewModal: Dispatch<SetStateAction<boolean>>;
interviewIdToCancel: string;
setInterviewIdToCancel: Dispatch<SetStateAction<string>>;
interviewToReschedule: any;
setInterviewToReschedule: Dispatch<SetStateAction<any>>;
setIsCancellingInterview: Dispatch<SetStateAction<boolean>>;
setIsUploading: Dispatch<SetStateAction<boolean>>;
setShowUploadForm: Dispatch<SetStateAction<boolean>>;
@ -79,10 +86,15 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
participantType,
users,
interviewDate,
setInterviewDate,
interviewType,
setInterviewType,
interviewMode,
setInterviewMode,
meetingLink,
setMeetingLink,
location,
setLocation,
scheduledInterviewParticipants,
uploadFile,
uploadDocType,
@ -107,6 +119,8 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
setShowCancelInterviewModal,
interviewIdToCancel,
setInterviewIdToCancel,
interviewToReschedule,
setInterviewToReschedule,
setIsCancellingInterview,
setIsUploading,
setShowUploadForm,
@ -176,7 +190,7 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
}, [currentUser, application, setUsers]);
const prefillInterviewParticipants = useCallback(() => {
if (!showScheduleModal || !application) return;
if (!showScheduleModal || !application || interviewToReschedule) return;
const levelNum = parseInt(interviewType.replace('level', '')) || 1;
const requiredRolesByLevel: Record<number, string[]> = {
1: ['DD-ZM', 'RBM'],
@ -233,7 +247,7 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
}
});
setScheduledInterviewParticipants(unique);
}, [showScheduleModal, application, interviewType, setScheduledInterviewParticipants]);
}, [showScheduleModal, application, interviewType, interviewToReschedule, setScheduledInterviewParticipants]);
const handleScheduleInterview = async () => {
if (!interviewDate) {
@ -242,20 +256,32 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
}
try {
setIsScheduling(true);
await onboardingService.scheduleInterview({
const payload = {
applicationId: application?.id,
level: interviewType,
scheduledAt: interviewDate,
type: interviewMode === 'virtual' ? 'Virtual Interview' : 'Physical Interview',
location: interviewMode === 'virtual' ? meetingLink : location,
participants: scheduledInterviewParticipants.map((p) => p.id),
});
toast.success('Interview scheduled successfully');
};
if (interviewToReschedule) {
await onboardingService.updateInterview(interviewToReschedule.id, {
...payload,
status: 'Scheduled',
});
toast.success('Interview rescheduled successfully');
} else {
await onboardingService.scheduleInterview(payload);
toast.success('Interview scheduled successfully');
}
setShowScheduleModal(false);
setInterviewToReschedule(null);
await fetchInterviews();
await fetchApplication();
} catch {
toast.error('Failed to schedule interview');
toast.error(interviewToReschedule ? 'Failed to reschedule interview' : 'Failed to schedule interview');
} finally {
setIsScheduling(false);
}
@ -266,6 +292,24 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
setShowCancelInterviewModal(true);
};
const handleRescheduleInterview = async (interview: any) => {
setInterviewToReschedule(interview);
setInterviewType(`level${interview.level}`);
setInterviewMode(interview.interviewType?.toLowerCase().includes('virtual') ? 'virtual' : 'physical');
setInterviewDate(interview.scheduleDate ? (() => {
const d = new Date(interview.scheduleDate);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
})() : '');
if (interview.interviewType?.toLowerCase().includes('virtual')) {
setMeetingLink(interview.linkOrLocation || '');
} else {
setLocation(interview.linkOrLocation || '');
}
const participants = (interview.participants || []).map((p: any) => p.user || p).filter(Boolean);
setScheduledInterviewParticipants(participants);
setShowScheduleModal(true);
};
const handleConfirmCancelInterview = async () => {
if (!interviewIdToCancel) return;
try {
@ -606,6 +650,7 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
fetchUsers,
maybeFetchUsersForModal,
handleScheduleInterview,
handleRescheduleInterview,
handleCancelInterview,
handleConfirmCancelInterview,
handleUpload,

View File

@ -19,6 +19,7 @@ export function useApplicationDetailsUIState({ initialTab = 'questionnaire' }: U
const [showScheduleModal, setShowScheduleModal] = useState(false);
const [showCancelInterviewModal, setShowCancelInterviewModal] = useState(false);
const [interviewIdToCancel, setInterviewIdToCancel] = useState('');
const [interviewToReschedule, setInterviewToReschedule] = useState<any>(null);
const [showKTMatrixModal, setShowKTMatrixModal] = useState(false);
const [showLevel2FeedbackModal, setShowLevel2FeedbackModal] = useState(false);
const [showLevel3FeedbackModal, setShowLevel3FeedbackModal] = useState(false);
@ -98,6 +99,7 @@ export function useApplicationDetailsUIState({ initialTab = 'questionnaire' }: U
showScheduleModal, setShowScheduleModal,
showCancelInterviewModal, setShowCancelInterviewModal,
interviewIdToCancel, setInterviewIdToCancel,
interviewToReschedule, setInterviewToReschedule,
showKTMatrixModal, setShowKTMatrixModal,
showLevel2FeedbackModal, setShowLevel2FeedbackModal,
showLevel3FeedbackModal, setShowLevel3FeedbackModal,

View File

@ -62,6 +62,7 @@ export const ApplicationDetails = () => {
showScheduleModal, setShowScheduleModal,
showCancelInterviewModal, setShowCancelInterviewModal,
interviewIdToCancel, setInterviewIdToCancel,
interviewToReschedule, setInterviewToReschedule,
showKTMatrixModal, setShowKTMatrixModal,
showLevel2FeedbackModal, setShowLevel2FeedbackModal,
showLevel3FeedbackModal, setShowLevel3FeedbackModal,
@ -266,6 +267,7 @@ export const ApplicationDetails = () => {
handleRemoveInterviewer,
maybeFetchUsersForModal,
handleScheduleInterview,
handleRescheduleInterview,
handleCancelInterview,
handleConfirmCancelInterview,
handleUpload,
@ -291,10 +293,15 @@ export const ApplicationDetails = () => {
participantType,
users,
interviewDate,
setInterviewDate,
interviewType,
setInterviewType,
interviewMode,
setInterviewMode,
meetingLink,
setMeetingLink,
location,
setLocation,
scheduledInterviewParticipants,
uploadFile,
uploadDocType,
@ -319,6 +326,8 @@ export const ApplicationDetails = () => {
setShowCancelInterviewModal,
interviewIdToCancel,
setInterviewIdToCancel,
interviewToReschedule,
setInterviewToReschedule,
setIsCancellingInterview,
setIsUploading,
setShowUploadForm,
@ -445,6 +454,7 @@ export const ApplicationDetails = () => {
setShowUploadForm={setShowUploadForm}
handleRetriggerEvaluators={handleRetriggerEvaluators}
handleCancelInterview={handleCancelInterview}
handleRescheduleInterview={handleRescheduleInterview}
setSelectedEvaluationForView={setSelectedEvaluationForView}
setShowFeedbackDetailsModal={setShowFeedbackDetailsModal}
renderFddAuditContent={renderFddAuditContent}
@ -533,6 +543,8 @@ export const ApplicationDetails = () => {
setInterviewIdToCancel={setInterviewIdToCancel}
isCancellingInterview={isCancellingInterview}
handleConfirmCancelInterview={handleConfirmCancelInterview}
interviewToReschedule={interviewToReschedule}
setInterviewToReschedule={setInterviewToReschedule}
interviewType={interviewType}
setInterviewType={setInterviewType}
interviewMode={interviewMode}

View File

@ -9,7 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { User as UserType } from '@/lib/mock-data';
import { toast } from 'sonner';
@ -97,6 +97,16 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadDocType, setUploadDocType] = useState<string>(RESIGNATION_DOCUMENT_TYPES[0]);
const [uploadStage, setUploadStage] = useState('');
const hasUploadedPPT = useMemo(() => {
const allDocs = [
...(resignationData?.documents || []),
...(resignationData?.uploadedDocuments || [])
];
return allDocs.some(doc =>
(doc.documentType || doc.type) === 'PPT Presentation'
);
}, [resignationData]);
const fetchResignation = async () => {
try {
setIsLoading(true);
@ -132,13 +142,13 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
{ 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: 'DD Admin Review', key: 'DD Admin', description: 'DD Admin verification' },
{ id: 7, name: 'Legal - Resignation Letter', key: 'Legal', description: 'Legal team issues resignation approval letter' },
{ 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' }
];
const stagesOrdered = ['ASM', 'RBM', 'ZBH', 'DD Lead', 'NBH', 'DD Admin', 'Legal', 'F&F Initiated', 'Completed'];
const stagesOrdered = ['ASM', 'RBM', 'ZBH', 'DD Lead', 'NBH', 'Legal', 'DD Admin', 'F&F Initiated', 'Completed'];
const legalStageApproved = (() => {
if (!resignationData) return false;
@ -157,6 +167,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const atLegal = stage === 'legal' || stage === 'legal - resignation letter';
const legalApprovedTransition =
targetStage === 'legal' ||
targetStage === 'dd admin' ||
targetStage === 'f&f initiated' ||
targetStage === 'fnf_initiated' ||
action.includes('approved');
@ -173,8 +184,17 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const currentStage = resignationData.currentStage;
const status = resignationData.status;
const userRole = currentUser.role;
const isZmRbmStage = currentStage === 'RBM' || currentStage === 'RBM Review' || currentStage === 'RBM + DD-ZM Review';
const userRoleCode = String(currentUser.roleCode || currentUser.role || '').trim().toUpperCase();
// Final states where no more actions are possible
// Check if current user already partially approved this request at this stage
const hasAlreadyPartiallyApproved = isZmRbmStage && auditLogs.some(log =>
log.action === 'PARTIAL_APPROVE' &&
(log.actor?.id === currentUser.id || log.actorId === currentUser.id || log.actor?.email === currentUser.email || log.userEmail === currentUser.email) &&
(log.details?.roleCode === userRoleCode || (log.details?.roleCode === 'DD-ZM' && userRoleCode === 'DD ZM'))
);
const isFinalState = ['Completed', 'Rejected', 'Withdrawn', 'Revoked'].includes(status);
// Check if it's already in the settlement phase
@ -184,18 +204,25 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const nbhIndex = stagesOrdered.indexOf('NBH');
const isPastNBH = stageIndex !== -1 && nbhIndex !== -1 && stageIndex >= nbhIndex;
const isCurrentlyAssigned = userRole === 'Super Admin' || userRole === STAGE_TO_ROLE_MAP[currentStage];
const isCurrentlyAssigned = userRoleCode === 'SUPER_ADMIN' ||
(isZmRbmStage && (userRoleCode === 'RBM' || userRoleCode === 'DD-ZM' || userRoleCode === 'DD ZM')) ||
userRole === STAGE_TO_ROLE_MAP[currentStage];
const isDDLeadStage = currentStage === 'DD Lead' || currentStage === 'DD Lead Review';
const isDDLead = userRoleCode === 'DD_LEAD' || userRoleCode === 'DD LEAD';
const canApprove = isCurrentlyAssigned &&
!isFinalState &&
!isSettlementPhase &&
!(currentStage === 'Legal' && legalStageApproved);
!hasAlreadyPartiallyApproved &&
!(currentStage === 'Legal' && legalStageApproved) &&
!(isDDLead && isDDLeadStage && !hasUploadedPPT);
return {
canApprove,
canSendBack: isCurrentlyAssigned && !isFinalState && !isSettlementPhase && stageIndex > 0,
canWithdraw: userRole === 'Dealer' && !isPastNBH && !isFinalState,
canRevoke: (userRole === '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) &&
!isSettlementPhase && !isFinalState,
canAssign: userRole !== 'Dealer' && !isFinalState
@ -203,7 +230,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
};
const permissions = getResignationPermissions();
const isNationalLevel = ['Super Admin', 'DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Legal Admin'].includes(currentUser?.role || '');
const isNationalLevel = ['Super Admin', 'DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Legal Admin', 'DD-ZM'].includes(currentUser?.role || '');
const stageAliases: Record<string, string[]> = {
'ASM': ['ASM', 'ASM Review', 'Request Initiated'],
@ -447,6 +474,31 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-600 mr-2">Workflow Actions:</span>
{/* Debug for PPT button visibility */}
{(() => {
const roleNormalized = String(currentUser?.roleCode || currentUser?.role || '').trim().toUpperCase();
const isDDLeadUser = roleNormalized === 'DD LEAD' || roleNormalized === 'DD_LEAD';
const isDDLeadStageCurrent = ['DD Lead', 'DD Lead Review', 'DDL Review'].includes(resignationData?.currentStage);
if (isDDLeadUser && isDDLeadStageCurrent) {
return (
<Button
size="sm"
variant="outline"
className="text-amber-700 border-amber-300 hover:bg-amber-50 shadow-sm"
onClick={() => {
setUploadDocType('PPT Presentation');
setUploadStage('DD Lead');
setShowUploadDialog(true);
}}
>
<Upload className="w-4 h-4 mr-2" />
Upload PPT
</Button>
);
}
return null;
})()}
{permissions.canApprove && (
<Button
size="sm"

View File

@ -153,7 +153,7 @@ 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', '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);
// Centralized Permissions Utility for Termination logic (Robust Validation)
const getTerminationPermissions = () => {
@ -169,12 +169,38 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
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 userHasApprovedJointly = auditLogs.some(log => {
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 result = 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')) {
console.log('[TerminationDebug] Joint Stage Detection:', {
currentStage,
userRole,
userId: currentUser.id,
userHasApprovedJointly,
auditLogsCount: auditLogs.length
});
}
const isCurrentlyAssigned = userRole === 'Super Admin' || userRole === 'DD Admin' || (
(currentStage === 'RBM Review' && userRole === 'RBM') ||
(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') ||
(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') ||
@ -206,8 +232,8 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
const isScnStage = ['Show Cause Notice', 'Show Cause Notice (SCN)', 'SCN'].includes(request.currentStage);
const stageAliases: Record<string, string[]> = {
'Submitted': ['Submitted', 'Request Initiated'],
'RBM Review': ['RBM Review'],
'Submitted': ['Submitted'],
'RBM + DD-ZM Review': ['RBM + DD-ZM Review'],
'ZBH Review': ['ZBH Review'],
'DD Lead Review': ['DD Lead Review'],
'Legal Verification': ['Legal Verification'],
@ -224,7 +250,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
const stageSequence = [
'Submitted',
'RBM Review',
'RBM + DD-ZM Review',
'ZBH Review',
'DD Lead Review',
'Legal Verification',
@ -301,21 +327,17 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
return acc;
}, {});
const getLatestStageTimelineEntry = (stageName: string) => {
const getStageTimelineEntries = (stageName: string) => {
const aliases = stageAliases[stageName] || [stageName];
const entries = (request.timeline || []).filter((entry: any) => aliases.includes(entry.stage));
const entries = (request.timeline || []).filter((entry: any) =>
aliases.includes(entry.stage) ||
(stageName === 'Submitted' && (entry.stage === 'Submitted' || entry.stage === 'Request Initiated'))
);
if (entries.length === 0) return null;
// Keep submitted row anchored to initiation details, not later stage-transition remarks.
if (stageName === 'Submitted') {
const initiatedEntry = entries.find((entry: any) =>
String(entry?.action || '').toLowerCase().includes('initiated')
);
return initiatedEntry || entries[0];
}
return entries[entries.length - 1];
// Sort by timestamp
return entries.sort((a: any, b: any) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
};
const progressStages = [
@ -332,9 +354,9 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
},
{
id: 2,
name: 'RBM Review',
status: getProgressStatus('RBM Review'),
description: 'Regional Business Manager review'
name: 'RBM + DD-ZM Review',
status: getProgressStatus('RBM + DD-ZM Review'),
description: 'Joint review and approval by RBM and DD-ZM'
},
{
id: 3,
@ -448,27 +470,34 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
setIsProcessing(true);
try {
let response: any;
if (actionType === 'approve' || actionType === 'sendBack' || actionType === 'withdrawal' || actionType === 'revoke') {
await terminationService.updateTerminationStatus(terminationId, effectiveAction, remarks);
response = await terminationService.updateTerminationStatus(terminationId, effectiveAction, remarks);
} else if (actionType === 'pushfnf') {
// Logic for push to fnf (using existing service if available)
await terminationService.updateTerminationStatus(terminationId, 'pushfnf', remarks);
response = await terminationService.updateTerminationStatus(terminationId, 'pushfnf', remarks);
} else {
toast.error('Action logic not fully implemented for this type');
setIsProcessing(false);
return;
}
if (response && (response.success === false || response.ok === false)) {
console.error('[TerminationDetails] Action failed:', response);
toast.error(response.message || response.data?.message || 'Failed to perform action');
setIsProcessing(false);
return;
}
const actionMessages: Record<string, string> = {
approve: 'Request approved and forwarded',
withdrawal: 'Request withdrawn successfully',
sendBack: 'Request sent back for clarification',
assign: `Request assigned to ${assignToUser}`,
assign: `Request assigned successfully`,
pushfnf: 'Request pushed to F&F successfully',
revoke: 'Request revoked and withdrawn'
};
toast.success(actionMessages[actionType!] || 'Action completed');
toast.success(actionMessages[actionType!] || response?.message || 'Action completed');
setActionDialog({ open: false, type: null });
setRemarks('');
setAssignToUser('');
@ -844,7 +873,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<div className="space-y-4">
{progressStages.map((stage, index) => {
const documentCount = stageDocuments[stage.name]?.length || 0;
const timelineEntry = getLatestStageTimelineEntry(stage.name);
const stageEntries = getStageTimelineEntries(stage.name);
return (
<div key={stage.id} className="flex gap-4">
<div className="flex flex-col items-center">
@ -884,29 +913,59 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
</button>
)}
</div>
{(timelineEntry?.timestamp || stage.date) && (
<div className="flex items-center gap-1 text-sm text-slate-600">
<Calendar className="w-4 h-4" />
<span>{formatDateTime(timelineEntry?.timestamp || stage.date)}</span>
{(stageEntries[0]?.timestamp || stage.date) && (
<div className="flex items-center gap-1 text-xs text-slate-500 bg-slate-50 px-2 py-0.5 rounded-full border border-slate-100">
<Calendar className="w-3 h-3" />
<span>{formatDateTime(stageEntries[0]?.timestamp || stage.date)}</span>
</div>
)}
</div>
<p className="text-slate-600 text-sm">{stage.description}</p>
{timelineEntry && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<Badge className="bg-blue-100 text-blue-700 border-blue-300">{timelineEntry.action || 'Updated'}</Badge>
<span className="text-xs text-slate-500">by {timelineEntry.user || 'System'}</span>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3">
<div className="space-y-2">
<div>
<Label className="text-xs text-slate-600">Remarks:</Label>
<p className="text-sm text-slate-700 mt-1">{timelineEntry.remarks || 'No remarks provided.'}</p>
{stageEntries.length > 0 && (
<div className="mt-3 space-y-3">
{stageEntries.map((entry: any, entryIdx: number) => {
const rawRemarks = entry.remarks || entry.comments || '';
const isAttachment = rawRemarks?.startsWith('Attachment:');
const remarksContent = isAttachment
? rawRemarks.replace('Attachment:', '').trim()
: rawRemarks;
return (
<div key={entryIdx} className="group">
<div className="flex items-center gap-2 mb-1">
<Badge className={`
text-[10px] h-4 px-1.5
${entry.action?.toLowerCase().includes('rejected') || entry.action?.toLowerCase().includes('revoked')
? 'bg-red-100 text-red-700'
: entry.action?.toLowerCase().includes('approved')
? 'bg-emerald-100 text-emerald-700'
: 'bg-blue-100 text-blue-700'}
`}>
{entry.action || 'Action'}
</Badge>
<span className="text-[10px] text-slate-500 font-medium">
by {entry.user || 'System'} {formatDateTime(entry.timestamp)}
</span>
</div>
<div className={`
p-2.5 rounded-lg border text-sm
${isAttachment
? 'bg-amber-50/50 border-amber-100 text-amber-900'
: 'bg-slate-50 border-slate-100 text-slate-700'}
`}>
{isAttachment ? (
<div className="flex items-center gap-2">
<FileText className="w-3.5 h-3.5 text-amber-600" />
<span className="font-medium truncate">{remarksContent}</span>
</div>
) : (
<p className="leading-relaxed">{remarksContent || 'No remarks provided.'}</p>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>

View File

@ -5,6 +5,7 @@ export const RESIGNATION_DOCUMENT_TYPES = [
"Legal Communication",
"Handover Document",
"Settlement Supporting Document",
"PPT Presentation",
"Other",
] as const;
@ -32,7 +33,7 @@ export const TERMINATION_DOCUMENT_TYPES = [
export const TERMINATION_STAGE_OPTIONS = [
"Submitted",
"RBM Review",
"RBM + DD-ZM Review",
"ZBH Review",
"DD Lead Review",
"Legal Verification",

View File

@ -11,6 +11,9 @@
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #da291c;
--primary-600: #da291c;
--primary-700: #b82216;
--primary-50: #fef2f2;
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.95 0.0058 264.53);
--secondary-foreground: #030213;
@ -99,6 +102,9 @@
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-50: var(--primary-50);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);