Replace hand-rolled shell with shadcn sidebar + breadcrumb header

AppSidebar (icon-collapsible, sync warning badge, brand header),
NavUser footer (avatar + privacy/theme/logout dropdown), and a thin
header with SidebarTrigger, breadcrumbs, and quick toggles. Mobile
drawer and cmd+B come from the sidebar primitives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-04 10:46:34 -06:00
parent d117fb79ea
commit d9039c76b9
3 changed files with 280 additions and 211 deletions

View File

@@ -1,239 +1,100 @@
import { useState, useEffect } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
Sparkles,
Telescope,
Calculator,
BarChart3,
Landmark,
PiggyBank,
Droplets,
Layers,
LogOut,
TrendingUp,
Wallet,
Menu,
RefreshCw,
Sun,
Moon,
Eye,
EyeOff,
type LucideIcon,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link, Outlet, useLocation } from "react-router-dom";
import { Eye, EyeOff, Moon, Sun } from "lucide-react";
import { getSyncStatus } from "@/lib/api";
import { titleFor } from "@/lib/navigation";
import { useTheme } from "@/contexts/theme-context";
import { usePrivacy } from "@/contexts/privacy-context";
import { useAuth } from "@/AuthContext";
import { AppSidebar } from "@/components/app-sidebar";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetClose,
} from "@/components/ui/sheet";
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface NavSection {
label: string;
items: { to: string; icon: LucideIcon; label: string }[];
}
const navSections: NavSection[] = [
{
label: "General",
items: [
{ to: "/asistente", icon: Sparkles, label: "Asistente" },
{ to: "/sync", icon: RefreshCw, label: "Sincronización" },
],
},
{
label: "Finanzas",
items: [
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
{ to: "/salarios", icon: Landmark, label: "Salarios" },
{ to: "/financiamientos", icon: Layers, label: "Financiamientos" },
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
{ to: "/planificador", icon: Telescope, label: "Planificador" },
{ to: "/analytics", icon: BarChart3, label: "Analytics" },
],
},
{
label: "Servicios",
items: [
{ to: "/servicios-municipales", icon: Droplets, label: "Municipalidad" },
],
},
];
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
const { pathname } = useLocation();
const isActive = (to: string) =>
pathname === to || pathname.startsWith(`${to}/`);
// Surface stalled ingestion sources as a dot on the Sincronización item.
const syncQ = useQuery({
queryKey: ['sync-status'],
queryFn: () => getSyncStatus().then((r) => r.data),
staleTime: 5 * 60_000,
});
const syncWarnings = syncQ.data?.warnings ?? 0;
return (
<nav className="flex flex-col gap-0.5 px-3">
{navSections.map((section) => (
<div key={section.label}>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-3 pt-4 pb-1">
{section.label}
</p>
{section.items.map(({ to, icon: Icon, label }) => (
<Link
key={to}
to={to}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
isActive(to)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<Icon className="w-4 h-4" />
{label}
{to === "/sync" && syncWarnings > 0 && (
<span
className="ml-auto w-2 h-2 rounded-full bg-amber-500"
title={`${syncWarnings} fuente(s) atrasada(s)`}
aria-label={`${syncWarnings} fuentes de datos atrasadas`}
/>
)}
</Link>
))}
</div>
))}
</nav>
);
}
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
export default function Layout() {
const { theme, toggleTheme } = useTheme();
const { privacyMode, togglePrivacy } = usePrivacy();
const { logout } = useAuth();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const handleLogout = async () => {
await logout();
navigate("/login", { replace: true });
};
const { pathname } = useLocation();
const title = titleFor(pathname);
return (
<div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-2 border-b border-border bg-background/90 px-4 backdrop-blur-sm">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
{pathname === "/" ? (
<BreadcrumbPage>Inicio</BreadcrumbPage>
) : (
<BreadcrumbLink render={<Link to="/" />}>
Inicio
</BreadcrumbLink>
)}
</BreadcrumbItem>
{pathname !== "/" && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{title}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(true)}
title="Open menu"
aria-label="Open menu"
className="md:hidden"
onClick={togglePrivacy}
title="Toggle privacy mode"
aria-label="Toggle privacy mode"
aria-pressed={privacyMode}
>
<Menu className="w-5 h-5" />
</Button>
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
</div>
<span className="text-lg font-bold tracking-tight hidden sm:inline" style={{ fontFamily: "var(--font-heading)" }}>
Wealthy<span className="text-primary">Smart</span>
</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode" aria-pressed={privacyMode}>
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme" aria-pressed={theme === "dark"}>
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{privacyMode ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleLogout}
title="Sign out"
aria-label="Sign out"
className="hidden md:inline-flex"
onClick={toggleTheme}
title="Toggle theme"
aria-label="Toggle theme"
aria-pressed={theme === "dark"}
>
<LogOut className="w-4 h-4" />
{theme === "dark" ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
</Button>
</div>
</div>
</header>
<div className="flex">
<aside className="hidden md:flex md:flex-col md:w-56 md:flex-shrink-0 border-r border-border sticky top-[57px] h-[calc(100vh-57px)] overflow-y-auto bg-background">
<div className="flex-1">
<SidebarNav />
</div>
<div className="px-3 pb-4">
<Separator className="mb-2" />
<button
onClick={handleLogout}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<LogOut className="w-4 h-4" />
Cerrar sesión
</button>
</div>
</aside>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="p-0 w-64">
<SheetHeader className="p-4">
<SheetTitle className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
</div>
<span style={{ fontFamily: "var(--font-heading)" }}>
Wealthy<span className="text-primary">Smart</span>
</span>
</SheetTitle>
</SheetHeader>
<Separator />
<div className="flex flex-col h-[calc(100%-65px)]">
<div className="flex-1 overflow-y-auto">
<SidebarNav onNavigate={() => setMobileOpen(false)} />
</div>
<div className="px-3 pb-4">
<Separator className="mb-2" />
<SheetClose render={<span />}>
<button
onClick={() => {
setMobileOpen(false);
void handleLogout();
}}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer"
>
<LogOut className="w-4 h-4" />
Cerrar sesión
</button>
</SheetClose>
</div>
</div>
</SheetContent>
</Sheet>
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6">
<div className="max-w-6xl mx-auto">
<main className="flex min-h-0 flex-1 flex-col px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col">
<Outlet />
</div>
</main>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,100 @@
import { Wallet } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getSyncStatus } from "@/lib/api";
import { isNavActive, navSections } from "@/lib/navigation";
import { NavUser } from "@/components/nav-user";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
export function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
const { pathname } = useLocation();
const { setOpenMobile } = useSidebar();
// Surface stalled ingestion sources as a dot on the Sincronización item.
const syncQ = useQuery({
queryKey: ["sync-status"],
queryFn: () => getSyncStatus().then((r) => r.data),
staleTime: 5 * 60_000,
});
const syncWarnings = syncQ.data?.warnings ?? 0;
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
render={<Link to="/" aria-label="Ir a Inicio" />}
>
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<Wallet className="size-4" strokeWidth={2.5} />
</div>
<span
className="text-base font-bold tracking-tight"
style={{ fontFamily: "var(--font-heading)" }}
>
Wealthy<span className="text-primary">Smart</span>
</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{navSections.map((section) => (
<SidebarGroup key={section.label}>
<SidebarGroupLabel>{section.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{section.items.map(({ to, icon: Icon, label }) => (
<SidebarMenuItem key={to}>
<SidebarMenuButton
isActive={isNavActive(to, pathname)}
tooltip={label}
render={
<Link to={to} onClick={() => setOpenMobile(false)} />
}
>
<Icon />
<span>{label}</span>
</SidebarMenuButton>
{to === "/sync" && syncWarnings > 0 && (
<SidebarMenuBadge>
<span
className="size-2 rounded-full bg-amber-500"
title={`${syncWarnings} fuente(s) atrasada(s)`}
aria-label={`${syncWarnings} fuentes de datos atrasadas`}
/>
</SidebarMenuBadge>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}

View File

@@ -0,0 +1,108 @@
import { ChevronsUpDown, Eye, EyeOff, LogOut, Moon, Sun } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getMe } from "@/lib/api";
import { useTheme } from "@/contexts/theme-context";
import { usePrivacy } from "@/contexts/privacy-context";
import { useAuth } from "@/AuthContext";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
export function NavUser() {
const { isMobile } = useSidebar();
const { theme, toggleTheme } = useTheme();
const { privacyMode, togglePrivacy } = usePrivacy();
const { logout } = useAuth();
const navigate = useNavigate();
const meQ = useQuery({
queryKey: ["me"],
queryFn: () => getMe().then((r) => r.data),
staleTime: Infinity,
});
const username = meQ.data?.username ?? "Usuario";
const initials = username.slice(0, 2).toUpperCase();
const handleLogout = async () => {
await logout();
navigate("/login", { replace: true });
};
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<Avatar>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{username}</span>
<span className="truncate text-xs text-muted-foreground">
WealthySmart
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-fit min-w-48"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{username}</span>
<span className="truncate text-xs text-muted-foreground">
WealthySmart
</span>
</div>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={togglePrivacy}>
{privacyMode ? <EyeOff /> : <Eye />}
{privacyMode ? "Desactivar modo privado" : "Modo privado"}
</DropdownMenuItem>
<DropdownMenuItem onClick={toggleTheme}>
{theme === "dark" ? <Sun /> : <Moon />}
{theme === "dark" ? "Tema claro" : "Tema oscuro"}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void handleLogout()}>
<LogOut />
Cerrar sesión
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}