Files
pn-new-crm/src/components/layout/sidebar.tsx
Matt 95724c8e3a fix(uat): prod UAT batch — reports, sidebar, search, berths, breakpoint
- financial report: drop Expenses KPI, Net Contribution, cash-flow chart,
  expense donut + ledger (expenses are business-trip costs, not net contribution)
- dashboard report PDF: pagination-safe tables (TableSection + per-row wrap)
  so long doc lists no longer overlap/crush
- clients PDF report: rename "Nationality" -> "Country"
- sidebar: hide a section header when all its items gate off (FINANCIAL orphan)
- topbar: move global search into the 1fr grid track so it can't overlap "New"
- clients card: show all linked berths (not just latest interest's primary)
- berths list: hide table-only toggles (ft/m, density, columns) in card mode
- lists: lower table/card breakpoint lg -> md so narrow desktops get tables
- alert-rules: stale floor created_at -> updated_at (survives created_at backfill)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:41:31 +02:00

601 lines
22 KiB
TypeScript

'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,
KeyRound,
Ship,
Building2,
Receipt,
FileText,
FileBarChart,
Inbox,
Camera,
Globe,
Settings,
Shield,
ScrollText,
RefreshCw,
Home,
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatRole } from '@/lib/constants';
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 { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
import type { UserPortRole } from '@/lib/db/schema/users';
import type { Role } from '@/lib/db/schema/users';
import type { Port } from '@/lib/db/schema/ports';
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[];
/** Per-port logo URLs resolved server-side in the dashboard layout.
* The sidebar header swaps to the current port's logo via the UI
* store's `currentPortId`. Null entries render the wordmark fallback. */
portLogoUrls?: Record<string, string | null>;
/** Per-port `tenancies_module_enabled` resolution. Gates the Tenancies
* sidebar entry. Resolved server-side in the dashboard layout. */
tenanciesModuleByPort?: Record<string, boolean>;
/** Per-port `expenses_module_enabled` resolution. Gates the Expenses
* + How-to-upload-receipts sidebar entries. Resolved server-side in
* the dashboard layout. Defaults to true (feature on) per port when
* the map is missing for the active port. */
expensesModuleByPort?: Record<string, boolean>;
/** Per-port `residential_module_enabled` resolution. Gates the entire
* "Residential" sidebar section. Resolved server-side in the dashboard
* layout. Defaults to true (feature on) per port when the map is
* missing for the active port. */
residentialModuleByPort?: Record<string, boolean>;
}
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;
/** When true, only render if the residential module is enabled for the
* current port. Resolved against `residentialModuleByPort`. */
requiresResidentialModule?: boolean;
/** When true, only render if Umami analytics is wired up for the port. */
umamiRequired?: boolean;
}
interface NavItemGated extends NavItem {
/** When true, only render this item if the tenancies module is enabled
* for the current port. Resolved against `tenanciesModuleByPort`. */
requiresTenanciesModule?: boolean;
/** When true, only render this item if the expenses module is enabled
* for the current port. Resolved against `expensesModuleByPort`. */
requiresExpensesModule?: boolean;
/** When true, only render this item if Umami analytics is wired up
* for the port. */
umamiRequired?: 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 },
{
href: `${base}/tenancies`,
label: 'Tenancies',
icon: KeyRound,
requiresTenanciesModule: true,
} as NavItemGated,
],
},
{
title: 'Residential',
residentialRequired: true,
requiresResidentialModule: true,
items: [
{
href: `${base}/residential/clients`,
label: 'Residential Clients',
icon: Home,
},
{
href: `${base}/residential/interests`,
label: 'Residential Interests',
icon: Bookmark,
},
],
},
{
title: 'Insights',
marinaRequired: true,
items: [
// Reports surface (dashboard / clients / berths / interests
// builders, plus templates / schedules / runs). Routes existed
// since the report-builder ship but the sidebar entry was never
// wired - reps had to land here via direct link.
{
href: `${base}/reports`,
label: 'Reports',
icon: FileBarChart,
},
// 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. Hidden when Umami isn't wired
// up via the per-item umamiRequired flag below.
{
href: `${base}/website-analytics`,
label: 'Website analytics',
icon: Globe,
umamiRequired: true,
} as NavItemGated,
],
},
{
title: 'Documents',
marinaRequired: true,
items: [{ href: `${base}/documents`, label: 'Documents', icon: FileText }],
},
{
title: 'Financial',
marinaRequired: true,
items: [
{
href: `${base}/expenses`,
label: 'Expenses',
icon: Receipt,
requiresExpensesModule: true,
} as NavItemGated,
// Invoices nav entry removed - the expense-to-PDF flow is the
// only invoicing surface now (employee expense reports). The
// standalone /invoices route still exists for any back-compat
// links but is no longer surfaced in nav.
// 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,
requiresExpensesModule: true,
} as NavItemGated,
],
},
{
title: 'Communication',
marinaRequired: true,
items: [
// Email tab removed: deferred building a full inbox/threading
// feature (would require Google OAuth + scope review + IMAP
// syncing infra). This entry routes to the merged
// Alerts + Reminders surface (2026-05-11) - explicit name so
// reps don't mistake it for an email inbox.
{ href: `${base}/inbox`, label: 'Alerts & Reminders', icon: Inbox },
],
},
{
title: 'Admin',
adminRequired: true,
items: [
{ href: `${base}/settings`, label: 'Settings', icon: Settings },
{ href: `${base}/admin`, label: 'Administration', icon: Shield },
// F14: audit log page existed but had no nav link.
{ href: `${base}/admin/audit`, label: 'Audit Log', icon: ScrollText },
// #67 Phase 5: surfaces berths flipped manually without a backing
// interest so reps can run the catch-up wizard.
{
href: `${base}/admin/berths/reconcile`,
label: 'Reconcile berths',
icon: RefreshCw,
},
],
},
];
}
function NavItemLink({
item,
collapsed,
active,
}: {
item: NavItem;
collapsed: boolean;
active: boolean;
}) {
const content = (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={item.href as any}
className={cn(
'relative flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150',
'text-slate-700 hover:bg-accent hover:text-foreground',
active && 'bg-accent text-foreground pl-[14px]',
collapsed && 'justify-center px-2',
)}
>
{active && !collapsed && (
<span
aria-hidden
className="absolute left-0 top-1 bottom-1 w-1 rounded-r-full bg-[#3a7bc8]"
/>
)}
<item.icon
className={cn(
'shrink-0',
active ? 'text-[#3a7bc8]' : 'text-slate-500',
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,
isSuperAdmin,
hasAdminAccess,
hasMarinaAccess,
hasResidentialAccess,
tenanciesModuleEnabled,
expensesModuleEnabled,
residentialModuleEnabled,
user,
ports,
currentPort,
currentLogoUrl,
onToggleCollapse,
}: {
collapsed: boolean;
portSlug: string | undefined;
portRoles: SidebarProps['portRoles'];
isSuperAdmin: boolean;
hasAdminAccess: boolean;
hasMarinaAccess: boolean;
hasResidentialAccess: boolean;
tenanciesModuleEnabled: boolean;
expensesModuleEnabled: boolean;
residentialModuleEnabled: boolean;
user?: SidebarProps['user'];
ports?: Port[];
currentPort: Port | null;
currentLogoUrl: string | null;
/** 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);
const umami = useUmamiActive('today');
const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true;
// Small label under the user identity when the user has access to more
// than one port - disambiguates which port is currently active without
// pulling the port name back into the breadcrumbs.
const showPortLabel = !!ports && ports.length > 1;
const currentPortName = showPortLabel
? (ports.find((p) => p.slug === portSlug)?.name ?? null)
: null;
// 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 (
<TooltipProvider delayDuration={0}>
<div className="flex flex-col h-full bg-white">
{/* Brand header - logo centered. Soft hairline below echoes the
inter-section separators in the nav so the eye reads the logo
as a distinct top-row, not a floating element. */}
<div
className={cn(
'relative flex items-center justify-center border-b border-slate-200',
collapsed ? 'h-16 px-2' : 'h-24 px-4',
)}
>
{currentLogoUrl ? (
<Image
src={currentLogoUrl}
alt={currentPort?.name ?? 'Logo'}
width={collapsed ? 40 : 72}
height={collapsed ? 40 : 72}
className="rounded-full shadow-sm"
unoptimized
priority
/>
) : (
<div className="flex h-12 items-center px-3 text-sm font-semibold text-slate-700">
{currentPort?.name ?? 'CRM'}
</div>
)}
{onToggleCollapse && (
<button
type="button"
onClick={onToggleCollapse}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={cn(
'absolute right-2 flex h-6 w-6 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-700 transition-colors',
collapsed ? 'top-1' : 'top-2',
)}
>
{collapsed ? (
<ChevronRight className="w-4 h-4" aria-hidden />
) : (
<ChevronLeft className="w-4 h-4" aria-hidden />
)}
</button>
)}
</div>
{/* Nav */}
<ScrollArea className="flex-1 py-2">
<nav className="px-2 space-y-4">
{sections.map((section) => {
if (section.adminRequired && !hasAdminAccess) return null;
if (section.marinaRequired && !hasMarinaAccess) return null;
if (section.residentialRequired && !hasResidentialAccess) return null;
if (section.requiresResidentialModule && !residentialModuleEnabled) return null;
if (section.umamiRequired && !umamiConfigured) return null;
// Resolve the items this section will actually render after
// per-item module/permission gating. If they all gate off
// (e.g. the Financial section once the Expenses module is
// disabled), skip the whole section so its header + separator
// don't linger as an orphaned label.
const visibleItems = section.items.filter((item) => {
const gated = item as NavItemGated;
if (gated.requiresTenanciesModule && !tenanciesModuleEnabled) return false;
if (gated.requiresExpensesModule && !expensesModuleEnabled) return false;
if (gated.umamiRequired && !umamiConfigured) return false;
return true;
});
if (visibleItems.length === 0) return null;
return (
<div key={section.title}>
{!collapsed && (
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-slate-500 text-xs font-semibold uppercase tracking-[0.12em]">
{section.title}
</span>
{section.adminRequired && (
<button
onClick={() => setAdminExpanded((v) => !v)}
className="text-slate-400 hover:text-slate-700 transition-colors"
>
{adminExpanded ? (
<ChevronUp className="w-3 h-3" aria-hidden />
) : (
<ChevronDown className="w-3 h-3" aria-hidden />
)}
</button>
)}
</div>
)}
{(!section.adminRequired || adminExpanded || collapsed) && (
<ul className="space-y-0.5">
{visibleItems.map((item) => (
<li key={item.href}>
<NavItemLink
item={item}
collapsed={collapsed}
active={isActive(item.href, item.exact)}
/>
</li>
))}
</ul>
)}
<Separator className="mt-3 bg-slate-200" aria-hidden />
</div>
);
})}
</nav>
</ScrollArea>
{/* User footer - entire row is the trigger for the UserMenu so the
user can click their name/avatar to access Settings /
port-switcher / sign-out. The same UserMenu component drives the
top-right avatar dropdown, so the menu items stay consistent. */}
<div className={cn('border-t border-slate-200 p-2', collapsed && 'flex justify-center')}>
{collapsed ? (
<UserMenu
align="start"
user={user}
ports={ports}
trigger={
<button
type="button"
aria-label="Open user menu"
className="rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-white"
>
<Avatar className="w-8 h-8 cursor-pointer">
<AvatarImage src={undefined} />
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
</button>
}
/>
) : (
<UserMenu
align="start"
user={user}
ports={ports}
trigger={
<button
type="button"
aria-label="Open user menu"
className="flex w-full items-center gap-3 rounded-md p-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-white"
>
<Avatar className="w-8 h-8 shrink-0 shadow-sm ring-2 ring-slate-200">
<AvatarImage src={undefined} />
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-foreground text-sm font-medium truncate">
{user?.name ?? 'User'}
</p>
<Badge
variant="outline"
className="text-xs px-1.5 py-0 text-slate-500 border-slate-300 mt-0.5"
>
{isSuperAdmin ? 'Super Admin' : formatRole(portRoles[0]?.role?.name)}
</Badge>
{currentPortName && (
<p className="mt-1 text-xs text-slate-400 truncate">{currentPortName}</p>
)}
</div>
</button>
}
/>
)}
</div>
</div>
</TooltipProvider>
);
}
export function Sidebar({
portRoles,
isSuperAdmin = false,
user,
ports,
portLogoUrls,
tenanciesModuleByPort,
expensesModuleByPort,
residentialModuleByPort,
}: SidebarProps) {
// Sidebar collapse removed - design preference is the always-expanded
// form. Forcibly false; the store flag stays for backwards-compat with
// any code still reading it.
const sidebarCollapsed = false;
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
const currentPortId = useUIStore((s) => s.currentPortId);
const currentPort = ports?.find((p) => p.id === currentPortId) ?? ports?.[0] ?? null;
const currentLogoUrl = currentPortId ? (portLogoUrls?.[currentPortId] ?? null) : null;
const tenanciesModuleEnabled = currentPortId
? (tenanciesModuleByPort?.[currentPortId] ?? false)
: false;
// Expenses defaults to enabled when the port's entry is missing - the
// registry default is `true`, so a port that's never explicitly
// toggled the feature should keep it visible.
const expensesModuleEnabled = currentPortId
? (expensesModuleByPort?.[currentPortId] ?? true)
: true;
// Residential defaults to enabled when the port's entry is missing -
// the registry default is `true`, so a port that's never explicitly
// toggled the feature keeps the section visible.
const residentialModuleEnabled = currentPortId
? (residentialModuleByPort?.[currentPortId] ?? true)
: true;
// Super admins see every section regardless of role rows.
const hasAdminAccess =
isSuperAdmin ||
portRoles.some(
(pr) =>
pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
);
const hasMarinaAccess =
isSuperAdmin || portRoles.some((pr) => pr.role?.permissions?.clients?.view);
const hasResidentialAccess =
isSuperAdmin ||
portRoles.some((pr) => pr.residentialAccess || pr.role?.permissions?.residential_clients?.view);
return (
<aside
className={cn(
'relative hidden md:flex flex-col h-screen border-r border-slate-200 transition-all duration-200 ease-in-out shrink-0 bg-white',
sidebarCollapsed ? 'w-sidebar-collapsed' : 'w-sidebar',
)}
>
<SidebarContent
collapsed={sidebarCollapsed}
portSlug={currentPortSlug ?? undefined}
portRoles={portRoles}
isSuperAdmin={isSuperAdmin}
hasAdminAccess={hasAdminAccess}
hasMarinaAccess={hasMarinaAccess}
hasResidentialAccess={hasResidentialAccess}
tenanciesModuleEnabled={tenanciesModuleEnabled}
expensesModuleEnabled={expensesModuleEnabled}
residentialModuleEnabled={residentialModuleEnabled}
user={user}
ports={ports}
currentPort={currentPort}
currentLogoUrl={currentLogoUrl}
/>
</aside>
);
}