DD Lead treated as natiolwide user and in app notification enhanced created the in app notification screen with pagination

This commit is contained in:
laxmanhalaki 2026-04-20 01:52:35 +05:30
parent 0e8d6bf49f
commit 37b3075a08
7 changed files with 220 additions and 77 deletions

View File

@ -46,6 +46,7 @@ import { DealerRelocationPage } from '@/features/relocation/pages/DealerRelocati
import QuestionnaireBuilder from '@/components/admin/QuestionnaireBuilder'; import QuestionnaireBuilder from '@/components/admin/QuestionnaireBuilder';
import QuestionnaireList from '@/components/admin/QuestionnaireList'; import QuestionnaireList from '@/components/admin/QuestionnaireList';
import { WorkNotesPage } from '@/features/onboarding/pages/WorkNotesPage'; import { WorkNotesPage } from '@/features/onboarding/pages/WorkNotesPage';
import { NotificationsPage } from '@/pages/NotificationsPage';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { User } from '@/lib/mock-data'; import { User } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -158,6 +159,7 @@ export default function App() {
'/approval-policies': 'Approval Policies', '/approval-policies': 'Approval Policies',
'/fdd-dashboard': 'FDD Dashboard', '/fdd-dashboard': 'FDD Dashboard',
'/fdd-details': 'Audit Workspace', '/fdd-details': 'Audit Workspace',
'/notifications': 'Notifications',
}; };
return titles[pathname] || 'Dashboard'; return titles[pathname] || 'Dashboard';
}; };
@ -246,6 +248,7 @@ export default function App() {
{/* Other Modules */} {/* Other Modules */}
<Route path="/users" element={<UserManagementPage />} /> <Route path="/users" element={<UserManagementPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/approval-policies" element={ <Route path="/approval-policies" element={
(hasRole(['Super Admin', 'DD Admin'])) (hasRole(['Super Admin', 'DD Admin']))
? <ApprovalPoliciesPage /> ? <ApprovalPoliciesPage />

View File

@ -25,13 +25,19 @@ export function Header({ title, onRefresh }: HeaderProps) {
const { user: currentUser } = useSelector((state: RootState) => state.auth); const { user: currentUser } = useSelector((state: RootState) => state.auth);
const { socket } = useSocket(); const { socket } = useSocket();
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState<number>(0);
useEffect(() => { useEffect(() => {
const fetchNotifications = async () => { const fetchNotifications = async () => {
try { try {
const res: any = await notificationService.getNotifications(); const res: any = await notificationService.getNotifications(1, 15);
if (res.success) { if (res.success) {
setNotifications(res.data); setNotifications(res.data);
if (res.pagination && res.pagination.unreadCount !== undefined) {
setUnreadCount(res.pagination.unreadCount);
} else {
setUnreadCount(res.data.filter((n: any) => !n.isRead).length);
}
} }
} catch (error) { } catch (error) {
console.error('Fetch notifications error:', error); console.error('Fetch notifications error:', error);
@ -44,7 +50,8 @@ export function Header({ title, onRefresh }: HeaderProps) {
useEffect(() => { useEffect(() => {
if (socket) { if (socket) {
socket.on('notification', (newNotification: Notification) => { socket.on('notification', (newNotification: Notification) => {
setNotifications(prev => [newNotification, ...prev]); setNotifications(prev => [newNotification, ...prev].slice(0, 15));
setUnreadCount(prev => prev + 1);
toast(newNotification.title, { toast(newNotification.title, {
description: newNotification.message, description: newNotification.message,
action: newNotification.link ? { action: newNotification.link ? {
@ -60,14 +67,20 @@ export function Header({ title, onRefresh }: HeaderProps) {
} }
}, [socket]); }, [socket]);
const handleMarkAsRead = async (id: string) => { const handleNotificationClick = async (notif: Notification) => {
try { try {
const res: any = await notificationService.markAsRead(id); if (!notif.isRead) {
if (res.success) { const res: any = await notificationService.markAsRead(notif.id);
setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n)); if (res.success) {
setNotifications(prev => prev.map(n => n.id === notif.id ? { ...n, isRead: true } : n));
setUnreadCount(prev => Math.max(0, prev - 1));
}
}
if (notif.link) {
window.location.href = notif.link;
} }
} catch (error) { } catch (error) {
console.error('Mark as read error:', error); console.error('Notification click error:', error);
} }
}; };
@ -76,14 +89,13 @@ export function Header({ title, onRefresh }: HeaderProps) {
const res: any = await notificationService.markAllAsRead(); const res: any = await notificationService.markAllAsRead();
if (res.success) { if (res.success) {
setNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
setUnreadCount(0);
} }
} catch (error) { } catch (error) {
console.error('Mark all as read error:', error); console.error('Mark all as read error:', error);
} }
}; };
const unreadCount = notifications.filter(n => !n.isRead).length;
return ( return (
<header className="bg-white border-b border-slate-200 px-6 py-4"> <header className="bg-white border-b border-slate-200 px-6 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -161,7 +173,7 @@ export function Header({ title, onRefresh }: HeaderProps) {
key={notification.id} key={notification.id}
className={`p-3 cursor-pointer flex items-start gap-3 ${!notification.isRead ? 'bg-blue-50/50' : '' className={`p-3 cursor-pointer flex items-start gap-3 ${!notification.isRead ? 'bg-blue-50/50' : ''
}`} }`}
onClick={() => handleMarkAsRead(notification.id)} onClick={() => handleNotificationClick(notification)}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-slate-900 text-sm font-medium">{notification.title}</p> <p className="text-slate-900 text-sm font-medium">{notification.title}</p>
@ -178,9 +190,12 @@ export function Header({ title, onRefresh }: HeaderProps) {
)} )}
</div> </div>
<div className="p-3 border-t text-center"> <div className="p-3 border-t text-center">
<p className="text-xs text-slate-400"> <button
Stay updated with your mentions and tasks onClick={() => window.location.href = '/notifications'}
</p> className="text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
>
View All Notifications
</button>
</div> </div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@ -301,6 +301,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
url: doc.filePath, url: doc.filePath,
})), })),
], ],
participants: s.participants || []
}; };
setFnfCase(finalMapped); setFnfCase(finalMapped);

View File

@ -26,8 +26,6 @@ import { EmailTemplates } from '@/features/master/components/EmailTemplates';
import { LocationManagement } from '@/features/master/components/LocationManagement'; import { LocationManagement } from '@/features/master/components/LocationManagement';
import { ASMDialog } from '@/features/master/components/ASMDialog'; import { ASMDialog } from '@/features/master/components/ASMDialog';
import { ZMDialog } from '@/features/master/components/ZMDialog'; import { ZMDialog } from '@/features/master/components/ZMDialog';
import { DDLeadManagement } from '@/features/master/components/DDLeadManagement';
import { DDLeadDialog } from '@/features/master/components/DDLeadDialog';
import { ZoneDialog } from '@/features/master/components/ZoneDialog'; import { ZoneDialog } from '@/features/master/components/ZoneDialog';
import { RegionDialog } from '@/features/master/components/RegionDialog'; import { RegionDialog } from '@/features/master/components/RegionDialog';
import { TemplateDialog } from '@/features/master/components/TemplateDialog'; import { TemplateDialog } from '@/features/master/components/TemplateDialog';
@ -40,7 +38,7 @@ import { RootState } from '@/store';
export const MasterPage: React.FC = () => { export const MasterPage: React.FC = () => {
const { fetchInitialData, fetchAreas } = useMasterData(); const { fetchInitialData, fetchAreas } = useMasterData();
const { const {
asms, zonalManagerMappings, ddLeads, asms, zonalManagerMappings,
allStates, allStates,
allDistricts, allDistricts,
users, users,
@ -77,12 +75,6 @@ export const MasterPage: React.FC = () => {
const [selectedZMZone, setSelectedZMZone] = useState(''); const [selectedZMZone, setSelectedZMZone] = useState('');
const [selectedZMRegions, setSelectedZMRegions] = useState<string[]>([]); const [selectedZMRegions, setSelectedZMRegions] = useState<string[]>([]);
// DD-Lead Management State
const [showDDLeadDialog, setShowDDLeadDialog] = useState(false);
const [editingDDLeadId, setEditingDDLeadId] = useState<string | null>(null);
const [ddLeadManagerId, setDdLeadManagerId] = useState('');
const [ddLeadStatus, setDdLeadStatus] = useState<'active' | 'inactive'>('active');
const [selectedDDLeadZones, setSelectedDDLeadZones] = useState<string[]>([]);
// Role Management State // Role Management State
const [showRoleDialog, setShowRoleDialog] = useState(false); const [showRoleDialog, setShowRoleDialog] = useState(false);
@ -207,13 +199,6 @@ export const MasterPage: React.FC = () => {
setShowZMDialog(true); setShowZMDialog(true);
}; };
const handleEditDDLead = (lead: any) => {
setEditingDDLeadId(lead.id);
setDdLeadManagerId(lead.id);
setDdLeadStatus(lead.status?.toLowerCase() === 'active' ? 'active' : 'inactive');
setSelectedDDLeadZones(lead.assignedZoneIds || []);
setShowDDLeadDialog(true);
};
const handleSaveZM = async () => { const handleSaveZM = async () => {
if (!zmManagerId || !selectedZMZone) { if (!zmManagerId || !selectedZMZone) {
@ -242,31 +227,6 @@ export const MasterPage: React.FC = () => {
} }
}; };
const handleSaveDDLead = async () => {
if (!ddLeadManagerId) {
toast.error('Manager user is required');
return;
}
try {
const payload = {
userId: ddLeadManagerId,
zoneIds: selectedDDLeadZones,
status: ddLeadStatus
};
const res = await (masterService as any).saveDDLead(payload) as any;
if (res.success) {
toast.success(`DD Lead ${editingDDLeadId ? 'updated' : 'assigned'} successfully`);
setShowDDLeadDialog(false);
fetchInitialData();
} else {
toast.error(res.message || 'Failed to save DD Lead');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Failed to save DD Lead';
toast.error(msg);
}
};
const handleSaveZone = async () => { const handleSaveZone = async () => {
@ -536,14 +496,6 @@ export const MasterPage: React.FC = () => {
<ASMManagement selectedZone={selectedZone} onAddASM={() => { setEditingASMId(null); setAsmManagerId(''); setSelectedASMZone(selectedZone === 'all' ? '' : selectedZone); setSelectedASMRegion(''); setSelectedASMStates([]); setSelectedASMDistricts([]); setShowASMDialog(true); }} <ASMManagement selectedZone={selectedZone} onAddASM={() => { setEditingASMId(null); setAsmManagerId(''); setSelectedASMZone(selectedZone === 'all' ? '' : selectedZone); setSelectedASMRegion(''); setSelectedASMStates([]); setSelectedASMDistricts([]); setShowASMDialog(true); }}
onEditASM={handleEditASM} onDeleteASM={() => toast.error('ASM deletion restricted')} /> onEditASM={handleEditASM} onDeleteASM={() => toast.error('ASM deletion restricted')} />
<DDLeadManagement selectedZone={selectedZone}
onAddLead={() => {
setEditingDDLeadId(null); setDdLeadManagerId('');
setDdLeadStatus('active'); setSelectedDDLeadZones([]);
setShowDDLeadDialog(true);
}}
onEditLead={handleEditDDLead}
onDeleteLead={() => toast.error('DD-Lead deletion restricted')} />
<UserManagementTable userAssignedData={users.length > 0 ? users : asms} /> <UserManagementTable userAssignedData={users.length > 0 ? users : asms} />
</TabsContent> </TabsContent>
@ -641,19 +593,6 @@ export const MasterPage: React.FC = () => {
onSave={handleSaveZM} 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} 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}
/> />
<DDLeadDialog
isOpen={showDDLeadDialog}
onOpenChange={setShowDDLeadDialog}
editingLeadId={editingDDLeadId}
leadManagerId={ddLeadManagerId}
setLeadManagerId={setDdLeadManagerId}
leadStatus={ddLeadStatus}
setLeadStatus={setDdLeadStatus}
selectedZones={selectedDDLeadZones}
setSelectedZones={setSelectedDDLeadZones}
onSave={handleSaveDDLead}
userAssignedData={users.length > 0 ? users.map(u => ({...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles, assignedZoneIds: (ddLeads.find(l => l.id === u.id)?.assignedZoneIds || [])})) : asms}
/>
<TemplateDialog isOpen={showTemplateDialog} onOpenChange={setShowTemplateDialog} editingTemplate={editingTemplate} setEditingTemplate={setEditingTemplate} testDataInput={testDataInput} setTestDataInput={setTestDataInput} previewLoading={previewLoading} handlePreviewTemplate={handlePreviewTemplate} previewContent={previewContent} handleSaveTemplate={handleSaveTemplate} /> <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} /> <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} />
<RoleDialog isOpen={showRoleDialog} onOpenChange={setShowRoleDialog} role={editingRole} onSave={handleSaveRole} /> <RoleDialog isOpen={showRoleDialog} onOpenChange={setShowRoleDialog} role={editingRole} onSave={handleSaveRole} />

View File

@ -348,6 +348,16 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
if (!appName || appName === 'Application' || appName === 'Resignation') setAppName(data.dealer?.businessName || 'Resignation'); if (!appName || appName === 'Application' || appName === 'Resignation') setAppName(data.dealer?.businessName || 'Resignation');
if (!regNumber) setRegNumber(data.resignationId || ''); if (!regNumber) setRegNumber(data.resignationId || '');
} }
} else if (requestType === 'fnf') {
const { API } = await import('@/api/API');
const res: any = await API.getFnFSettlementById(requestId);
if (res.data?.success) {
data = res.data.fnf;
if (externalParticipants.length === 0 && data.participants) setExternalParticipants(data.participants || []);
const dealerName = data.outlet?.dealer?.fullName || data.dealer?.fullName || "F&F Settlement";
if (!appName || appName === 'Application' || appName === 'F&F Settlement') setAppName(dealerName);
if (!regNumber) setRegNumber(data.settlementId || '');
}
} }
} catch (error) { } catch (error) {
console.error(`Failed to fetch ${requestType} details:`, error); console.error(`Failed to fetch ${requestType} details:`, error);

View File

@ -0,0 +1,171 @@
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Bell, Check, Clock, ChevronLeft, ChevronRight } from 'lucide-react';
import { notificationService, Notification } from '@/services/notification.service';
import { useNavigate } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
export const NotificationsPage: React.FC = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const limit = 15;
const navigate = useNavigate();
const fetchNotifications = async (currentPage: number) => {
try {
setLoading(true);
const res: any = await notificationService.getNotifications(currentPage, limit);
if (res.success) {
setNotifications(res.data);
if (res.pagination) {
setTotalPages(res.pagination.totalPages);
}
}
} catch (error) {
console.error('Failed to fetch notifications', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchNotifications(page);
}, [page]);
const handleMarkAsRead = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
await notificationService.markAsRead(id);
fetchNotifications(page);
};
const handleMarkAllAsRead = async () => {
await notificationService.markAllAsRead();
fetchNotifications(page);
};
const handleNotificationClick = async (notif: Notification) => {
if (!notif.isRead) {
await notificationService.markAsRead(notif.id);
}
if (notif.link) {
// Support both relative or absolute links dynamically
const url = notif.link;
try {
const parsedUrl = new URL(url);
if (parsedUrl.origin === window.location.origin) {
navigate(parsedUrl.pathname + parsedUrl.search + parsedUrl.hash);
} else {
window.open(url, '_blank');
}
} catch (e) {
navigate(url);
}
} else {
fetchNotifications(page); // refresh list manually if no link
}
};
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Bell className="w-6 h-6 text-blue-600" />
Notifications Center
</h1>
<p className="text-gray-500 text-sm mt-1">View and manage all your alerts</p>
</div>
<Button
variant="outline"
onClick={handleMarkAllAsRead}
disabled={loading || !notifications.some(n => !n.isRead)}
>
<Check className="w-4 h-4 mr-2" />
Mark all as read
</Button>
</div>
<Card className="shadow-sm border-gray-200">
<CardContent className="p-0">
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
<Bell className="w-12 h-12 text-gray-300 mb-4" />
<p className="text-lg font-medium">No notifications yet</p>
<p className="text-sm">When you get updates, they'll show up here.</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{notifications.map(notif => (
<div
key={notif.id}
className={`p-4 hover:bg-gray-50 transition-colors flex items-start gap-4 cursor-pointer ${!notif.isRead ? 'bg-blue-50/50' : ''}`}
onClick={() => handleNotificationClick(notif)}
>
<div className="mt-1 flex-shrink-0 cursor-pointer" onClick={(e) => handleMarkAsRead(notif.id, e)}>
{!notif.isRead ? (
<div className="w-3 h-3 bg-blue-600 rounded-full shadow-sm"></div>
) : (
<div className="w-3 h-3 border-2 border-gray-300 rounded-full"></div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start mb-1">
<h4 className={`text-sm font-semibold ${!notif.isRead ? 'text-gray-900' : 'text-gray-700'}`}>
{notif.title}
</h4>
<div className="flex items-center text-xs text-gray-500 whitespace-nowrap ml-4">
<Clock className="w-3 h-3 mr-1" />
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
</div>
</div>
<p className="text-sm text-gray-600 line-clamp-2">{notif.message}</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Pagination Controls */}
{!loading && totalPages > 1 && (
<div className="flex items-center justify-between mt-6 px-4">
<p className="text-sm text-gray-600">
Showing page <span className="font-medium text-gray-900">{page}</span> of{' '}
<span className="font-medium text-gray-900">{totalPages}</span>
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page === totalPages}
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
);
};
export default NotificationsPage;

View File

@ -14,8 +14,12 @@ export interface Notification {
} }
export const notificationService = { export const notificationService = {
getNotifications: async () => { getNotifications: async (page?: number, limit?: number) => {
const response = await client.get(`${API_BASE}/notifications`); let url = `${API_BASE}/notifications`;
if (page && limit) {
url += `?page=${page}&limit=${limit}`;
}
const response = await client.get(url);
return response.data; return response.data;
}, },