"use client"; import { useState, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; import { useRouter, usePathname } from "next/navigation"; import { UserButton } from "@clerk/nextjs"; import { LayoutDashboard, FileText, Receipt, TrendingUp, BarChart3, Users, CreditCard, Calendar, Clock, FolderOpen, Shield, Megaphone, MessageSquare, Home, User, Settings, UserPlus, CheckSquare, Box, Star, Menu, X, Search, } from "lucide-react"; import { CurrencyProvider } from "@/components/providers/CurrencyProvider"; import { CurrencyData } from "@/lib/currency"; import { canAccessPage, getUserPermissions } from "@/lib/supabase/rbac-actions"; import { fetchNotifications, getUnreadNotificationCount } from "@/lib/supabase/invite-actions"; import { PAGE_PERMISSIONS, hasBypassRole } from "@/lib/permissions"; import { Button } from "@/components/ui/button"; export type PageKey = | "home" | "invoices" | "expenses" | "bank-feeds" | "reports" | "employees" | "roles" | "payroll" | "leave" | "performance" | "documents" | "recruitment" | "tasks" | "noticeboard" | "comms" | "directory" | "assets" | "portal" | "clients" | "settings" | "pricing" | "privacy" | "terms"; interface PageDef { key: PageKey; name: string; href: string; icon: typeof LayoutDashboard; section: "ACCOUNTING" | "PEOPLE" | "WORKSPACE" | null; loader: (() => Promise) | null; } // Dynamic imports for each page view — code-split, loaded on first visit // Each loader returns the component function (NOT the result of calling it) const pageLoaders: Record Promise) | null> = { home: () => import("./pages/HomePage").then((m) => m.default), invoices: () => import("./pages/InvoicesPage").then((m) => m.default), expenses: () => import("./pages/ExpensesShell").then((m) => m.default), "bank-feeds": () => import("./pages/BankFeedsPage").then((m) => m.default), reports: () => import("./pages/ReportsShell").then((m) => m.default), employees: () => import("./pages/EmployeesShell").then((m) => m.default), roles: () => import("./pages/RolesPage").then((m) => m.default), payroll: () => import("./pages/PayrollShell").then((m) => m.default), leave: () => import("./pages/LeaveShell").then((m) => m.default), performance: () => import("./pages/PerformancePage").then((m) => m.default), documents: () => import("./pages/DocumentsShell").then((m) => m.default), recruitment: () => import("./pages/RecruitmentShell").then((m) => m.default), tasks: () => import("./pages/TasksShell").then((m) => m.default), noticeboard: () => import("./pages/NoticeboardPage").then((m) => m.default), comms: () => import("./pages/CommsPage").then((m) => m.default), directory: () => import("./pages/DirectoryPage").then((m) => m.default), assets: () => import("./pages/AssetsPage").then((m) => m.default), portal: () => import("./pages/PortalPage").then((m) => m.default), clients: () => import("./pages/ClientsPage").then((m) => m.default), settings: () => import("./pages/SettingsPage").then((m) => m.default), pricing: () => import("./pages/PricingPage").then((m) => m.default), privacy: () => import("./pages/PrivacyPage").then((m) => m.default), terms: () => import("./pages/TermsPage").then((m) => m.default), }; export const PAGE_DEFS: PageDef[] = [ { key: "home", name: "Home", href: "/home", icon: Home, section: null, loader: pageLoaders.home }, { key: "invoices", name: "Invoices", href: "/invoices", icon: FileText, section: "ACCOUNTING", loader: pageLoaders.invoices }, { key: "expenses", name: "Expenses", href: "/expenses", icon: Receipt, section: "ACCOUNTING", loader: pageLoaders.expenses }, { key: "bank-feeds", name: "Bank feeds", href: "/bank-feeds", icon: TrendingUp, section: "ACCOUNTING", loader: pageLoaders["bank-feeds"] }, { key: "reports", name: "Reports", href: "/reports", icon: BarChart3, section: "ACCOUNTING", loader: pageLoaders.reports }, { key: "employees", name: "Employees", href: "/employees", icon: Users, section: "PEOPLE", loader: pageLoaders.employees }, { key: "roles", name: "Roles", href: "/roles", icon: Shield, section: "PEOPLE", loader: pageLoaders.roles }, { key: "payroll", name: "Payroll", href: "/payroll", icon: CreditCard, section: "PEOPLE", loader: pageLoaders.payroll }, { key: "leave", name: "Leave", href: "/leave", icon: Calendar, section: "PEOPLE", loader: pageLoaders.leave }, { key: "performance", name: "Appraisals", href: "/performance", icon: Star, section: "PEOPLE", loader: pageLoaders.performance }, { key: "documents", name: "Documents", href: "/documents", icon: FolderOpen, section: "PEOPLE", loader: pageLoaders.documents }, { key: "recruitment", name: "Hiring", href: "/recruitment", icon: UserPlus, section: "PEOPLE", loader: pageLoaders.recruitment }, { key: "tasks", name: "Tasks", href: "/tasks", icon: CheckSquare, section: "PEOPLE", loader: pageLoaders.tasks }, { key: "noticeboard", name: "Noticeboard", href: "/noticeboard", icon: Megaphone, section: "WORKSPACE", loader: pageLoaders.noticeboard }, { key: "comms", name: "Comms", href: "/comms", icon: MessageSquare, section: "WORKSPACE", loader: pageLoaders.comms }, { key: "directory", name: "Directory", href: "/directory", icon: Search, section: "WORKSPACE", loader: pageLoaders.directory }, { key: "assets", name: "Assets", href: "/assets", icon: Box, section: "WORKSPACE", loader: pageLoaders.assets }, { key: "portal", name: "Portal", href: "/portal", icon: User, section: null, loader: pageLoaders.portal }, { key: "clients", name: "Clients", href: "/clients", icon: Users, section: null, loader: pageLoaders.clients }, { key: "settings", name: "Settings", href: "/settings", icon: Settings, section: null, loader: pageLoaders.settings }, { key: "pricing", name: "Pricing", href: "/pricing", icon: Receipt, section: null, loader: pageLoaders.pricing }, { key: "privacy", name: "Privacy", href: "/privacy", icon: Shield, section: null, loader: pageLoaders.privacy }, { key: "terms", name: "Terms", href: "/terms", icon: FileText, section: null, loader: pageLoaders.terms }, ]; const PAGE_BY_HREF = new Map(PAGE_DEFS.map((d) => [d.href, d])); function hrefToPageKey(href: string): PageKey | null { return PAGE_BY_HREF.get(href)?.key ?? null; } // ── Page cache (loaded views stay in memory for instant switching) ── const pageCache = new Map(); // ── Placeholder for unimplemented pages ── function PlaceholderPage({ name }: { name: string }) { return (

{name}

This page is under construction. Check back soon!

); } function PageSkeleton() { return (
{[1, 2, 3, 4].map((i) => (
))}
); } // ── Sidebar ────────────────────────────────────────────────── function Sidebar({ activePage, onNavigate, visiblePages, isMobile = false, onClose, }: { activePage: PageKey; onNavigate: (key: PageKey) => void; visiblePages: Set; isMobile?: boolean; onClose?: () => void; }) { const homePage = PAGE_DEFS.find((d) => d.key === "home"); const accountingPages = PAGE_DEFS.filter((d) => d.section === "ACCOUNTING" && visiblePages.has(d.key)); const peoplePages = PAGE_DEFS.filter((d) => d.section === "PEOPLE" && visiblePages.has(d.key)); const workspacePages = PAGE_DEFS.filter((d) => d.section === "WORKSPACE" && visiblePages.has(d.key)); const extraPages = PAGE_DEFS.filter((d) => d.section === null && d.key !== "home" && visiblePages.has(d.key)); const handleNavigate = (key: PageKey) => { onNavigate(key); if (isMobile && onClose) onClose(); }; return (
{/* Logo */}

