485 lines
18 KiB
TypeScript
485 lines
18 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
|
import { Bell, Settings, User, Plus, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, List, Share2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { ReLogo } from '@/assets';
|
|
import notificationApi, { Notification } from '@/services/notificationApi';
|
|
import { getSocket, joinUserRoom } from '@/utils/socket';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import { TokenManager } from '@/utils/tokenManager';
|
|
|
|
interface PageLayoutProps {
|
|
children: React.ReactNode;
|
|
currentPage?: string;
|
|
onNavigate?: (page: string) => void;
|
|
onNewRequest?: () => void;
|
|
onLogout?: () => void;
|
|
}
|
|
|
|
export function PageLayout({ children, currentPage = 'dashboard', onNavigate, onNewRequest, onLogout }: PageLayoutProps) {
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
|
const { user } = useAuth();
|
|
|
|
// Check if user is a Dealer
|
|
const isDealer = useMemo(() => {
|
|
try {
|
|
const userData = TokenManager.getUserData();
|
|
return userData?.jobTitle === 'Dealer';
|
|
} catch (error) {
|
|
console.error('[PageLayout] Error checking dealer status:', error);
|
|
return false;
|
|
}
|
|
}, []);
|
|
|
|
// Get user initials for avatar
|
|
const getUserInitials = () => {
|
|
try {
|
|
if (user?.displayName && typeof user.displayName === 'string') {
|
|
const names = user.displayName.split(' ').filter(Boolean);
|
|
if (names.length >= 2) {
|
|
return `${names[0]?.[0] || ''}${names[names.length - 1]?.[0] || ''}`.toUpperCase();
|
|
}
|
|
return user.displayName.substring(0, 2).toUpperCase();
|
|
}
|
|
if (user?.email && typeof user.email === 'string') {
|
|
return user.email.substring(0, 2).toUpperCase();
|
|
}
|
|
return 'U';
|
|
} catch (error) {
|
|
console.error('[PageLayout] Error getting user initials:', error);
|
|
return 'U';
|
|
}
|
|
};
|
|
|
|
const menuItems = useMemo(() => {
|
|
const items = [
|
|
{ id: 'dashboard', label: 'Dashboard', icon: Home },
|
|
// Add "All Requests" for all users (admin sees org-level, regular users see their participant requests)
|
|
{ id: 'requests', label: 'All Requests', icon: List, adminOnly: false }
|
|
// { id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
|
|
];
|
|
|
|
// Add remaining menu items (exclude "My Requests" for dealers)
|
|
if (!isDealer) {
|
|
items.push({ id: 'my-requests', label: 'My Requests', icon: User });
|
|
}
|
|
|
|
items.push(
|
|
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
|
|
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
|
|
{ id: 'shared-summaries', label: 'Shared Summary', icon: Share2 }
|
|
);
|
|
|
|
return items;
|
|
}, [isDealer]);
|
|
|
|
const toggleSidebar = () => {
|
|
setSidebarOpen(!sidebarOpen);
|
|
};
|
|
|
|
const handleNotificationClick = async (notification: Notification) => {
|
|
try {
|
|
// Mark as read
|
|
if (!notification.isRead) {
|
|
await notificationApi.markAsRead(notification.notificationId);
|
|
setNotifications(prev =>
|
|
prev.map(n => n.notificationId === notification.notificationId ? { ...n, isRead: true } : n)
|
|
);
|
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
|
}
|
|
|
|
// Navigate to the request if URL provided
|
|
if (notification.actionUrl && onNavigate) {
|
|
// Extract request number from URL (e.g., /request/REQ-2025-12345)
|
|
const requestNumber = notification.metadata?.requestNumber;
|
|
if (requestNumber) {
|
|
// Determine which tab to open based on notification type
|
|
let navigationUrl = `request/${requestNumber}`;
|
|
|
|
// Work note related notifications should open Work Notes tab
|
|
if (notification.notificationType === 'mention' ||
|
|
notification.notificationType === 'comment' ||
|
|
notification.notificationType === 'worknote') {
|
|
navigationUrl += '?tab=worknotes';
|
|
}
|
|
|
|
// Navigate to request detail page
|
|
onNavigate(navigationUrl);
|
|
}
|
|
}
|
|
|
|
setNotificationsOpen(false);
|
|
} catch (error) {
|
|
console.error('[PageLayout] Error handling notification click:', error);
|
|
}
|
|
};
|
|
|
|
const handleMarkAllAsRead = async () => {
|
|
try {
|
|
await notificationApi.markAllAsRead();
|
|
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
|
|
setUnreadCount(0);
|
|
} catch (error) {
|
|
console.error('[PageLayout] Error marking all as read:', error);
|
|
}
|
|
};
|
|
|
|
// Fetch notifications and setup real-time updates
|
|
useEffect(() => {
|
|
const userId = (user as any)?.userId;
|
|
if (!userId) return;
|
|
|
|
let mounted = true;
|
|
|
|
// Fetch initial notifications (only 4 for dropdown preview)
|
|
const fetchNotifications = async () => {
|
|
try {
|
|
const result = await notificationApi.list({ page: 1, limit: 4, unreadOnly: false });
|
|
if (!mounted) return;
|
|
|
|
const notifs = result.data?.notifications || [];
|
|
setNotifications(notifs);
|
|
setUnreadCount(result.data?.unreadCount || 0);
|
|
} catch (error) {
|
|
console.error('[PageLayout] Failed to fetch notifications:', error);
|
|
}
|
|
};
|
|
|
|
fetchNotifications();
|
|
|
|
// Setup socket for real-time notifications
|
|
const socket = getSocket(); // Uses getSocketBaseUrl() helper internally
|
|
|
|
if (socket) {
|
|
// Join user's personal notification room
|
|
joinUserRoom(socket, userId);
|
|
|
|
// Listen for new notifications
|
|
const handleNewNotification = (data: { notification: Notification }) => {
|
|
if (!mounted) return;
|
|
|
|
setNotifications(prev => [data.notification, ...prev].slice(0, 4)); // Keep latest 4 for dropdown
|
|
setUnreadCount(prev => prev + 1);
|
|
};
|
|
|
|
socket.on('notification:new', handleNewNotification);
|
|
|
|
return () => {
|
|
mounted = false;
|
|
socket.off('notification:new', handleNewNotification);
|
|
};
|
|
}
|
|
|
|
return () => { mounted = false; };
|
|
}, [user]);
|
|
|
|
// Handle responsive behavior: sidebar open on desktop, closed on mobile
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
// 768px is the md breakpoint in Tailwind
|
|
if (window.innerWidth >= 768) {
|
|
setSidebarOpen(true); // Always open on desktop
|
|
} else {
|
|
setSidebarOpen(false); // Closed by default on mobile
|
|
}
|
|
};
|
|
|
|
// Set initial state
|
|
handleResize();
|
|
|
|
// Add event listener
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
// Cleanup
|
|
return () => window.removeEventListener('resize', handleResize);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="min-h-screen flex w-full bg-background">
|
|
{/* Mobile Overlay */}
|
|
{sidebarOpen && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
|
onClick={() => setSidebarOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Sidebar - Hidden on mobile by default, toggleable on desktop */}
|
|
<aside className={`
|
|
fixed md:relative
|
|
inset-y-0 left-0
|
|
w-64
|
|
transform transition-transform duration-300 ease-in-out
|
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
|
md:translate-x-0
|
|
${sidebarOpen ? 'md:w-64' : 'md:w-0'}
|
|
z-50 md:z-auto
|
|
flex-shrink-0
|
|
border-r border-gray-800 bg-black
|
|
flex flex-col
|
|
overflow-hidden
|
|
`}>
|
|
<div className={`w-64 h-full flex flex-col overflow-hidden ${!sidebarOpen ? 'md:hidden' : ''}`}>
|
|
<div className="p-4 border-b border-gray-800 flex-shrink-0">
|
|
<div className="flex flex-col items-center justify-center">
|
|
<img
|
|
src={ReLogo}
|
|
alt="Royal Enfield Logo"
|
|
className="h-10 w-auto max-w-[168px] object-contain"
|
|
/>
|
|
<p className="text-xs text-gray-400 text-center mt-1 truncate">RE Flow</p>
|
|
</div>
|
|
</div>
|
|
<div className="p-3 flex-1 overflow-y-auto">
|
|
<div className="space-y-2">
|
|
{menuItems.filter(item => !item.adminOnly || (user as any)?.role === 'ADMIN').map((item) => (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => {
|
|
if (item.id === 'admin/templates') {
|
|
onNavigate?.('admin/templates');
|
|
} else {
|
|
onNavigate?.(item.id);
|
|
}
|
|
// Close sidebar on mobile after navigation
|
|
if (window.innerWidth < 768) {
|
|
setSidebarOpen(false);
|
|
}
|
|
}}
|
|
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${currentPage === item.id
|
|
? 'bg-re-green text-white font-medium'
|
|
: 'text-gray-300 hover:bg-gray-900 hover:text-white'
|
|
}`}
|
|
>
|
|
<item.icon className="w-4 h-4 shrink-0" />
|
|
<span className="truncate">{item.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Quick Action in Sidebar - Right below menu items */}
|
|
{!isDealer && (
|
|
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
|
|
<Button
|
|
onClick={onNewRequest}
|
|
className="w-full bg-re-green hover:bg-re-green/90 text-white text-sm font-medium"
|
|
size="sm"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Raise New Request
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main Content Area */}
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
{/* Header */}
|
|
<header className="h-16 border-b border-gray-200 bg-white flex items-center justify-between px-6 shrink-0">
|
|
<div className="flex items-center gap-4 min-w-0 flex-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={toggleSidebar}
|
|
className="shrink-0 h-10 w-10 sidebar-toggle"
|
|
>
|
|
{sidebarOpen ? <PanelLeftClose className="w-5 h-5 text-gray-600" /> : <PanelLeft className="w-5 h-5 text-gray-600" />}
|
|
</Button>
|
|
{/* Search bar commented out */}
|
|
{/* <div className="relative max-w-md flex-1">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
|
<Input
|
|
placeholder="Search..."
|
|
className="pl-10 bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green w-full text-sm h-10"
|
|
/>
|
|
</div> */}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 shrink-0">
|
|
{!isDealer && (
|
|
<Button
|
|
onClick={onNewRequest}
|
|
className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm"
|
|
size="sm"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
New Request
|
|
</Button>
|
|
)}
|
|
|
|
<DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="relative shrink-0 h-10 w-10">
|
|
<Bell className="w-5 h-5" />
|
|
{unreadCount > 0 && (
|
|
<Badge className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-destructive text-destructive-foreground text-xs flex items-center justify-center p-0">
|
|
{unreadCount > 9 ? '9+' : unreadCount}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-96 max-h-[500px]">
|
|
<div className="p-3 border-b flex items-center justify-between sticky top-0 bg-white z-10">
|
|
<h4 className="font-semibold text-base">Notifications</h4>
|
|
{unreadCount > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-xs text-blue-600 hover:text-blue-700 h-auto p-1"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleMarkAllAsRead();
|
|
}}
|
|
>
|
|
Mark all as read
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="max-h-[400px] overflow-y-auto">
|
|
{notifications.length === 0 ? (
|
|
<div className="p-6 text-center">
|
|
<Bell className="w-12 h-12 text-gray-300 mx-auto mb-2" />
|
|
<p className="text-sm text-gray-500">No notifications yet</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y">
|
|
{notifications.map((notif) => (
|
|
<div
|
|
key={notif.notificationId}
|
|
className={`p-3 hover:bg-gray-50 cursor-pointer transition-colors ${!notif.isRead ? 'bg-blue-50' : ''
|
|
}`}
|
|
onClick={() => handleNotificationClick(notif)}
|
|
>
|
|
<div className="flex gap-2">
|
|
{!notif.isRead && (
|
|
<div className="w-2 h-2 rounded-full bg-blue-600 mt-1.5 shrink-0" />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<p className={`text-sm ${!notif.isRead ? 'font-semibold' : 'font-medium'}`}>
|
|
{notif.title}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
|
{notif.message}
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{notifications.length > 0 && (
|
|
<div className="p-2 border-t">
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full text-sm text-blue-600 hover:text-blue-700"
|
|
onClick={() => {
|
|
setNotificationsOpen(false);
|
|
onNavigate?.('notifications'); // Navigate to full notifications page
|
|
}}
|
|
>
|
|
View all notifications
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Avatar className="cursor-pointer shrink-0 h-10 w-10">
|
|
<AvatarImage src={user?.picture || ''} />
|
|
<AvatarFallback className="bg-re-green text-white text-sm">
|
|
{getUserInitials()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => onNavigate?.('profile')}>
|
|
<User className="w-4 h-4 mr-2" />
|
|
Profile
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onNavigate?.('settings')}>
|
|
<Settings className="w-4 h-4 mr-2" />
|
|
Settings
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => setShowLogoutDialog(true)}
|
|
className="text-red-600 focus:text-red-600"
|
|
>
|
|
<LogOut className="w-4 h-4 mr-2" />
|
|
Logout
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main Content */}
|
|
<main className="flex-1 p-2 sm:p-4 lg:p-6 overflow-auto min-w-0">
|
|
{children}
|
|
</main>
|
|
</div>
|
|
|
|
{/* Logout Confirmation Dialog */}
|
|
<AlertDialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle className="flex items-center gap-2">
|
|
<LogOut className="w-5 h-5 text-red-600" />
|
|
Confirm Logout
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription className="pt-2">
|
|
Are you sure you want to logout? You will need to sign in again to access your account.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={() => setShowLogoutDialog(false)}>
|
|
Cancel
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={async () => {
|
|
setShowLogoutDialog(false);
|
|
if (onLogout) {
|
|
try {
|
|
await onLogout();
|
|
} catch (error) {
|
|
console.error('🔴 Error calling onLogout:', error);
|
|
}
|
|
} else {
|
|
console.error('🔴 ERROR: onLogout is undefined!');
|
|
}
|
|
}}
|
|
className="bg-red-600 hover:bg-red-700 text-white focus:ring-red-600"
|
|
>
|
|
<LogOut className="w-4 h-4 mr-2" />
|
|
Logout
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
} |