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

649 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useMemo } from 'react';
import { Bell, Settings, User, Plus, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, List, Share2, ChevronDown, ChevronRight, Receipt, Shield } 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 { getForm16Permissions, type Form16Permissions } from '@/services/form16Api';
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 [form16Expanded, setForm16Expanded] = useState(() => currentPage?.startsWith('form16') ?? false);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [form16Permissions, setForm16Permissions] = useState<Form16Permissions | null>(null);
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;
}
}, []);
// Check if user is Admin (role from backend)
const isAdmin = user?.role === 'ADMIN';
// 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 }
);
// Admin-only: link to Admin panel (configuration, user management, etc.)
// Disabled: admin configurations are now under Settings (User icon → Settings → Templates, etc.)
const showAdminSidebar = false;
if (isAdmin && showAdminSidebar) {
items.push({ id: 'admin', label: 'Admin', icon: Shield });
}
return items;
}, [isDealer, isAdmin]);
// Form 16 permissions (API-driven from admin config)
useEffect(() => {
if (!user?.userId) {
setForm16Permissions(null);
return;
}
let mounted = true;
getForm16Permissions()
.then((p) => { if (mounted) setForm16Permissions(p); })
.catch((err) => {
if (mounted) {
setForm16Permissions({ canViewForm16Submission: false, canView26AS: false });
console.warn('[PageLayout] Form 16 permissions could not be loaded Form 16 menu will be hidden.', err);
}
});
return () => { mounted = false; };
}, [user?.userId]);
const showForm16Section = form16Permissions && (form16Permissions.canViewForm16Submission || form16Permissions.canView26AS);
const canViewForm16Submission = !!form16Permissions?.canViewForm16Submission;
const canView26AS = !!form16Permissions?.canView26AS;
// Keep Form 16 expanded when on a Form 16 page (dealer or RE)
const isForm16Page = currentPage === 'form16-credit-notes' || currentPage === 'form16-submit' || currentPage === 'form16-pending-submissions' || currentPage === 'form16-26as' || currentPage === 'form16-non-submitted-dealers';
const form16ExpandedOrActive = form16Expanded || isForm16Page;
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={() => {
onNavigate?.(item.id);
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>
))}
{/* Form 16 collapsible section visibility from API-driven permissions (admin config) */}
{showForm16Section && (
<div className="pt-2 border-t border-gray-800">
<button
type="button"
onClick={() => setForm16Expanded(!form16ExpandedOrActive)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
isForm16Page ? 'bg-re-green text-white font-medium' : 'text-gray-300 hover:bg-gray-900 hover:text-white'
}`}
>
<Receipt className="w-4 h-4 shrink-0" />
<span className="truncate flex-1 text-left">Form 16</span>
{form16ExpandedOrActive ? (
<ChevronDown className="w-4 h-4 shrink-0" />
) : (
<ChevronRight className="w-4 h-4 shrink-0" />
)}
</button>
{form16ExpandedOrActive && (
<div className="mt-1 ml-4 pl-2 border-l border-gray-700 space-y-0.5">
{isDealer ? (
<>
{canViewForm16Submission && (
<>
<button
type="button"
onClick={() => {
onNavigate?.('/form16/submit');
if (window.innerWidth < 768) setSidebarOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
currentPage === 'form16-submit'
? 'bg-re-green/80 text-white font-medium'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<FileText className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">Submit Form 16</span>
</button>
<button
type="button"
onClick={() => {
onNavigate?.('/form16/pending-submissions');
if (window.innerWidth < 768) setSidebarOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
currentPage === 'form16-pending-submissions'
? 'bg-re-green/80 text-white font-medium'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<FileText className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">Pending Submissions</span>
</button>
<button
type="button"
onClick={() => {
onNavigate?.('/form16/credit-notes');
if (window.innerWidth < 768) setSidebarOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
currentPage === 'form16-credit-notes'
? 'bg-re-green/80 text-white font-medium'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<Receipt className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">Credit Notes</span>
</button>
</>
)}
</>
) : (
<>
{canView26AS && (
<button
type="button"
onClick={() => {
onNavigate?.('/form16/26as');
if (window.innerWidth < 768) setSidebarOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
currentPage === 'form16-26as'
? 'bg-re-green/80 text-white font-medium'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<FileText className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">26AS Management</span>
</button>
)}
{canViewForm16Submission && (
<>
<button
type="button"
onClick={() => {
onNavigate?.('/form16/non-submitted-dealers');
if (window.innerWidth < 768) setSidebarOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
currentPage === 'form16-non-submitted-dealers'
? 'bg-re-green/80 text-white font-medium'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<Receipt className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">Non-submitted Dealers</span>
</button>
<button
type="button"
onClick={() => {
onNavigate?.('/form16/credit-notes');
if (window.innerWidth < 768) setSidebarOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors ${
currentPage === 'form16-credit-notes'
? 'bg-re-green/80 text-white font-medium'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<Receipt className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">Credit Notes</span>
</button>
</>
)}
</>
)}
</div>
)}
</div>
)}
</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>
);
}