257 lines
12 KiB
TypeScript
257 lines
12 KiB
TypeScript
import { Link, usePage } from '@inertiajs/react';
|
|
import { ChevronDown, Building2 } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
|
|
import { cn } from '@/lib/utils';
|
|
import { navigationConfig, type NavGroup } from '@/lib/nav-config';
|
|
import { PageProps, SidebarBadges } from '@/types';
|
|
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarMenu,
|
|
SidebarMenuBadge,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarMenuSub,
|
|
SidebarMenuSubButton,
|
|
SidebarMenuSubItem,
|
|
SidebarRail,
|
|
} from '@/Components/ui/sidebar';
|
|
|
|
|
|
function NavCollapsible({
|
|
item,
|
|
badges,
|
|
}: {
|
|
item: NavGroup['items'][number] & { children?: { title: string; href: string; routeMatch: string; badgeKey?: string }[] };
|
|
badges?: SidebarBadges;
|
|
}) {
|
|
const isAnyChildActive = item.children?.some(child => {
|
|
try {
|
|
if (child.routeMatch === 'bids.*' && route().current('bids.my')) {
|
|
return false;
|
|
}
|
|
return route().current(child.routeMatch);
|
|
} catch { return false; }
|
|
}) ?? false;
|
|
|
|
const isParentActive = (() => {
|
|
try { return route().current(item.routeMatch); } catch { return false; }
|
|
})();
|
|
|
|
const isActive = isAnyChildActive || isParentActive;
|
|
const [open, setOpen] = useState(isActive);
|
|
const Icon = item.icon;
|
|
|
|
const totalBadge = item.children?.reduce((sum, child) => {
|
|
if (child.badgeKey && badges) {
|
|
return sum + (badges[child.badgeKey as keyof SidebarBadges] ?? 0);
|
|
}
|
|
return sum;
|
|
}, 0) ?? 0;
|
|
|
|
return (
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
onClick={() => setOpen(!open)}
|
|
isActive={isActive}
|
|
tooltip={item.title}
|
|
>
|
|
<Icon className="size-4" />
|
|
<span className="flex-1">{item.title}</span>
|
|
{totalBadge > 0 && !open && (
|
|
<span className="ml-auto flex h-5 min-w-5 items-center justify-center rounded-full bg-emerald-500/20 px-1.5 text-[10px] font-semibold text-emerald-400 tabular-nums">
|
|
{totalBadge}
|
|
</span>
|
|
)}
|
|
<ChevronDown
|
|
className={cn(
|
|
'ml-auto size-4 shrink-0 transition-transform duration-200',
|
|
open && 'rotate-180'
|
|
)}
|
|
/>
|
|
</SidebarMenuButton>
|
|
{open && (
|
|
<SidebarMenuSub>
|
|
{item.children!.map(child => {
|
|
const childActive = (() => {
|
|
try {
|
|
if (child.routeMatch === 'bids.*' && route().current('bids.my')) {
|
|
return false;
|
|
}
|
|
return route().current(child.routeMatch);
|
|
} catch { return false; }
|
|
})();
|
|
const badgeCount = child.badgeKey && badges
|
|
? badges[child.badgeKey as keyof SidebarBadges] ?? 0
|
|
: 0;
|
|
|
|
return (
|
|
<SidebarMenuSubItem key={child.href}>
|
|
<SidebarMenuSubButton
|
|
render={<Link href={route(child.href, child.params)} />}
|
|
isActive={childActive}
|
|
>
|
|
<span>{child.title}</span>
|
|
{badgeCount > 0 && (
|
|
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-emerald-500/20 px-1 text-[10px] font-semibold text-emerald-400 tabular-nums">
|
|
{badgeCount}
|
|
</span>
|
|
)}
|
|
</SidebarMenuSubButton>
|
|
</SidebarMenuSubItem>
|
|
);
|
|
})}
|
|
</SidebarMenuSub>
|
|
)}
|
|
</SidebarMenuItem>
|
|
);
|
|
}
|
|
|
|
export default function AppSidebar() {
|
|
const { auth, sidebarBadges } = usePage<PageProps>().props;
|
|
|
|
return (
|
|
<Sidebar collapsible="icon" className="border-r-0">
|
|
{/* Header */}
|
|
<SidebarHeader className="border-b border-sidebar-border px-3 py-4">
|
|
<Link href="/" className="flex items-center gap-2.5">
|
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500/15 text-emerald-400">
|
|
<Building2 className="size-4" />
|
|
</div>
|
|
<div className="flex flex-col gap-0.5 leading-none group-data-[collapsible=icon]:hidden">
|
|
<span className="text-sm font-bold tracking-tight">GSB Construction</span>
|
|
<span className="text-[10px] font-medium text-sidebar-foreground/50 uppercase tracking-widest">ERP System</span>
|
|
</div>
|
|
</Link>
|
|
</SidebarHeader>
|
|
|
|
{/* Navigation */}
|
|
<SidebarContent>
|
|
{navigationConfig.map(group => {
|
|
const filteredItems = group.items.map(item => {
|
|
const isAdminUser = auth.user.user_type === 'admin' ||
|
|
auth.roles.includes('Super Admin') ||
|
|
auth.roles.includes('admin');
|
|
|
|
const isBiddingManager = auth.user.user_type === 'admin' ||
|
|
auth.roles.includes('Super Admin') ||
|
|
auth.roles.includes('admin') ||
|
|
auth.roles.includes('Project Manager');
|
|
const isSiteOperationsUser = auth.roles.some(role => [
|
|
'Site Technical',
|
|
'Construction Supervisor',
|
|
'Site Operations',
|
|
'Site Engineer',
|
|
'Site Supervisor',
|
|
].includes(role));
|
|
const isContractorPortalUser = auth.user.user_type === 'contractor' || auth.user.contractor_id !== null;
|
|
const isContractorAdminUser = !isSiteOperationsUser && (
|
|
auth.roles.includes('Main Contractor Admin') ||
|
|
auth.roles.includes('Contractor Admin') ||
|
|
auth.user.user_type === 'contractor' ||
|
|
auth.user.contractor_id !== null
|
|
);
|
|
|
|
const hasParentAccess = item.title === 'Bidding'
|
|
? !isSiteOperationsUser && (isBiddingManager || isContractorPortalUser)
|
|
: isAdminUser ||
|
|
!item.permissions ||
|
|
item.permissions.length === 0 ||
|
|
item.permissions.some(p => auth.permissions.includes(p));
|
|
|
|
if (!hasParentAccess) return null;
|
|
|
|
if (item.children && item.children.length > 0) {
|
|
const filteredChildren = item.children.filter(child => {
|
|
if (child.href === 'retention.index') {
|
|
if (isSiteOperationsUser) return false;
|
|
return isAdminUser || isBiddingManager || isContractorAdminUser;
|
|
}
|
|
if (isAdminUser) return true;
|
|
if (!child.permissions || child.permissions.length === 0) return true;
|
|
return child.permissions.some(p => auth.permissions.includes(p));
|
|
});
|
|
|
|
if (filteredChildren.length === 0) return null;
|
|
|
|
return {
|
|
...item,
|
|
children: item.title === 'Bidding'
|
|
? filteredChildren.filter(child => {
|
|
if (child.title === 'Bid Packages') return isBiddingManager;
|
|
if (child.title === 'My Bids') return isContractorPortalUser;
|
|
return true;
|
|
})
|
|
: filteredChildren
|
|
};
|
|
}
|
|
|
|
return item;
|
|
}).filter(Boolean) as NavGroup['items'];
|
|
|
|
if (filteredItems.length === 0) return null;
|
|
|
|
return (
|
|
<SidebarGroup key={group.label}>
|
|
<SidebarGroupLabel className="text-[10px] uppercase tracking-widest font-semibold text-sidebar-foreground/40">
|
|
{group.label}
|
|
</SidebarGroupLabel>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{filteredItems.map(item => {
|
|
if (item.children && item.children.length > 0) {
|
|
return (
|
|
<NavCollapsible
|
|
key={item.href}
|
|
item={item}
|
|
badges={sidebarBadges}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const isActive = (() => {
|
|
try { return route().current(item.routeMatch); } catch { return false; }
|
|
})();
|
|
|
|
const badgeCount = item.badgeKey && sidebarBadges
|
|
? sidebarBadges[item.badgeKey] ?? 0
|
|
: 0;
|
|
|
|
const Icon = item.icon;
|
|
|
|
return (
|
|
<SidebarMenuItem key={item.href}>
|
|
<SidebarMenuButton
|
|
render={<Link href={route(item.href, item.params)} />}
|
|
isActive={isActive}
|
|
tooltip={item.title}
|
|
>
|
|
<Icon className="size-4" />
|
|
<span>{item.title}</span>
|
|
</SidebarMenuButton>
|
|
{badgeCount > 0 && (
|
|
<SidebarMenuBadge className="bg-emerald-500/20 text-emerald-400 text-[10px] font-semibold">
|
|
{badgeCount}
|
|
</SidebarMenuBadge>
|
|
)}
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
);
|
|
})}
|
|
</SidebarContent>
|
|
|
|
<SidebarRail />
|
|
</Sidebar>
|
|
);
|
|
}
|