diff --git a/src/components/layout/inbox.tsx b/src/components/layout/inbox.tsx new file mode 100644 index 0000000..7dd26c4 --- /dev/null +++ b/src/components/layout/inbox.tsx @@ -0,0 +1,260 @@ +'use client'; + +/** + * Unified Inbox - merges the previous AlertBell + NotificationBell into + * a single bell with two tabs: + * + * System - port-wide operational alerts (system-generated, all + * users with `view_alerts` see the same list). Sourced from + * `useAlertList('open')` + `useAlertCount()`. + * + * Personal - per-user notifications (you signed something, someone + * @-mentioned you, your reminder fired, etc.). Sourced from + * `useNotifications` + the /api/v1/notifications endpoint. + * + * The bell icon shows a single combined unread count. The popover opens + * to whichever tab has unread items first; ties → Personal. + * + * Replaces the topbar's `` + ``. The two + * old components stay in the tree (untouched) so anything that imports + * them directly (tests, deep-link pages) keeps working - they're simply + * no longer mounted in the topbar. + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; +import Link from 'next/link'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Bell, ShieldAlert, Inbox as InboxIcon } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { apiFetch } from '@/lib/api/client'; +import { useUIStore } from '@/stores/ui-store'; +import { cn } from '@/lib/utils'; +import { useNotifications } from '@/hooks/use-notifications'; +import { NotificationItem } from '@/components/notifications/notification-item'; +import { AlertCard, AlertCardEmpty } from '@/components/alerts/alert-card'; +import { useAlertCount, useAlertList, useAlertRealtime } from '@/components/alerts/use-alerts'; + +interface NotificationListResponse { + data: Array<{ + id: string; + type: string; + title: string; + description: string | null; + link: string | null; + isRead: boolean; + createdAt: Date; + }>; + total: number; +} + +type TabKey = 'personal' | 'system'; + +export function Inbox() { + const portSlug = useUIStore((s) => s.currentPortSlug); + const [open, setOpen] = useState(false); + + // ── System (alerts) ── + const { data: alertCount } = useAlertCount(); + const { data: alertList, isLoading: alertsLoading } = useAlertList('open', open); + useAlertRealtime(); + const systemTotal = alertCount?.total ?? 0; + const systemCritical = alertCount?.bySeverity.critical ?? 0; + const systemAlerts = alertList?.data ?? []; + const systemTop = systemAlerts.slice(0, 8); + + // ── Personal (notifications) ── + const { unreadCount: personalUnread } = useNotifications(); + const queryClient = useQueryClient(); + const { data: notifData, isLoading: notifLoading } = useQuery({ + queryKey: ['notifications', 'list'], + queryFn: () => apiFetch('/api/v1/notifications?limit=20'), + staleTime: 30_000, + // Only fetch the list when the popover opens - count stays live in the + // background via useNotifications' realtime hook. + enabled: open, + }); + const notifications = notifData?.data ?? []; + + const markRead = useMutation({ + mutationFn: (id: string) => apiFetch(`/api/v1/notifications/${id}`, { method: 'PATCH' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }), + }); + const markAllRead = useMutation({ + mutationFn: () => apiFetch('/api/v1/notifications/read-all', { method: 'POST' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }), + }); + + // ── Combined badge + initial tab ── + const combined = systemTotal + personalUnread; + const initialTab = useMemo(() => { + // Default to whichever side has unread items; tie → Personal (most users + // care about their own pings before system-wide rumblings). + if (systemTotal > 0 && personalUnread === 0) return 'system'; + return 'personal'; + }, [systemTotal, personalUnread]); + + const [activeTab, setActiveTab] = useState(initialTab); + + // Re-anchor to the unread side ONLY on the rising edge of `open` (i.e. + // when the popover actually opens). The previous version ran whenever + // `initialTab` changed too, which yanked the tab out from under the + // user any time a new socket event re-derived the initial tab while + // they were actively reading the other one. + const initialTabRef = useRef(initialTab); + initialTabRef.current = initialTab; + useEffect(() => { + if (open) setActiveTab(initialTabRef.current); + }, [open]); + + return ( + + + + + + + setActiveTab(v as TabKey)}> +
+ + + + Personal + {personalUnread > 0 ? ( + + {personalUnread > 99 ? '99+' : personalUnread} + + ) : null} + + + + System + {systemTotal > 0 ? ( + 0 + ? 'bg-destructive/15 text-destructive' + : 'bg-amber-500/15 text-amber-600', + )} + > + {systemTotal > 99 ? '99+' : systemTotal} + + ) : null} + + +
+ + {/* ── Personal tab ── */} + +
+

