import { DASHBOARD_PATH } from "@/lib/deployment"; import { LayoutDashboard, BarChart3, FileBarChart, ArrowLeftRight, TrendingUp, Lightbulb, Building2, ChevronRight, ListTodo, ListChecks, Sun, Moon, Database, Link2, Sliders, UserCircle, LogOut, ChevronsUpDown, PanelLeftClose, PanelLeft, Bell, ExternalLink, KeyRound, Wallet, History, type LucideIcon, } from "lucide-react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { useMemo } from "react"; import { NavLink } from "@/components/NavLink"; import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarFooter, SidebarHeader, useSidebar, } from "@/components/ui/sidebar"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuSeparator, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuLabel, } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useFinanceData } from "@/context/FinanceDataContext"; import { useTheme } from "@/context/ThemeContext"; import { useAuth } from "@/context/AuthContext"; import { useNotifications } from "@/hooks/useNotifications"; import { useActivity } from "@/hooks/useActivity"; import { usePendingMerchantCategories } from "@/hooks/useMerchantCategoryReview"; import { useSuggestedTransfers } from "@/hooks/useTransfers"; import { useSuggestedDuplicates } from "@/hooks/useDuplicates"; import { activityTitle } from "@/lib/activity"; import { formatDate } from "@/lib/format"; import { formatDistanceToNow } from "date-fns"; import { cn } from "@/lib/utils"; import { accountDisplayName, accountMask, isBankAccount, isAssetAccount } from "@/lib/accounts"; import { Logo } from "@/components/brand/Logo"; import type { Account } from "@/types/finance"; const ICON_SIZE = 18; const ICON_STROKE = 1.5; const mainNav = [ { title: "Home", url: DASHBOARD_PATH, icon: LayoutDashboard }, { title: "Analytics", url: "/analytics", icon: BarChart3 }, { title: "Reports", url: "/reports", icon: FileBarChart }, { title: "Transactions", url: "/transactions", icon: ArrowLeftRight }, ]; /** Children of the Financial Planning parent item. */ const planningChildren = [ { title: "Goals Tracker", url: "/goals" }, { title: "Budgets", url: "/budgets" }, ]; // Non-financial "life" surfaces, the start of the control-panel vision: // Todos today, room for Health/etc. alongside it later. const lifeNav = [ { title: "Todos", url: "/todos", icon: ListTodo }, ]; type NavItem = { title: string; url: string; icon: LucideIcon; badge?: number }; const navLinkBase = "flex items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground rounded-md"; const navLinkActive = "bg-sidebar-accent text-sidebar-accent-foreground font-medium"; /** Chevron button; rotation uses Collapsible root `group` + `group-data-[state=open]` on the icon (Radix sets data-state on root). */ const collapsibleChevronTriggerClass = "absolute right-1 top-1/2 z-10 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-sidebar-foreground outline-none ring-sidebar-ring transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 data-[state=open]:bg-sidebar-accent/60"; const collapsibleChevronIconClass = "size-4 shrink-0 transition-transform duration-200 ease-out group-data-[state=open]:rotate-90"; /** Group accounts under their institution, each group sorted by name. */ function groupByInstitution(list: Account[]) { const m = new Map(); list.forEach((a) => { const key = (a.institution || "Other").trim() || "Other"; if (!m.has(key)) m.set(key, []); m.get(key)!.push(a); }); return Array.from(m.entries()) .map(([institution, accs]) => ({ institution, accounts: [...accs].sort((x, y) => (accountDisplayName(x) || x.mask || "").localeCompare(accountDisplayName(y) || y.mask || "")), })) .sort((a, b) => a.institution.localeCompare(b.institution)); } export function AppSidebar() { const { state, toggleSidebar } = useSidebar(); const collapsed = state === "collapsed"; const { investmentAccounts, accounts, dateFormat } = useFinanceData(); const notifications = useNotifications(); const unreadCount = notifications.filter((n) => n.actionNeeded).length; const { data: recentActivity = [] } = useActivity(4); const { data: pendingCategories = [] } = usePendingMerchantCategories(); const { data: suggestedTransfers = [] } = useSuggestedTransfers(); const { data: suggestedDuplicates = [] } = useSuggestedDuplicates(); const reviewCount = pendingCategories.length + suggestedTransfers.length + suggestedDuplicates.length; const lifeNavItems: NavItem[] = [ ...lifeNav, { title: "Review", url: "/review", icon: ListChecks, badge: reviewCount }, ]; const { theme, toggleTheme } = useTheme(); const { user, logout } = useAuth(); const location = useLocation(); const navigate = useNavigate(); const displayName = user?.name || user?.email?.split("@")[0] || "Account"; const avatarInitial = (user?.name || user?.email || "?").trim().charAt(0).toUpperCase(); const isInvestmentsActive = location.pathname.startsWith("/investments"); // An account's detail page (/accounts/) belongs to whichever list it is // on: Accounts for bank accounts, Assets for the rest. const detailId = location.pathname.startsWith("/accounts/") ? decodeURIComponent(location.pathname.split("/")[2] ?? "") : ""; const filteredId = location.pathname === "/transactions" ? new URLSearchParams(location.search).get("account") : null; const focused = accounts.find((a) => a.id === (detailId || filteredId)); const onAsset = !!focused && isAssetAccount(focused.type); const isAccountsActive = location.pathname === "/accounts" || (!!focused && !onAsset); const isAssetsActive = location.pathname === "/assets" || onAsset; const bankTree = useMemo(() => groupByInstitution(accounts.filter((a) => isBankAccount(a.type))), [accounts]); const assetTree = useMemo(() => groupByInstitution(accounts.filter((a) => isAssetAccount(a.type))), [accounts]); const renderAccountGroup = (opts: { title: string; url: string; icon: typeof Building2; active: boolean; tree: ReturnType; emptyLabel: string; emptyTo: string; }) => (
{opts.title}
{opts.tree.length === 0 ? ( {opts.emptyLabel} ) : ( opts.tree.map(({ institution, accounts: instAccounts }) => (

{institution}

{instAccounts.map((acc) => { const acctUrl = `/accounts/${encodeURIComponent(acc.id)}`; const isAcctActive = location.pathname === acctUrl; const name = accountDisplayName(acc); const mask = accountMask(acc); const label = mask ? `${name} ··${mask}` : name; return ( {name} {mask ? ··{mask} : null} ); })}
)) )}
); const renderNavItems = (items: NavItem[]) => ( {items.map((item) => ( {item.title} {!!item.badge && ( {item.badge} )} ))} ); return ( {/* Header, brand + collapse toggle aligned with breadcrumbs */}
{/* Main nav */} Overview {renderNavItems(mainNav)} {/* Investments */}
Investments
{investmentAccounts.map((acct) => ( {acct.label} ))}
{/* Accounts (everyday bank accounts) and Assets (investments, property, vehicles, loans) */} {renderAccountGroup({ title: "Accounts", url: "/accounts", icon: Building2, active: isAccountsActive, tree: bankTree, emptyLabel: "Connect accounts", emptyTo: "/settings/sources?connect=1", })} {renderAccountGroup({ title: "Assets", url: "/assets", icon: Wallet, active: isAssetsActive, tree: assetTree, emptyLabel: "Add an asset", emptyTo: "/assets", })} {/* Planning */} Plan
Financial Planning
{planningChildren.map((item) => ( {item.title} ))}
{/* Life, beyond finances */} Life {renderNavItems(lifeNavItems)}
{/* Footer: User avatar + Notification bell */}
{displayName} {user?.email && ( {user.isDemo ? "Read-only demo" : user.email} )} navigate("/settings/account")}> Profile navigate("/notifications")}> Notifications {unreadCount > 0 && ( {unreadCount} )} navigate("/settings/sources")}> Data Sources navigate("/settings/brokerages")}> Brokerages navigate("/settings/preferences")}> Preferences navigate("/settings/api-access")}> API Access Appearance toggleTheme()}> e.preventDefault()}> Light e.preventDefault()}> Dark logout()} > Sign out {/* Notification bell with popover panel */}

Alerts

{notifications.length === 0 ? (

Nothing needs attention

) : (
{notifications.slice(0, 6).map((n) => (

{n.title}

{n.detail}

{formatDate(n.date, dateFormat, "short")}

))} {notifications.length > 6 && ( )}
)} {/* Every create/update/delete + login, not just the alerts above - a quick "did that actually save" glance without leaving the current page. Full history lives on the Activity tab, this is only ever the last few. */}
Recent activity
{recentActivity.length === 0 ? (

Nothing yet

) : (
{/* Read-only, like the full Activity tab: a log entry isn't a link to anything useful. */} {recentActivity.map((a) => (

{activityTitle(a)}

{a.at ? formatDistanceToNow(new Date(a.at), { addSuffix: true }) : ""}
))}
)}
); }