Vela

{isMobile && onClose && ( )}
{/* Navigation */} {/* User Button */}
); } /** Wraps UserButton in a client-only mount guard to avoid hydration mismatch. */ function UserButtonClient() { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); if (!mounted) { return
; } return ( ); } // ── AppShell ───────────────────────────────────────────────── export default function AppShell({ initialPage, initialChildren, currency, }: { initialPage: PageKey; initialChildren: ReactNode; currency?: CurrencyData; }) { const router = useRouter(); const pathname = usePathname(); // Determine the correct initial page from the current URL path const resolvedPage = useMemo(() => { const key = hrefToPageKey(pathname); return key || initialPage; }, [pathname, initialPage]); // Check if the resolved page has a loader — if so, load it immediately const resolvedDef = PAGE_DEFS.find((d) => d.key === resolvedPage); const hasInitialLoader = !!resolvedDef?.loader; const [activePage, setActivePage] = useState(resolvedPage); const [pageContent, setPageContent] = useState( hasInitialLoader ? null : (resolvedPage === initialPage ? initialChildren : null) ); const [loading, setLoading] = useState(hasInitialLoader); // Sync state when URL changes (e.g., back/forward buttons) useEffect(() => { if (resolvedPage !== activePage) { setActivePage(resolvedPage); const Cached = pageCache.get(resolvedPage); if (Cached) { setPageContent(); setLoading(false); } else { // This is handled by the other effects or navigateTo } } }, [resolvedPage, activePage]); const [visiblePages, setVisiblePages] = useState>(new Set(PAGE_DEFS.map((d) => d.key))); const [unreadNotifs, setUnreadNotifs] = useState(0); const [showNotifications, setShowNotifications] = useState(false); const [notifications, setNotifications] = useState([]); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const notifRef = useRef(null); // Close notifications on click outside useEffect(() => { const handler = (e: MouseEvent) => { if (notifRef.current && !notifRef.current.contains(e.target as Node)) { setShowNotifications(false); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); // Fetch unread notification count useEffect(() => { getUnreadNotificationCount().then(setUnreadNotifs).catch(() => {}); }, []); const handleOpenNotifications = async () => { if (showNotifications) { setShowNotifications(false); return; } setShowNotifications(true); const notifs = await fetchNotifications(20); setNotifications(notifs); }; // Fetch user permissions and determine visible pages useEffect(() => { getUserPermissions().then(({ orgRole, permissions }) => { const visible = new Set(["home"]); // home always visible for (const def of PAGE_DEFS) { if (def.key === "home") continue; // owner/admin see everything if (hasBypassRole(orgRole)) { visible.add(def.key); continue; } // Check page permissions const required = (PAGE_PERMISSIONS as Record)[def.key]; if (!required || required.length === 0) { visible.add(def.key); } else if (required.some((p) => permissions.has(p))) { visible.add(def.key); } } setVisiblePages(visible); }).catch(() => { // On error, default to showing all pages (safe fallback) setVisiblePages(new Set(PAGE_DEFS.map((d) => d.key))); }); }, []); // Load the initial page if it has a loader (e.g., home, invoices, etc.) useEffect(() => { if (hasInitialLoader && resolvedDef) { const Cached = pageCache.get(resolvedPage); if (Cached) { setPageContent(); setLoading(false); } else { resolvedDef.loader!().then((Comp) => { pageCache.set(resolvedPage, Comp); setPageContent(); setLoading(false); }); } } }, []); // eslint-disable-line react-hooks/exhaustive-deps // Navigate to a page const navigateTo = useCallback( (key: PageKey) => { if (key === activePage) return; setActivePage(key); const def = PAGE_DEFS.find((d) => d.key === key); if (!def) return; // For non-app pages (pricing, privacy, terms), swap content AND update URL if (!def.section) { router.push(def.href, { scroll: false }); if (def.loader) { setLoading(true); def.loader().then((Comp) => { pageCache.set(key, Comp); setPageContent(); setLoading(false); }); } else { const C = () => ; pageCache.set(key, C); setPageContent(); } return; } // App pages: update URL without full navigation router.push(def.href, { scroll: false }); // Check cache first (instant switch) const cached = pageCache.get(key); if (cached) { const Comp = cached; setPageContent(); return; } // Load page if it has a loader if (def.loader) { setLoading(true); def.loader().then((Comp) => { pageCache.set(key, Comp); setPageContent(); setLoading(false); }); } else { // No loader — show placeholder const C = () => ; pageCache.set(key, C); setPageContent(); } }, [activePage, router] ); // If resolved page differs from initial, load it on mount useEffect(() => { if (resolvedPage === initialPage) return; const def = PAGE_DEFS.find((d) => d.key === resolvedPage); if (!def) return; if (def.loader) { setLoading(true); def.loader().then((Comp) => { pageCache.set(resolvedPage, Comp); setPageContent(); setLoading(false); }); } else { const C = () => ; pageCache.set(resolvedPage, C); setPageContent(); setLoading(false); } }, [resolvedPage, initialPage]); return (
{/* Sidebar — hidden on mobile, shown on lg+ */}
{/* Mobile Sidebar Overlay */} {mobileMenuOpen && (
setMobileMenuOpen(false)} />
setMobileMenuOpen(false)} />
)}
{/* Top Header */}
{/* Hamburger for mobile */} {/* Logo for mobile only (centered or left) */}

Vela

{/* Notifications Dropdown */} {showNotifications && (

Notifications

{notifications.length === 0 ? (

No notifications

) : ( notifications.map((n) => (

{n.title}

{n.body &&

{n.body}

}

{new Date(n.created_at).toLocaleDateString()}

)) )}
)} {/* Mobile User Button (if needed, or just let Clerk handle it) */}
{loading ? : pageContent}
); }