Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useState } from 'react';
|
|
|
|
|
import Link from 'next/link';
|
|
|
|
|
import { usePathname } from 'next/navigation';
|
|
|
|
|
import {
|
|
|
|
|
LayoutDashboard,
|
|
|
|
|
Users,
|
|
|
|
|
Bookmark,
|
|
|
|
|
Anchor,
|
|
|
|
|
Receipt,
|
|
|
|
|
FileText,
|
|
|
|
|
FolderOpen,
|
|
|
|
|
Mail,
|
|
|
|
|
Bell,
|
|
|
|
|
Settings,
|
|
|
|
|
Shield,
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
Menu,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
ChevronUp,
|
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import { useUIStore } from '@/stores/ui-store';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
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 { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
|
|
|
|
import {
|
|
|
|
|
Tooltip,
|
|
|
|
|
TooltipContent,
|
|
|
|
|
TooltipProvider,
|
|
|
|
|
TooltipTrigger,
|
|
|
|
|
} from '@/components/ui/tooltip';
|
|
|
|
|
import type { UserPortRole } from '@/lib/db/schema/users';
|
|
|
|
|
import type { Role } from '@/lib/db/schema/users';
|
|
|
|
|
|
|
|
|
|
interface SidebarProps {
|
|
|
|
|
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface NavItem {
|
|
|
|
|
href: string;
|
|
|
|
|
label: string;
|
|
|
|
|
icon: React.ElementType;
|
|
|
|
|
exact?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface NavSection {
|
|
|
|
|
title: string;
|
|
|
|
|
items: NavItem[];
|
|
|
|
|
adminRequired?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|
|
|
|
const base = portSlug ? `/${portSlug}` : '';
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
title: 'Main',
|
|
|
|
|
items: [
|
|
|
|
|
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
|
|
|
|
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
|
|
|
|
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
|
|
|
|
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Documents',
|
|
|
|
|
items: [
|
|
|
|
|
{ href: `${base}/documents`, label: 'Documents', icon: FileText },
|
|
|
|
|
{ href: `${base}/documents/files`, label: 'Files', icon: FolderOpen },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Financial',
|
|
|
|
|
items: [
|
|
|
|
|
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
|
|
|
|
|
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Communication',
|
|
|
|
|
items: [
|
|
|
|
|
{ href: `${base}/email`, label: 'Email', icon: Mail },
|
|
|
|
|
{ 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 = (
|
|
|
|
|
<Link
|
2026-03-26 12:29:55 +01:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
href={item.href as any}
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
className={cn(
|
|
|
|
|
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors duration-150',
|
|
|
|
|
'text-[#cdcfd6] hover:bg-[#171f35] hover:text-white',
|
|
|
|
|
active && 'border-l-2 border-[#3a7bc8] bg-[#3a7bc810] text-white pl-[10px]',
|
|
|
|
|
collapsed && 'justify-center px-2',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<item.icon
|
|
|
|
|
className={cn(
|
|
|
|
|
'shrink-0',
|
|
|
|
|
active ? 'text-[#3a7bc8]' : 'text-[#83aab1]',
|
|
|
|
|
collapsed ? 'w-5 h-5' : 'w-4 h-4',
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{!collapsed && <span>{item.label}</span>}
|
|
|
|
|
</Link>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (collapsed) {
|
|
|
|
|
return (
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="right" className="font-medium">
|
|
|
|
|
{item.label}
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return content;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SidebarContent({
|
|
|
|
|
collapsed,
|
|
|
|
|
portSlug,
|
|
|
|
|
portRoles,
|
|
|
|
|
hasAdminAccess,
|
|
|
|
|
}: {
|
|
|
|
|
collapsed: boolean;
|
|
|
|
|
portSlug: string | undefined;
|
|
|
|
|
portRoles: SidebarProps['portRoles'];
|
|
|
|
|
hasAdminAccess: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const pathname = usePathname();
|
|
|
|
|
const [adminExpanded, setAdminExpanded] = useState(false);
|
|
|
|
|
const sections = buildNavSections(portSlug);
|
|
|
|
|
|
|
|
|
|
function isActive(href: string, exact?: boolean): boolean {
|
|
|
|
|
if (exact) return pathname === href;
|
|
|
|
|
return pathname.startsWith(href);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col h-full bg-[#1e2844]">
|
|
|
|
|
{/* Logo area */}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex items-center gap-3 px-4 py-5 border-b border-[#474e66]',
|
|
|
|
|
collapsed && 'justify-center px-2',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div className="shrink-0 w-8 h-8 rounded-md bg-[#3a7bc8] flex items-center justify-center">
|
|
|
|
|
<span className="text-white font-bold text-sm">PN</span>
|
|
|
|
|
</div>
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<p className="text-white font-semibold text-sm leading-tight truncate">Port Nimara</p>
|
|
|
|
|
<p className="text-[#83aab1] text-xs truncate">Marina CRM</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Nav */}
|
|
|
|
|
<ScrollArea className="flex-1 py-2">
|
|
|
|
|
<TooltipProvider delayDuration={0}>
|
|
|
|
|
<nav className="px-2 space-y-4">
|
|
|
|
|
{sections.map((section) => {
|
|
|
|
|
if (section.adminRequired && !hasAdminAccess) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={section.title}>
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
<div className="flex items-center justify-between px-1 mb-1">
|
|
|
|
|
<span className="text-[#71768a] text-xs font-medium uppercase tracking-wider">
|
|
|
|
|
{section.title}
|
|
|
|
|
</span>
|
|
|
|
|
{section.adminRequired && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setAdminExpanded((v) => !v)}
|
|
|
|
|
className="text-[#71768a] hover:text-[#cdcfd6] transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{adminExpanded ? (
|
|
|
|
|
<ChevronUp className="w-3 h-3" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronDown className="w-3 h-3" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{(!section.adminRequired || adminExpanded || collapsed) && (
|
|
|
|
|
<ul className="space-y-0.5">
|
|
|
|
|
{section.items.map((item) => (
|
|
|
|
|
<li key={item.href}>
|
|
|
|
|
<NavItemLink
|
|
|
|
|
item={item}
|
|
|
|
|
collapsed={collapsed}
|
|
|
|
|
active={isActive(item.href, item.exact)}
|
|
|
|
|
/>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
<Separator className="mt-3 bg-[#474e66]/50" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</nav>
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
|
|
|
|
|
{/* User footer */}
|
|
|
|
|
<div className={cn('border-t border-[#474e66] p-3', collapsed && 'flex justify-center')}>
|
|
|
|
|
{collapsed ? (
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Avatar className="w-8 h-8 cursor-pointer">
|
|
|
|
|
<AvatarImage src={undefined} />
|
|
|
|
|
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
|
|
|
|
|
U
|
|
|
|
|
</AvatarFallback>
|
|
|
|
|
</Avatar>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="right">User Profile</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Avatar className="w-8 h-8 shrink-0">
|
|
|
|
|
<AvatarImage src={undefined} />
|
|
|
|
|
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
|
|
|
|
|
U
|
|
|
|
|
</AvatarFallback>
|
|
|
|
|
</Avatar>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<p className="text-white text-sm font-medium truncate">User Name</p>
|
|
|
|
|
<Badge
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="text-[10px] px-1.5 py-0 text-[#83aab1] border-[#474e66] mt-0.5"
|
|
|
|
|
>
|
|
|
|
|
{portRoles[0]?.role?.name ?? 'Staff'}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function Sidebar({ portRoles }: SidebarProps) {
|
|
|
|
|
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
|
|
|
|
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
|
|
|
|
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
|
|
|
|
|
|
|
|
|
// Check for admin access based on role permissions
|
|
|
|
|
const hasAdminAccess = portRoles.some(
|
|
|
|
|
(pr) => pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{/* Desktop sidebar */}
|
|
|
|
|
<aside
|
|
|
|
|
className={cn(
|
|
|
|
|
'hidden md:flex flex-col h-screen border-r border-[#474e66] transition-all duration-200 ease-in-out shrink-0',
|
|
|
|
|
sidebarCollapsed ? 'w-sidebar-collapsed' : 'w-sidebar',
|
|
|
|
|
)}
|
|
|
|
|
style={{ backgroundColor: '#1e2844' }}
|
|
|
|
|
>
|
|
|
|
|
<SidebarContent
|
|
|
|
|
collapsed={sidebarCollapsed}
|
|
|
|
|
portSlug={currentPortSlug ?? undefined}
|
|
|
|
|
portRoles={portRoles}
|
|
|
|
|
hasAdminAccess={hasAdminAccess}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Collapse toggle */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={toggleSidebar}
|
|
|
|
|
className={cn(
|
|
|
|
|
'absolute top-1/2 -translate-y-1/2 -right-3 z-10',
|
|
|
|
|
'w-6 h-6 rounded-full bg-[#1e2844] border border-[#474e66]',
|
|
|
|
|
'flex items-center justify-center text-[#cdcfd6]',
|
|
|
|
|
'hover:bg-[#3a7bc8] hover:border-[#3a7bc8] hover:text-white transition-colors',
|
|
|
|
|
)}
|
|
|
|
|
aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
|
|
|
>
|
|
|
|
|
{sidebarCollapsed ? (
|
|
|
|
|
<ChevronRight className="w-3 h-3" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronLeft className="w-3 h-3" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
{/* Mobile drawer */}
|
|
|
|
|
<Sheet>
|
|
|
|
|
<SheetTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="md:hidden fixed top-3 left-3 z-50 text-white bg-[#1e2844] hover:bg-[#171f35]"
|
|
|
|
|
>
|
|
|
|
|
<Menu className="w-5 h-5" />
|
|
|
|
|
<span className="sr-only">Open navigation</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</SheetTrigger>
|
|
|
|
|
<SheetContent side="left" className="p-0 w-sidebar border-r-0">
|
|
|
|
|
<SidebarContent
|
|
|
|
|
collapsed={false}
|
|
|
|
|
portSlug={currentPortSlug ?? undefined}
|
|
|
|
|
portRoles={portRoles}
|
|
|
|
|
hasAdminAccess={hasAdminAccess}
|
|
|
|
|
/>
|
|
|
|
|
</SheetContent>
|
|
|
|
|
</Sheet>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|