377 lines
23 KiB
TypeScript
377 lines
23 KiB
TypeScript
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/Components/ui/sidebar';
|
|
import { Separator } from '@/Components/ui/separator';
|
|
import AppSidebar from '@/Components/AppSidebar';
|
|
import { usePage, router, Link } from '@inertiajs/react';
|
|
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
|
|
import { PageProps } from '@/types';
|
|
import {
|
|
ShieldAlert, Check, AlertCircle, X, Bell, Sun, Moon,
|
|
Settings, LogOut, ChevronDown, Activity, Sparkles
|
|
} from 'lucide-react';
|
|
import Modal from '@/Components/Modal';
|
|
import { Button } from '@/Components/ui/button';
|
|
import { Badge } from '@/Components/ui/badge';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/Components/ui/avatar';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/Components/ui/dropdown-menu';
|
|
import { formatCurrency } from '@/lib/utils';
|
|
import RetentionStickyToast from '@modules/FinancialManagement/resources/js/Components/RetentionStickyToast';
|
|
|
|
function getInitials(name: string): string {
|
|
return name
|
|
.split(' ')
|
|
.map(w => w[0])
|
|
.join('')
|
|
.toUpperCase()
|
|
.slice(0, 2);
|
|
}
|
|
|
|
export default function Authenticated({
|
|
header,
|
|
children,
|
|
}: PropsWithChildren<{ header?: ReactNode }>) {
|
|
const { flash, auth, sidebarBadges, retention_reminders, unconfirmed_payments } = usePage<PageProps>().props;
|
|
const [showErrorModal, setShowErrorModal] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [localFlash, setLocalFlash] = useState<{ success?: string; error?: string } | null>(null);
|
|
const [isDark, setIsDark] = useState(false);
|
|
|
|
const userRoles = ((auth?.roles || []) as string[]).map(r => (typeof r === 'string' ? r : (r as any).name || '').toLowerCase());
|
|
const userType = (auth?.user?.user_type || '').toLowerCase();
|
|
const isExecutive = userType === 'admin' || userType === 'super_admin' || userType === 'project_manager' || userRoles.some(r => ['super admin', 'admin', 'project manager', 'executive'].includes(r));
|
|
const isSiteOperations = userType === 'site_technical' || userType === 'supervisor' || userType === 'operator' || userRoles.some(r => r.includes('site') || r.includes('supervisor') || r.includes('operator') || r.includes('technical'));
|
|
const isContractorAdmin = !isExecutive && !isSiteOperations && (
|
|
userType === 'contractor'
|
|
|| auth?.user?.contractor_id !== null
|
|
|| userRoles.some(r => r.includes('contractor'))
|
|
);
|
|
|
|
const isAdmin = userType === 'admin' || userType === 'super_admin' || userRoles.some(r => ['super admin', 'admin'].includes(r));
|
|
const permissions = ((auth?.permissions || []) as (string | { name: string })[]).map(p => typeof p === 'string' ? p : p.name);
|
|
|
|
const canAccessApprovals = isAdmin || permissions.includes('approvals.access');
|
|
const canAccessInventory = isAdmin || permissions.includes('inventory.access');
|
|
const canAccessFinance = !isSiteOperations && (isAdmin || permissions.includes('finance.access') || isContractorAdmin);
|
|
|
|
const pendingApprovals = canAccessApprovals ? (sidebarBadges?.pending_approvals || 0) : 0;
|
|
const pendingRequisitions = canAccessInventory ? (sidebarBadges?.pending_requisitions || 0) : 0;
|
|
const pendingPurchaseOrders = canAccessInventory ? (sidebarBadges?.pending_purchase_orders || 0) : 0;
|
|
const pendingRetention = canAccessFinance ? (retention_reminders?.count || 0) : 0;
|
|
const pendingUnconfirmed = canAccessFinance ? (unconfirmed_payments?.count || 0) : 0;
|
|
|
|
const totalBadgeCount = pendingApprovals + pendingRequisitions + pendingPurchaseOrders + pendingRetention + pendingUnconfirmed;
|
|
|
|
useEffect(() => {
|
|
const isDarkMode = document.documentElement.classList.contains('dark') ||
|
|
localStorage.getItem('theme') === 'dark' ||
|
|
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
|
|
if (isDarkMode) {
|
|
document.documentElement.classList.add('dark');
|
|
setIsDark(true);
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
setIsDark(false);
|
|
}
|
|
}, []);
|
|
|
|
const toggleTheme = () => {
|
|
if (document.documentElement.classList.contains('dark')) {
|
|
document.documentElement.classList.remove('dark');
|
|
localStorage.setItem('theme', 'light');
|
|
setIsDark(false);
|
|
} else {
|
|
document.documentElement.classList.add('dark');
|
|
localStorage.setItem('theme', 'dark');
|
|
setIsDark(true);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (flash?.error) {
|
|
const error = flash.error.toLowerCase();
|
|
const isPermissionError = ['permission', 'unauthorized', 'access denied', 'not have access', 'only platform admin']
|
|
.some(message => error.includes(message));
|
|
|
|
if (isPermissionError) {
|
|
setErrorMessage(flash.error);
|
|
setShowErrorModal(true);
|
|
} else {
|
|
setLocalFlash({ error: flash.error });
|
|
}
|
|
} else if (flash?.success) {
|
|
setLocalFlash({ success: flash.success });
|
|
} else {
|
|
setLocalFlash(null);
|
|
}
|
|
}, [flash]);
|
|
|
|
useEffect(() => {
|
|
const unregisterException = router.on('exception', (event) => {
|
|
console.error('Inertia Exception:', event.detail.exception);
|
|
setLocalFlash({ error: 'A server error occurred. Please try again later.' });
|
|
});
|
|
|
|
const unregisterInvalid = router.on('invalid', (event) => {
|
|
console.error('Inertia Invalid Response:', event.detail.response);
|
|
setLocalFlash({ error: `Server returned an error (${event.detail.response.status}). Please try again.` });
|
|
});
|
|
|
|
const handleOffline = () => {
|
|
setLocalFlash({ error: 'You are offline. Please check your network connection.' });
|
|
};
|
|
window.addEventListener('offline', handleOffline);
|
|
|
|
return () => {
|
|
unregisterException();
|
|
unregisterInvalid();
|
|
window.removeEventListener('offline', handleOffline);
|
|
};
|
|
}, []);
|
|
|
|
const handleCloseError = () => {
|
|
setShowErrorModal(false);
|
|
const props = usePage<PageProps>().props;
|
|
if (props.flash) props.flash.error = undefined;
|
|
};
|
|
|
|
const dismissFlash = () => {
|
|
setLocalFlash(null);
|
|
const props = usePage<PageProps>().props;
|
|
if (props.flash) {
|
|
props.flash.success = undefined;
|
|
props.flash.error = undefined;
|
|
}
|
|
};
|
|
|
|
const primaryRole = auth?.roles && auth.roles.length > 0
|
|
? (typeof auth.roles[0] === 'string' ? auth.roles[0] : (auth.roles[0] as any)?.name)
|
|
: (auth?.user?.user_type ? auth.user.user_type.replace('_', ' ') : 'Member');
|
|
|
|
return (
|
|
<SidebarProvider>
|
|
<AppSidebar />
|
|
<SidebarInset>
|
|
<header className="sticky top-0 z-30 flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border/80 bg-background/90 px-4 sm:px-6 backdrop-blur-md transition-all">
|
|
{/* Left: Sidebar Trigger & System Breadcrumb */}
|
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
|
<SidebarTrigger className="-ml-1 text-muted-foreground hover:text-foreground cursor-pointer" />
|
|
<Separator orientation="vertical" className="mr-1.5 h-4" />
|
|
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground truncate">
|
|
<span className="text-foreground font-semibold">GSB Construction</span>
|
|
<span className="text-muted-foreground/40">/</span>
|
|
<span className="truncate">Enterprise ERP</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Status, Role Badge, Theme Toggle & User Menu */}
|
|
<div className="flex items-center gap-2 sm:gap-2.5 shrink-0">
|
|
{/* Live System Indicator */}
|
|
<div className="hidden md:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
|
</span>
|
|
<span>Live System</span>
|
|
</div>
|
|
|
|
{/* Primary Role Badge */}
|
|
<Badge variant="outline" className="hidden lg:inline-flex text-[10px] font-semibold tracking-wide uppercase px-2 py-0.5 border-border bg-muted/40 text-muted-foreground">
|
|
{primaryRole}
|
|
</Badge>
|
|
|
|
{/* Theme Toggle Button */}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={toggleTheme}
|
|
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted/80 cursor-pointer rounded-lg"
|
|
title={isDark ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
|
>
|
|
{isDark ? <Sun className="h-4 w-4 text-amber-400" /> : <Moon className="h-4 w-4 text-slate-600" />}
|
|
</Button>
|
|
|
|
{/* Notification / Action Summary Dropdown */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
className="relative inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/80 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
title="System Notifications & Alerts"
|
|
>
|
|
<Bell className="h-4 w-4" />
|
|
{totalBadgeCount > 0 && (
|
|
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-emerald-500 px-1 text-[9px] font-bold text-white shadow-xs">
|
|
{totalBadgeCount}
|
|
</span>
|
|
)}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-72 p-2 rounded-xl">
|
|
<div className="flex items-center justify-between px-2 py-1.5 border-b border-border/60 mb-1">
|
|
<span className="text-xs font-bold text-foreground">Action Center</span>
|
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
|
|
{totalBadgeCount} Pending
|
|
</span>
|
|
</div>
|
|
{totalBadgeCount === 0 ? (
|
|
<div className="py-4 text-center text-xs text-muted-foreground">
|
|
All workflows and approvals are up to date.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1 py-1">
|
|
{pendingApprovals > 0 && (
|
|
<DropdownMenuItem render={<Link href={route('approvals.index')} className="flex items-center justify-between text-xs py-2 w-full" />}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="size-1.5 rounded-full bg-amber-500" />
|
|
<span>Pending Approvals</span>
|
|
</div>
|
|
<Badge className="bg-amber-500/15 text-amber-600 dark:text-amber-400 border-none text-[10px]">
|
|
{pendingApprovals}
|
|
</Badge>
|
|
</DropdownMenuItem>
|
|
)}
|
|
{pendingRequisitions > 0 && (
|
|
<DropdownMenuItem render={<Link href={route('requisitions.index')} className="flex items-center justify-between text-xs py-2 w-full" />}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="size-1.5 rounded-full bg-blue-500" />
|
|
<span>Material Requisitions</span>
|
|
</div>
|
|
<Badge className="bg-blue-500/15 text-blue-600 dark:text-blue-400 border-none text-[10px]">
|
|
{pendingRequisitions}
|
|
</Badge>
|
|
</DropdownMenuItem>
|
|
)}
|
|
{pendingPurchaseOrders > 0 && (
|
|
<DropdownMenuItem render={<Link href={route('purchase-orders.index')} className="flex items-center justify-between text-xs py-2 w-full" />}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="size-1.5 rounded-full bg-indigo-500" />
|
|
<span>Purchase Orders</span>
|
|
</div>
|
|
<Badge className="bg-indigo-500/15 text-indigo-600 dark:text-indigo-400 border-none text-[10px]">
|
|
{pendingPurchaseOrders}
|
|
</Badge>
|
|
</DropdownMenuItem>
|
|
)}
|
|
{pendingRetention > 0 && (
|
|
<DropdownMenuItem render={<Link href={route('retention.index')} className="flex items-center justify-between text-xs py-2 w-full" />}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="size-1.5 rounded-full bg-emerald-500" />
|
|
<span>Retention Releases</span>
|
|
</div>
|
|
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-none text-[10px]">
|
|
{pendingRetention}
|
|
</Badge>
|
|
</DropdownMenuItem>
|
|
)}
|
|
{pendingUnconfirmed > 0 && (
|
|
<DropdownMenuItem render={<Link href={route('finance.index')} className="flex items-center justify-between text-xs py-2 w-full" />}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="size-1.5 rounded-full bg-violet-500" />
|
|
<span>Invoice Payment Proofs</span>
|
|
</div>
|
|
<Badge className="bg-violet-500/15 text-violet-600 dark:text-violet-400 border-none text-[10px]">
|
|
{pendingUnconfirmed}
|
|
</Badge>
|
|
</DropdownMenuItem>
|
|
)}
|
|
</div>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<Separator orientation="vertical" className="hidden sm:block h-4" />
|
|
|
|
{/* User Profile Dropdown */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
className="inline-flex h-8 items-center gap-2 px-1.5 sm:px-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/80 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<Avatar className="h-6 w-6 sm:h-7 sm:w-7 rounded-md border border-emerald-500/20">
|
|
{auth.user.profile_picture && (
|
|
<AvatarImage src={auth.user.profile_picture} alt={auth.user.name} className="object-cover" />
|
|
)}
|
|
<AvatarFallback className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 text-[10px] font-bold">
|
|
{getInitials(auth.user.name)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="hidden sm:inline-block max-w-28 truncate text-xs font-semibold text-foreground">
|
|
{auth.user.name}
|
|
</span>
|
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56 p-1.5 rounded-xl">
|
|
<div className="px-2 py-1.5 border-b border-border/60 mb-1">
|
|
<p className="text-xs font-bold text-foreground truncate">{auth.user.name}</p>
|
|
<p className="text-[11px] text-muted-foreground truncate">{auth.user.email}</p>
|
|
</div>
|
|
<DropdownMenuItem render={<Link href={route('profile.edit')} className="flex items-center gap-2 text-xs py-2 w-full" />}>
|
|
<Settings className="size-3.5 text-muted-foreground" />
|
|
Profile Settings
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem render={<Link href={route('logout')} method="post" as="button" className="w-full flex items-center gap-2 text-xs text-rose-600 dark:text-rose-400 py-2 cursor-pointer" />}>
|
|
<LogOut className="size-3.5" />
|
|
Log Out
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex-1 relative pb-12">
|
|
{/* Integrated Page Header */}
|
|
{header && (
|
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-6 pb-2">
|
|
{header}
|
|
</div>
|
|
)}
|
|
|
|
{localFlash && (localFlash.success || localFlash.error) && (
|
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-4">
|
|
<div className={`flex items-center justify-between p-4 rounded-xl border shadow-sm transition-all duration-300 ${
|
|
localFlash.success
|
|
? 'bg-emerald-50/90 border-emerald-200/80 text-emerald-800 dark:bg-emerald-950/30 dark:border-emerald-900/30 dark:text-emerald-400'
|
|
: 'bg-rose-50/90 border-rose-200/80 text-rose-800 dark:bg-rose-950/30 dark:border-rose-900/30 dark:text-rose-400'
|
|
}`}>
|
|
<div className="flex items-center gap-3">
|
|
{localFlash.success ? <Check className="h-5 w-5 text-emerald-600 shrink-0" /> : <AlertCircle className="h-5 w-5 text-rose-600 shrink-0" />}
|
|
<p className="text-sm font-medium">{localFlash.success || localFlash.error}</p>
|
|
</div>
|
|
<button type="button" onClick={dismissFlash} className="p-1 rounded-lg hover:bg-slate-200/50 dark:hover:bg-slate-800/50 transition-colors">
|
|
<X className="h-4 w-4 text-slate-500 hover:text-slate-700 dark:hover:text-slate-350" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{children}
|
|
</div>
|
|
|
|
{/* Persistent Retention & 10% Payment Sticky Toast */}
|
|
<RetentionStickyToast />
|
|
</SidebarInset>
|
|
|
|
<Modal show={showErrorModal} onClose={handleCloseError} maxWidth="md">
|
|
<div className="p-6">
|
|
<div className="flex items-start gap-4">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400">
|
|
<ShieldAlert className="h-6 w-6" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Access Denied</h3>
|
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400 leading-relaxed">{errorMessage}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-6 flex justify-end">
|
|
<Button variant="destructive" onClick={handleCloseError} className="bg-red-600 hover:bg-red-700 text-white font-medium">
|
|
Okay
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</SidebarProvider>
|
|
);
|
|
}
|