DD Lead treated as natiolwide user and in app notification enhanced created the in app notification screen with pagination
This commit is contained in:
parent
0e8d6bf49f
commit
37b3075a08
@ -46,6 +46,7 @@ import { DealerRelocationPage } from '@/features/relocation/pages/DealerRelocati
|
||||
import QuestionnaireBuilder from '@/components/admin/QuestionnaireBuilder';
|
||||
import QuestionnaireList from '@/components/admin/QuestionnaireList';
|
||||
import { WorkNotesPage } from '@/features/onboarding/pages/WorkNotesPage';
|
||||
import { NotificationsPage } from '@/pages/NotificationsPage';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { User } from '@/lib/mock-data';
|
||||
import { toast } from 'sonner';
|
||||
@ -158,6 +159,7 @@ export default function App() {
|
||||
'/approval-policies': 'Approval Policies',
|
||||
'/fdd-dashboard': 'FDD Dashboard',
|
||||
'/fdd-details': 'Audit Workspace',
|
||||
'/notifications': 'Notifications',
|
||||
};
|
||||
return titles[pathname] || 'Dashboard';
|
||||
};
|
||||
@ -246,6 +248,7 @@ export default function App() {
|
||||
|
||||
{/* Other Modules */}
|
||||
<Route path="/users" element={<UserManagementPage />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/approval-policies" element={
|
||||
(hasRole(['Super Admin', 'DD Admin']))
|
||||
? <ApprovalPoliciesPage />
|
||||
|
||||
@ -25,13 +25,19 @@ export function Header({ title, onRefresh }: HeaderProps) {
|
||||
const { user: currentUser } = useSelector((state: RootState) => state.auth);
|
||||
const { socket } = useSocket();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const res: any = await notificationService.getNotifications();
|
||||
const res: any = await notificationService.getNotifications(1, 15);
|
||||
if (res.success) {
|
||||
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) {
|
||||
console.error('Fetch notifications error:', error);
|
||||
@ -44,7 +50,8 @@ export function Header({ title, onRefresh }: HeaderProps) {
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
socket.on('notification', (newNotification: Notification) => {
|
||||
setNotifications(prev => [newNotification, ...prev]);
|
||||
setNotifications(prev => [newNotification, ...prev].slice(0, 15));
|
||||
setUnreadCount(prev => prev + 1);
|
||||
toast(newNotification.title, {
|
||||
description: newNotification.message,
|
||||
action: newNotification.link ? {
|
||||
@ -60,14 +67,20 @@ export function Header({ title, onRefresh }: HeaderProps) {
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const handleMarkAsRead = async (id: string) => {
|
||||
const handleNotificationClick = async (notif: Notification) => {
|
||||
try {
|
||||
const res: any = await notificationService.markAsRead(id);
|
||||
if (!notif.isRead) {
|
||||
const res: any = await notificationService.markAsRead(notif.id);
|
||||
if (res.success) {
|
||||
setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n));
|
||||
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) {
|
||||
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();
|
||||
if (res.success) {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
|
||||
setUnreadCount(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Mark all as read error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.isRead).length;
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b border-slate-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -161,7 +173,7 @@ export function Header({ title, onRefresh }: HeaderProps) {
|
||||
key={notification.id}
|
||||
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">
|
||||
<p className="text-slate-900 text-sm font-medium">{notification.title}</p>
|
||||
@ -178,9 +190,12 @@ export function Header({ title, onRefresh }: HeaderProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 border-t text-center">
|
||||
<p className="text-xs text-slate-400">
|
||||
Stay updated with your mentions and tasks
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/notifications'}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
View All Notifications
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@ -301,6 +301,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
|
||||
url: doc.filePath,
|
||||
})),
|
||||
],
|
||||
participants: s.participants || []
|
||||
};
|
||||
setFnfCase(finalMapped);
|
||||
|
||||
|
||||
@ -26,8 +26,6 @@ import { EmailTemplates } from '@/features/master/components/EmailTemplates';
|
||||
import { LocationManagement } from '@/features/master/components/LocationManagement';
|
||||
import { ASMDialog } from '@/features/master/components/ASMDialog';
|
||||
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 { RegionDialog } from '@/features/master/components/RegionDialog';
|
||||
import { TemplateDialog } from '@/features/master/components/TemplateDialog';
|
||||
@ -40,7 +38,7 @@ import { RootState } from '@/store';
|
||||
export const MasterPage: React.FC = () => {
|
||||
const { fetchInitialData, fetchAreas } = useMasterData();
|
||||
const {
|
||||
asms, zonalManagerMappings, ddLeads,
|
||||
asms, zonalManagerMappings,
|
||||
allStates,
|
||||
allDistricts,
|
||||
users,
|
||||
@ -77,12 +75,6 @@ export const MasterPage: React.FC = () => {
|
||||
const [selectedZMZone, setSelectedZMZone] = useState('');
|
||||
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
|
||||
const [showRoleDialog, setShowRoleDialog] = useState(false);
|
||||
@ -207,13 +199,6 @@ export const MasterPage: React.FC = () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
@ -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); }}
|
||||
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} />
|
||||
</TabsContent>
|
||||
@ -641,19 +593,6 @@ export const MasterPage: React.FC = () => {
|
||||
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}
|
||||
/>
|
||||
<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} />
|
||||
<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} />
|
||||
|
||||
@ -348,6 +348,16 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
if (!appName || appName === 'Application' || appName === 'Resignation') setAppName(data.dealer?.businessName || 'Resignation');
|
||||
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) {
|
||||
console.error(`Failed to fetch ${requestType} details:`, error);
|
||||
|
||||
171
src/pages/NotificationsPage.tsx
Normal file
171
src/pages/NotificationsPage.tsx
Normal 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;
|
||||
@ -14,8 +14,12 @@ export interface Notification {
|
||||
}
|
||||
|
||||
export const notificationService = {
|
||||
getNotifications: async () => {
|
||||
const response = await client.get(`${API_BASE}/notifications`);
|
||||
getNotifications: async (page?: number, limit?: number) => {
|
||||
let url = `${API_BASE}/notifications`;
|
||||
if (page && limit) {
|
||||
url += `?page=${page}&limit=${limit}`;
|
||||
}
|
||||
const response = await client.get(url);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user