+ Your notifications +

+ {personalUnread > 0 ? ( + + ) : ( + + View all + + )} +
+ + + {notifLoading ? ( +
+ Loading… +
+ ) : notifications.length === 0 ? ( +
+ + No notifications +
+ ) : ( +
+ {notifications.map((n) => ( + markRead.mutate(id)} + /> + ))} +
+ )} +
+
+ + {/* ── System tab ── */} + +
+

+ Active alerts +

+ + View all + +
+ + + {alertsLoading ? ( +
Loading…
+ ) : systemTop.length === 0 ? ( +
+ +
+ ) : ( +
+ {systemTop.map((a) => ( + + ))} +
+ )} +
+
+
+
+
+ ); +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 546470e..e314f77 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import Image from 'next/image'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { @@ -13,9 +14,9 @@ import { Receipt, FileText, FolderOpen, - Mail, Bell, Camera, + Globe, Settings, Shield, Home, @@ -32,13 +33,32 @@ 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 { @@ -103,15 +123,38 @@ function buildNavSections(portSlug: string | undefined): NavSection[] { marinaRequired: true, items: [ { href: `${base}/expenses`, label: 'Expenses', icon: Receipt }, - { href: `${base}/scan`, label: 'Scan receipt', icon: Camera }, { 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: [ - { href: `${base}/email`, label: 'Email', icon: Mail }, + // 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 }, ], }, @@ -185,6 +228,7 @@ function SidebarContent({ hasMarinaAccess, hasResidentialAccess, user, + ports, onToggleCollapse, }: { collapsed: boolean; @@ -194,6 +238,7 @@ function SidebarContent({ hasMarinaAccess: boolean; hasResidentialAccess: boolean; user?: SidebarProps['user']; + ports?: Port[]; /** When provided, renders the collapse toggle row above the user footer (desktop). */ onToggleCollapse?: () => void; }) { @@ -201,29 +246,61 @@ function SidebarContent({ 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; - return pathname.startsWith(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 (
- {/* Logo area */} + {/* Brand header - logo centered (large when expanded, smaller when + collapsed). Collapse toggle floats top-right as a tiny chevron. */}
-
- PN -
- {!collapsed && ( -
-

Port Nimara

-

Marina CRM

-
+ Port Nimara + {onToggleCollapse && ( + )}
@@ -276,57 +353,62 @@ function SidebarContent({ - {/* Collapse toggle (desktop only) */} - {onToggleCollapse && ( - - )} - - {/* User footer */} -
+ {/* 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. */} +
{collapsed ? ( - - - - - - U - - - - User Profile - - ) : ( -
- - - - {(user?.name ?? 'U').slice(0, 1).toUpperCase()} - - -
-

{user?.name ?? 'User'}

- - {portRoles[0]?.role?.name ?? 'Staff'} - -
-
+ + + + {(user?.name ?? 'U').slice(0, 1).toUpperCase()} + + + + } + /> + ) : ( + + + + + {(user?.name ?? 'U').slice(0, 1).toUpperCase()} + + +
+

+ {user?.name ?? 'User'} +

+ + {humanizeRole(portRoles[0]?.role?.name)} + +
+ + } + /> )}
@@ -334,7 +416,7 @@ function SidebarContent({ ); } -export function Sidebar({ portRoles, isSuperAdmin = false, user }: SidebarProps) { +export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: SidebarProps) { const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed); const toggleSidebar = useUIStore((s) => s.toggleSidebar); const currentPortSlug = useUIStore((s) => s.currentPortSlug); @@ -370,6 +452,7 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user }: SidebarProps) hasMarinaAccess={hasMarinaAccess} hasResidentialAccess={hasResidentialAccess} user={user} + ports={ports} onToggleCollapse={toggleSidebar} /> diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index 51ee6f7..5a84449 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Plus, Moon, Sun, LogOut, User, Settings, Bell } from 'lucide-react'; +import { Plus } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useUIStore } from '@/stores/ui-store'; @@ -15,11 +15,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { PortSwitcher } from '@/components/layout/port-switcher'; import { Breadcrumbs } from '@/components/layout/breadcrumbs'; import { CommandSearch } from '@/components/search/command-search'; -import { NotificationBell } from '@/components/notifications/notification-bell'; -import { AlertBell } from '@/components/alerts/alert-bell'; +import { Inbox } from '@/components/layout/inbox'; +import { UserMenu } from '@/components/layout/user-menu'; import type { Port } from '@/lib/db/schema/ports'; interface TopbarProps { @@ -30,31 +29,29 @@ interface TopbarProps { export function Topbar({ ports, user }: 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 ( -
- {/* Breadcrumbs / page title */} -
+ // Three-column grid: breadcrumbs left, search center, actions right. + // The brand logo lives in the sidebar header (per design feedback) so the + // topbar center is dedicated to the global search bar. +
+ {/* LEFT: breadcrumbs / page title */} +
- {/* Actions row */} -
- {/* Global search — inline with dropdown results */} - - - {/* Port switcher — hidden for single port */} - + {/* CENTER: global search - capped width and horizontally centered + inside its grid slot so it sits visually in the middle of the + topbar regardless of breadcrumb / action-row width. */} +
+
+ +
+
+ {/* RIGHT: action row */} +
{/* + New dropdown */} @@ -88,17 +85,21 @@ export function Topbar({ ports, user }: TopbarProps) { - {/* Phase B operational alerts — distinct from user notifications */} - - - {/* Notification bell — real-time via socket */} - + {/* Unified inbox - combines system alerts (operational) and personal + notifications (user-targeted) into a single bell with two tabs. + Replaces the previous AlertBell + NotificationBell pair. */} + - {/* User menu */} - - + {/* User menu - single source of truth for the profile dropdown. + Same component is mounted in the sidebar footer so the menu + items (incl. port switcher) stay consistent across triggers. */} + @@ -107,56 +108,8 @@ export function Topbar({ ports, user }: TopbarProps) { - - - -
{user?.name ?? 'My Account'}
- {user?.email && ( -
{user.email}
- )} -
- - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - router.push(`${base}/settings/profile` as any)}> - - Profile - - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - router.push(`${base}/settings` as any)}> - - Settings - - router.push(`${base}/notifications/preferences` as any)} - > - - Notification preferences - - - - {darkMode ? ( - <> - - Light Mode - - ) : ( - <> - - Dark Mode - - )} - - - router.push('/api/auth/sign-out')} - > - - Sign Out - -
-
+ } + />
); diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx new file mode 100644 index 0000000..b735c1e --- /dev/null +++ b/src/components/layout/user-menu.tsx @@ -0,0 +1,159 @@ +'use client'; + +/** + * Unified user menu - used by the topbar avatar AND the sidebar-footer + * profile row. Encapsulates: + * - Profile / Settings / Notification preferences links + * - Dark-mode toggle + * - Sign out + * - Inline port switcher (when the user has access to >1 port) + * + * Everywhere a "click my profile" affordance lives, it should mount this + * component. That keeps the menu items consistent regardless of which + * trigger the user reached for. + */ + +import { useRouter } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; +import { Moon, Sun, LogOut, User, Settings, Bell, Check, Building2 } from 'lucide-react'; +import { type ReactNode } from 'react'; + +import { useUIStore } from '@/stores/ui-store'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import type { Port } from '@/lib/db/schema/ports'; + +interface UserMenuProps { + /** Element rendered as the dropdown trigger. Must be a single React node + * that can receive a click handler (asChild semantics). */ + trigger: ReactNode; + /** "start" anchors menu under a sidebar-footer trigger (left edge); + * "end" anchors under a top-right avatar. Forwarded to Radix. */ + align?: 'start' | 'end'; + user?: { name: string; email: string }; + /** Ports the user has access to. When length > 1, renders a port-switcher + * group inside the menu. When ≤ 1, the switcher is omitted. */ + ports?: Port[]; +} + +export function UserMenu({ trigger, align = 'end', user, ports }: UserMenuProps) { + const router = useRouter(); + const queryClient = useQueryClient(); + const currentPortId = useUIStore((s) => s.currentPortId); + const currentPortSlug = useUIStore((s) => s.currentPortSlug); + const setPort = useUIStore((s) => s.setPort); + const darkMode = useUIStore((s) => s.darkMode); + const toggleDarkMode = useUIStore((s) => s.toggleDarkMode); + + const base = currentPortSlug ? `/${currentPortSlug}` : ''; + const showPortSwitcher = ports && ports.length > 1; + + function handleToggleDarkMode() { + toggleDarkMode(); + document.documentElement.classList.toggle('dark'); + } + + function handlePortChange(port: Port) { + if (port.id === currentPortId) return; + setPort(port.id, port.slug); + // All cached queries are port-scoped - invalidate so they refetch with + // the new X-Port-Id header. + queryClient.invalidateQueries(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.push(`/${port.slug}/dashboard` as any); + } + + return ( + + {trigger} + + +
{user?.name ?? 'My Account'}
+ {user?.email && ( +
{user.email}
+ )} +
+ + + {showPortSwitcher && ( + <> + {/* Port list lives in a submenu so the user has to actively hover/ + click "Switch port" to see them - the main menu stays compact + regardless of how many ports the operator has access to. */} + + + + Switch port + + + {ports!.map((port) => { + const active = port.id === currentPortId; + return ( + handlePortChange(port)} + className={active ? 'bg-accent/40' : undefined} + > + {port.name} + {active && } + + ); + })} + + + + + )} + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + router.push(`${base}/settings/profile` as any)}> + + Profile + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + router.push(`${base}/settings` as any)}> + + Settings + + router.push(`${base}/notifications/preferences` as any)} + > + + Notification preferences + + + + {darkMode ? ( + <> + + Light Mode + + ) : ( + <> + + Dark Mode + + )} + + + router.push('/api/auth/sign-out')} + > + + Sign Out + +
+
+ ); +} diff --git a/src/components/notifications/notification-bell.tsx b/src/components/notifications/notification-bell.tsx index 776c907..da9bace 100644 --- a/src/components/notifications/notification-bell.tsx +++ b/src/components/notifications/notification-bell.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState } from 'react'; import { Bell } from 'lucide-react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -27,11 +28,18 @@ interface NotificationListResponse { export function NotificationBell() { const { unreadCount } = useNotifications(); const queryClient = useQueryClient(); + // Track popover open state so we only fire the list fetch when the user + // actually opens the bell. Without this, an instance of NotificationBell + // mounted alongside would populate the same ['notifications', + // 'list'] cache key without the gating Inbox carefully applies, defeating + // Inbox's enabled-on-open optimization. + const [open, setOpen] = useState(false); const { data, isLoading } = useQuery({ queryKey: ['notifications', 'list'], queryFn: () => apiFetch('/api/v1/notifications?limit=20'), staleTime: 30_000, + enabled: open, }); const markReadMutation = useMutation({ @@ -52,7 +60,7 @@ export function NotificationBell() { const notifications = data?.data ?? []; return ( - +