259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { usePathname } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { INVENTORY_ENABLED, SUBMITTER_VIEW_ALL_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
LayoutDashboard,
|
|
FileText,
|
|
Plus,
|
|
CheckSquare,
|
|
CreditCard,
|
|
History,
|
|
Receipt,
|
|
Users,
|
|
Ship,
|
|
Building2,
|
|
Briefcase,
|
|
Store,
|
|
Anchor,
|
|
Package,
|
|
Upload,
|
|
MapPin,
|
|
ShoppingCart,
|
|
UserCircle,
|
|
ShieldCheck,
|
|
Network,
|
|
ClipboardList,
|
|
UserSearch,
|
|
Contact,
|
|
CalendarDays,
|
|
CalendarCheck,
|
|
UserCog,
|
|
Gauge,
|
|
BadgeCheck,
|
|
Truck,
|
|
ChevronRight,
|
|
} from "lucide-react";
|
|
import type { Role } from "@prisma/client";
|
|
|
|
interface NavItem {
|
|
href: string;
|
|
label: string;
|
|
icon: React.ElementType;
|
|
roles?: Role[];
|
|
}
|
|
|
|
// History is open to all-PO viewers; when the submitter-view-all flag is on, submitters
|
|
// (TECHNICAL / MANNING) get read+export access to it too.
|
|
const HISTORY_ROLES: Role[] = [
|
|
"MANAGER", "SUPERUSER", "AUDITOR", "ADMIN",
|
|
...(SUBMITTER_VIEW_ALL_ENABLED ? (["TECHNICAL", "MANNING"] as Role[]) : []),
|
|
];
|
|
|
|
const NAV_ITEMS: NavItem[] = [
|
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
|
{ href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
|
|
{ href: "/my-orders", label: "Closed Purchase Orders", icon: FileText, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
|
|
{ href: "/po/import", label: "Import PO", icon: Upload, roles: ["MANAGER", "SUPERUSER"] },
|
|
{ href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] },
|
|
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
|
|
{ href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] },
|
|
{ href: "/history", label: "History", icon: History, roles: HISTORY_ROLES },
|
|
{ href: "/profile", label: "My Profile", icon: UserCircle },
|
|
];
|
|
|
|
// ── Purchasing section ────────────────────────────────────────────────────────
|
|
// Staff browsing items (product catalogue + cart for PO creation)
|
|
const PURCHASING_STAFF: NavItem[] = [
|
|
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
|
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
|
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
|
|
];
|
|
|
|
// Manager catalogue management — Sites conditionally shown
|
|
// Admin does not use Purchasing; their links live under Administration
|
|
const PURCHASING_MGMT: NavItem[] = [
|
|
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
|
|
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["MANAGER"] },
|
|
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER"] },
|
|
...(INVENTORY_ENABLED
|
|
? [{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER"] as Role[] }]
|
|
: []),
|
|
];
|
|
|
|
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
|
|
|
|
// ── Crewing section (feature-flagged) ─────────────────────────────────────────
|
|
// Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per
|
|
// Crewing-Implementation-Spec §7); later phases append Candidates / Crew / Leave
|
|
// / Attendance / Verification with their per-role visibility. "Ranks & documents"
|
|
// lives under Administration.
|
|
const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
|
|
? [
|
|
{ href: "/crewing/requisitions", label: "Requisitions", icon: ClipboardList, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
|
|
{ href: "/crewing/candidates", label: "Candidates", icon: UserSearch, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
|
|
{ href: "/crewing/crew", label: "Crew", icon: Contact, roles: ["MANNING", "MANAGER", "SUPERUSER", "SITE_STAFF", "ACCOUNTS"] },
|
|
{ href: "/crewing/leave", label: "Leave", icon: CalendarDays, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
|
|
{ href: "/crewing/attendance", label: "Attendance", icon: CalendarCheck, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
|
|
{ href: "/crewing/verification", label: "Verification", icon: BadgeCheck, roles: ["MANNING", "SUPERUSER", "ACCOUNTS"] },
|
|
]
|
|
: [];
|
|
|
|
// ── Administration section ────────────────────────────────────────────────────
|
|
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
|
|
const MANAGER_ADMIN_ITEMS: NavItem[] = [
|
|
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
|
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
|
{ href: "/admin/delivery-locations", label: "Delivery Locations", icon: Truck, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
|
|
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
|
|
...(CREWING_ENABLED
|
|
? [
|
|
{ href: "/admin/ranks", label: "Ranks & documents", icon: Network, roles: ["MANAGER", "ADMIN"] as Role[] },
|
|
{ href: "/admin/crew", label: "Crew management", icon: UserCog, roles: ["MANAGER", "SUPERUSER", "ADMIN"] as Role[] },
|
|
{ href: "/admin/crew-strength", label: "Crew strength", icon: Gauge, roles: ["MANAGER", "SUPERUSER", "ADMIN"] as Role[] },
|
|
]
|
|
: []),
|
|
];
|
|
|
|
// Full Administration section (ADMIN only)
|
|
const ADMIN_ITEMS: NavItem[] = [
|
|
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship },
|
|
{ href: "/admin/sites", label: "Sites", icon: MapPin },
|
|
{ href: "/admin/users", label: "Users", icon: Users },
|
|
{ href: "/admin/superuser-requests", label: "SuperUser Requests",icon: ShieldCheck },
|
|
{ href: "/admin/accounts", label: "Accounting Codes", icon: Building2 },
|
|
{ href: "/admin/companies", label: "Companies", icon: Briefcase },
|
|
];
|
|
|
|
interface Section {
|
|
id: string;
|
|
label: string;
|
|
items: NavItem[];
|
|
}
|
|
|
|
function isItemActive(href: string, pathname: string) {
|
|
return pathname === href || pathname.startsWith(href + "/");
|
|
}
|
|
|
|
export function Sidebar({ userRole }: { userRole: Role }) {
|
|
const pathname = usePathname();
|
|
const isAdmin = userRole === "ADMIN";
|
|
|
|
const visibleMain = NAV_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
|
const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
|
const visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
|
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
|
const adminItems = isAdmin ? [...MANAGER_ADMIN_ITEMS, ...ADMIN_ITEMS] : visibleMgrAdmin;
|
|
|
|
// Headed, collapsible sections (the main links above sit outside any section).
|
|
const sections: Section[] = [
|
|
{ id: "purchasing", label: "Purchasing", items: visiblePurchasing },
|
|
{ id: "crewing", label: "Crewing", items: visibleCrewing },
|
|
{ id: "administration", label: "Administration", items: adminItems },
|
|
].filter((s) => s.items.length > 0);
|
|
|
|
// The section (if any) that holds the currently active route.
|
|
const activeSectionId =
|
|
sections.find((s) => s.items.some((i) => isItemActive(i.href, pathname)))?.id ?? null;
|
|
|
|
// Single-open accordion, collapsed by default. Auto-expand the section that
|
|
// contains the active route so the user is never stranded on a hidden link.
|
|
const [openSection, setOpenSection] = useState<string | null>(activeSectionId);
|
|
|
|
// On navigation, open the section holding the new active route (which, being a
|
|
// single-open accordion, collapses any other open heading).
|
|
useEffect(() => {
|
|
if (activeSectionId) setOpenSection(activeSectionId);
|
|
}, [activeSectionId]);
|
|
|
|
const toggleSection = (id: string) =>
|
|
setOpenSection((current) => (current === id ? null : id));
|
|
|
|
return (
|
|
<aside className="flex h-screen w-60 shrink-0 flex-col border-r border-neutral-200 bg-white">
|
|
<div className="flex h-16 items-center gap-2.5 border-b border-neutral-200 px-4">
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600">
|
|
<Anchor className="h-4 w-4 text-white" />
|
|
</div>
|
|
<span className="text-sm font-semibold text-neutral-900">PPMS</span>
|
|
</div>
|
|
|
|
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-0.5">
|
|
{visibleMain.map((item) => (
|
|
<NavLink key={item.href} item={item} pathname={pathname} />
|
|
))}
|
|
|
|
{sections.map((section) => {
|
|
const isOpen = openSection === section.id;
|
|
const regionId = `nav-section-${section.id}`;
|
|
return (
|
|
<div key={section.id}>
|
|
<SectionHeader
|
|
label={section.label}
|
|
isOpen={isOpen}
|
|
regionId={regionId}
|
|
onToggle={() => toggleSection(section.id)}
|
|
/>
|
|
{isOpen && (
|
|
<div id={regionId} className="space-y-0.5">
|
|
{section.items.map((item) => (
|
|
<NavLink key={item.href} item={item} pathname={pathname} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</nav>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function SectionHeader({
|
|
label,
|
|
isOpen,
|
|
regionId,
|
|
onToggle,
|
|
}: {
|
|
label: string;
|
|
isOpen: boolean;
|
|
regionId: string;
|
|
onToggle: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
aria-expanded={isOpen}
|
|
aria-controls={regionId}
|
|
className="flex w-full items-center justify-between pt-4 pb-1 px-3 text-xs font-semibold text-neutral-400 uppercase tracking-wider hover:text-neutral-600"
|
|
>
|
|
<span>{label}</span>
|
|
<ChevronRight
|
|
className={cn("h-3.5 w-3.5 shrink-0 transition-transform", isOpen && "rotate-90")}
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function NavLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
|
const isActive = isItemActive(item.href, pathname);
|
|
const Icon = item.icon;
|
|
return (
|
|
<Link
|
|
href={item.href}
|
|
className={cn(
|
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
|
isActive
|
|
? "bg-primary-50 text-primary-700"
|
|
: "text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0" />
|
|
{item.label}
|
|
</Link>
|
|
);
|
|
}
|