'use client';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Users,
Bookmark,
Anchor,
Ship,
Building2,
Receipt,
FileText,
FolderOpen,
Bell,
Camera,
Globe,
Settings,
Shield,
Home,
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { UserMenu } from '@/components/layout/user-menu';
import type { UserPortRole } from '@/lib/db/schema/users';
import type { Role } from '@/lib/db/schema/users';
import type { Port } from '@/lib/db/schema/ports';
const LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
interface SidebarProps {
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
isSuperAdmin?: boolean;
user?: { name: string; email: string };
/** Ports the user has access to. Drives the footer port switcher. */
ports?: Port[];
}
/**
* Turn a snake_cased DB role identifier (e.g. "super_admin") into a human
* label ("Super Admin"). Empty/missing → "Staff" fallback.
*/
function humanizeRole(roleName: string | null | undefined): string {
if (!roleName) return 'Staff';
return roleName
.split('_')
.map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : part))
.join(' ');
}
interface NavItem {
href: string;
label: string;
icon: React.ElementType;
exact?: boolean;
}
interface NavSection {
title: string;
items: NavItem[];
adminRequired?: boolean;
/** When true, only render if the user has marina-side access. */
marinaRequired?: boolean;
/** When true, only render if the user has residential-side access. */
residentialRequired?: boolean;
}
function buildNavSections(portSlug: string | undefined): NavSection[] {
const base = portSlug ? `/${portSlug}` : '';
return [
{
title: 'Main',
marinaRequired: true,
items: [
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
{ href: `${base}/clients`, label: 'Clients', icon: Users },
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
],
},
{
title: 'Residential',
residentialRequired: true,
items: [
{
href: `${base}/residential/clients`,
label: 'Residential Clients',
icon: Home,
},
{
href: `${base}/residential/interests`,
label: 'Residential Interests',
icon: Bookmark,
},
],
},
{
title: 'Documents',
marinaRequired: true,
items: [
{ href: `${base}/documents`, label: 'Documents', icon: FileText },
{ href: `${base}/documents/files`, label: 'Files', icon: FolderOpen },
],
},
{
title: 'Financial',
marinaRequired: true,
items: [
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
// Dedicated explainer page covers "Add to Home Screen" install
// + walkthrough; the mobile-only scanner UI itself lives at /scan
// and is reached via the install or the explainer page button.
{
href: `${base}/invoices/upload-receipts`,
label: 'How to upload receipts',
icon: Camera,
},
],
},
{
title: 'Insights',
marinaRequired: true,
items: [
// Marketing / Umami integration. Distinct from the main dashboard
// (which is sales-focused) so the audience and the metrics don't
// compete for visual real estate.
{
href: `${base}/website-analytics`,
label: 'Website analytics',
icon: Globe,
},
],
},
{
title: 'Communication',
marinaRequired: true,
items: [
// Email tab removed: we deferred building a full inbox/threading
// feature (would require Google OAuth + scope review + IMAP
// syncing infra). Reminders stays since it's already wired up.
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
],
},
{
title: 'Admin',
adminRequired: true,
items: [
{ href: `${base}/settings`, label: 'Settings', icon: Settings },
{ href: `${base}/admin`, label: 'Administration', icon: Shield },
],
},
];
}
function NavItemLink({
item,
collapsed,
active,
}: {
item: NavItem;
collapsed: boolean;
active: boolean;
}) {
const content = (
{active && !collapsed && (
)}
{!collapsed && {item.label}}
);
if (collapsed) {
return (
{content}
{item.label}
);
}
return content;
}
function SidebarContent({
collapsed,
portSlug,
portRoles,
hasAdminAccess,
hasMarinaAccess,
hasResidentialAccess,
user,
ports,
onToggleCollapse,
}: {
collapsed: boolean;
portSlug: string | undefined;
portRoles: SidebarProps['portRoles'];
hasAdminAccess: boolean;
hasMarinaAccess: boolean;
hasResidentialAccess: boolean;
user?: SidebarProps['user'];
ports?: Port[];
/** When provided, renders the collapse toggle row above the user footer (desktop). */
onToggleCollapse?: () => void;
}) {
const pathname = usePathname();
const [adminExpanded, setAdminExpanded] = useState(true);
const sections = buildNavSections(portSlug);
// Pre-compute every nav href the sidebar offers across all sections so
// the active-state check can do longest-prefix-match. Without this,
// /invoices/upload-receipts would highlight both "Invoices" and "How to
// upload receipts" - every ancestor prefix matches.
const allHrefs = sections.flatMap((s) => s.items.map((i) => i.href));
function isActive(href: string, exact?: boolean): boolean {
if (exact) return pathname === href;
if (!pathname.startsWith(href)) return false;
// Active only if no other sidebar item has a strictly more-specific
// prefix match. That makes the highlight follow the URL into the
// deepest registered route (e.g. /invoices/upload-receipts wins over
// /invoices when both are sidebar items).
return !allHrefs.some((other) => other.length > href.length && pathname.startsWith(other));
}
// Sidebar header showcases the Port Nimara logo as the visual anchor of
// the brand. Collapse toggle is a small overlay button so it doesn't
// compete with the logo for attention.
return (
{/* Brand header - logo centered (large when expanded, smaller when
collapsed). Collapse toggle floats top-right as a tiny chevron. */}
{onToggleCollapse && (
)}
{/* Nav */}
{/* User footer - entire row is the trigger for the UserMenu so the
user can click their name/avatar to access Profile / Settings /
port-switcher / sign-out. The same UserMenu component drives the
top-right avatar dropdown, so the menu items stay consistent. */}