mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 09:08:46 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
100
frontend/src/components/app-sidebar.tsx
Normal file
100
frontend/src/components/app-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
frontend/src/components/nav-user.tsx
Normal file
108
frontend/src/components/nav-user.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user