Re_Figma_Code/src/components/layout/PageLayout/PageLayout.tsx

486 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 },
{ id: 'my-requests', label: 'My Requests', icon: User, 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>
);
}