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>
This commit is contained in:
129
src/components/layout/breadcrumbs.tsx
Normal file
129
src/components/layout/breadcrumbs.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Fragment } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { usePortContext } from '@/providers/port-provider';
|
||||
|
||||
// Human-readable labels for route segments
|
||||
const SEGMENT_LABELS: Record<string, string> = {
|
||||
dashboard: 'Dashboard',
|
||||
clients: 'Clients',
|
||||
interests: 'Interests',
|
||||
berths: 'Berths',
|
||||
documents: 'Documents',
|
||||
files: 'Files',
|
||||
expenses: 'Expenses',
|
||||
invoices: 'Invoices',
|
||||
email: 'Email',
|
||||
reminders: 'Reminders',
|
||||
settings: 'Settings',
|
||||
admin: 'Administration',
|
||||
reports: 'Reports',
|
||||
new: 'New',
|
||||
edit: 'Edit',
|
||||
profile: 'Profile',
|
||||
};
|
||||
|
||||
function formatSegment(segment: string): string {
|
||||
return SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const pathname = usePathname();
|
||||
const { currentPort, currentPortSlug } = usePortContext();
|
||||
|
||||
// Split pathname and filter empty segments
|
||||
const rawSegments = pathname.split('/').filter(Boolean);
|
||||
|
||||
// Remove the portSlug segment from display
|
||||
const segments = currentPortSlug
|
||||
? rawSegments.filter((seg) => seg !== currentPortSlug)
|
||||
: rawSegments;
|
||||
|
||||
if (segments.length === 0) {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-foreground font-medium">
|
||||
{currentPort?.name ?? 'Port Nimara CRM'}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
|
||||
// Build href for each segment
|
||||
const crumbs = segments.map((segment, index) => {
|
||||
const segmentsUpToHere = rawSegments.slice(0, rawSegments.indexOf(segment, index) + 1);
|
||||
const href = '/' + segmentsUpToHere.join('/');
|
||||
const label = formatSegment(segment);
|
||||
const isLast = index === segments.length - 1;
|
||||
|
||||
return { label, href, isLast };
|
||||
});
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{currentPort && (
|
||||
<>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/${currentPortSlug}/dashboard` as any}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{currentPort.name}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{crumbs.length > 0 && (
|
||||
<BreadcrumbSeparator>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</BreadcrumbSeparator>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{crumbs.map((crumb, index) => (
|
||||
<Fragment key={crumb.href}>
|
||||
<BreadcrumbItem>
|
||||
{crumb.isLast ? (
|
||||
<BreadcrumbPage className="font-medium text-foreground">
|
||||
{crumb.label}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={crumb.href as any}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{!crumb.isLast && (
|
||||
<BreadcrumbSeparator>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</BreadcrumbSeparator>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
56
src/components/layout/port-switcher.tsx
Normal file
56
src/components/layout/port-switcher.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { Port } from '@/lib/db/schema/ports';
|
||||
|
||||
interface PortSwitcherProps {
|
||||
ports: Port[];
|
||||
}
|
||||
|
||||
export function PortSwitcher({ ports }: PortSwitcherProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const currentPortId = useUIStore((s) => s.currentPortId);
|
||||
const setPort = useUIStore((s) => s.setPort);
|
||||
|
||||
// Hidden when user has access to only one port
|
||||
if (ports.length <= 1) return null;
|
||||
|
||||
function handlePortChange(portId: string) {
|
||||
const port = ports.find((p) => p.id === portId);
|
||||
if (!port) return;
|
||||
|
||||
setPort(port.id, port.slug);
|
||||
|
||||
// Invalidate all cached queries — they are port-scoped
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
// Navigate to the selected port's dashboard
|
||||
router.push(`/${port.slug}/dashboard` as any);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={currentPortId ?? undefined} onValueChange={handlePortChange}>
|
||||
<SelectTrigger className="w-40 h-8 text-sm">
|
||||
<SelectValue placeholder="Select port..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ports.map((port) => (
|
||||
<SelectItem key={port.id} value={port.id}>
|
||||
{port.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
345
src/components/layout/sidebar.tsx
Normal file
345
src/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
'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,
|
||||
LogOut,
|
||||
} 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
|
||||
href={item.href as any}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
137
src/components/layout/topbar.tsx
Normal file
137
src/components/layout/topbar.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { Plus, Moon, Sun, LogOut, User, Settings } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { PortSwitcher } from '@/components/layout/port-switcher';
|
||||
import { Breadcrumbs } from '@/components/layout/breadcrumbs';
|
||||
import { CommandSearch, SearchTrigger } from '@/components/search/command-search';
|
||||
import { NotificationBell } from '@/components/notifications/notification-bell';
|
||||
import type { Port } from '@/lib/db/schema/ports';
|
||||
|
||||
interface TopbarProps {
|
||||
ports: Port[];
|
||||
}
|
||||
|
||||
export function Topbar({ ports }: TopbarProps) {
|
||||
const router = useRouter();
|
||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const darkMode = useUIStore((s) => s.darkMode);
|
||||
const toggleDarkMode = useUIStore((s) => s.toggleDarkMode);
|
||||
|
||||
const base = currentPortSlug ? `/${currentPortSlug}` : '';
|
||||
|
||||
function handleToggleDarkMode() {
|
||||
toggleDarkMode();
|
||||
document.documentElement.classList.toggle('dark');
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-14 border-b border-border bg-background flex items-center gap-3 px-4 shrink-0">
|
||||
{/* Breadcrumbs / page title */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
|
||||
{/* Actions row */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* Global search — inline with dropdown results */}
|
||||
<CommandSearch />
|
||||
|
||||
{/* Port switcher — hidden for single port */}
|
||||
<PortSwitcher ports={ports} />
|
||||
|
||||
{/* + New dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" className="bg-brand hover:bg-brand-500 text-white gap-1.5">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">New</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Create</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/clients/new` as any)}>
|
||||
New Client
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/interests/new` as any)}>
|
||||
New Interest
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/expenses/new` as any)}>
|
||||
New Expense
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/reminders/new` as any)}>
|
||||
New Reminder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Notification bell — real-time via socket */}
|
||||
<NotificationBell />
|
||||
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
{/* User menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="rounded-full">
|
||||
<Avatar className="w-7 h-7">
|
||||
<AvatarImage src={undefined} />
|
||||
<AvatarFallback className="bg-brand text-white text-xs font-semibold">
|
||||
U
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/settings/profile` as any)}>
|
||||
<User className="w-4 h-4 mr-2" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/settings` as any)}>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleToggleDarkMode}>
|
||||
{darkMode ? (
|
||||
<>
|
||||
<Sun className="w-4 h-4 mr-2" />
|
||||
Light Mode
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Moon className="w-4 h-4 mr-2" />
|
||||
Dark Mode
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => router.push('/api/auth/sign-out')}
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Sign Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user