feat(ui): app shell con Sidebar, Header, ThemeToggle y HomePage grid de modulos

This commit is contained in:
2026-04-14 11:21:48 -03:00
parent 8acd2975ba
commit 7eea0fd17c
7 changed files with 372 additions and 6 deletions

View File

@@ -0,0 +1,97 @@
import { Menu, LogOut, User } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ThemeToggle } from '@/components/layout/ThemeToggle'
import { SidebarNav } from '@/components/layout/AppSidebar'
import { useAuthStore } from '@/stores/authStore'
export function AppHeader() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
const initials = user?.nombre
? user.nombre
.split(' ')
.slice(0, 2)
.map((n) => n[0])
.join('')
.toUpperCase()
: 'U'
function handleLogout() {
logout()
void navigate('/login')
}
return (
<header className="flex h-14 items-center gap-4 border-b border-border bg-background px-4">
{/* Mobile sidebar trigger */}
<Sheet>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="lg:hidden"
aria-label="Abrir menú"
>
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-64">
<SheetHeader className="sr-only">
<SheetTitle>Navegación</SheetTitle>
</SheetHeader>
<SidebarNav />
</SheetContent>
</Sheet>
{/* Spacer */}
<div className="flex-1" />
{/* Right actions */}
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="rounded-full"
aria-label="Menú de usuario"
>
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem disabled>
<User className="mr-2 h-4 w-4" />
Mi perfil
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Cerrar sesión
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
)
}

View File

@@ -0,0 +1,85 @@
import { Link, useLocation } from 'react-router-dom'
import {
LayoutDashboard,
ShoppingCart,
Calculator,
Zap,
Settings,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
interface NavItem {
label: string
href: string
icon: React.ElementType
disabled?: boolean
}
const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/', icon: LayoutDashboard },
{ label: 'Ventas', href: '/ventas', icon: ShoppingCart, disabled: true },
{ label: 'Tasación', href: '/tasacion', icon: Calculator, disabled: true },
{
label: 'Integraciones',
href: '/integraciones',
icon: Zap,
disabled: true,
},
{
label: 'Administración',
href: '/administracion',
icon: Settings,
disabled: true,
},
]
export function SidebarNav() {
const { pathname } = useLocation()
return (
<aside className="flex h-full flex-col bg-background border-r border-border">
{/* Brand */}
<div className="flex h-14 items-center px-4 border-b border-border">
<span className="text-base font-semibold tracking-tight text-foreground">
SIG-CM 2.0
</span>
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-4 px-2 space-y-1">
{navItems.map((item) => {
const Icon = item.icon
const isActive = pathname === item.href && !item.disabled
return item.disabled ? (
<div
key={item.href}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground cursor-not-allowed opacity-60"
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{item.label}</span>
<Badge variant="secondary" className="text-xs">
Próximamente
</Badge>
</div>
) : (
<Link
key={item.href}
to={item.href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
isActive
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground',
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</Link>
)
})}
</nav>
</aside>
)
}

View File

@@ -0,0 +1,30 @@
import { Moon, Sun } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTheme } from '@/hooks/useTheme'
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme()
function toggle() {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
}
return (
<Button
variant="ghost"
size="icon"
onClick={toggle}
aria-label={
resolvedTheme === 'dark'
? 'Cambiar a modo claro'
: 'Cambiar a modo oscuro'
}
>
{resolvedTheme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
)
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react'
export type Theme = 'light' | 'dark' | 'system'
const STORAGE_KEY = 'theme'
function getSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
function applyTheme(theme: Theme) {
const resolved = theme === 'system' ? getSystemTheme() : theme
const root = document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(resolved)
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
if (stored === 'light' || stored === 'dark' || stored === 'system') {
return stored
}
return 'system'
})
const resolvedTheme: 'light' | 'dark' =
theme === 'system' ? getSystemTheme() : theme
useEffect(() => {
applyTheme(theme)
}, [theme])
function setTheme(next: Theme) {
localStorage.setItem(STORAGE_KEY, next)
setThemeState(next)
}
return { theme, setTheme, resolvedTheme }
}

View File

@@ -1,4 +1,6 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { AppHeader } from '@/components/layout/AppHeader'
import { SidebarNav } from '@/components/layout/AppSidebar'
interface ProtectedLayoutProps { interface ProtectedLayoutProps {
children: ReactNode children: ReactNode
@@ -6,8 +8,17 @@ interface ProtectedLayoutProps {
export function ProtectedLayout({ children }: ProtectedLayoutProps) { export function ProtectedLayout({ children }: ProtectedLayoutProps) {
return ( return (
<div className="min-h-screen bg-white"> <div className="flex h-svh overflow-hidden bg-background text-foreground">
{children} {/* Desktop sidebar */}
<div className="hidden lg:flex lg:w-60 lg:flex-col lg:shrink-0">
<SidebarNav />
</div>
{/* Main column */}
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
<AppHeader />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div> </div>
) )
} }

View File

@@ -6,7 +6,7 @@ interface PublicLayoutProps {
export function PublicLayout({ children }: PublicLayoutProps) { export function PublicLayout({ children }: PublicLayoutProps) {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-svh bg-muted/40 flex items-center justify-center p-4">
{children} {children}
</div> </div>
) )

View File

@@ -1,8 +1,109 @@
import {
TrendingUp,
Calculator,
Zap,
Settings,
LayoutDashboard,
} from 'lucide-react'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { useAuthStore } from '@/stores/authStore'
interface ModuleCard {
title: string
description: string
icon: React.ElementType
available: boolean
}
const modules: ModuleCard[] = [
{
title: 'Dashboard',
description: 'Vista general del sistema y métricas principales.',
icon: LayoutDashboard,
available: true,
},
{
title: 'Ventas',
description: 'Gestión de ventas, presupuestos y seguimiento de clientes.',
icon: TrendingUp,
available: false,
},
{
title: 'Tasación',
description: 'Herramientas de valuación y tasación de propiedades.',
icon: Calculator,
available: false,
},
{
title: 'Integraciones',
description: 'Conectores con portales inmobiliarios y servicios externos.',
icon: Zap,
available: false,
},
{
title: 'Administración',
description: 'Gestión de usuarios, roles y configuración del sistema.',
icon: Settings,
available: false,
},
]
export function HomePage() { export function HomePage() {
const user = useAuthStore((s) => s.user)
return ( return (
<div className="p-8"> <div className="space-y-6">
<h1 className="text-2xl font-semibold text-gray-900">Dashboard</h1> {/* Welcome */}
<p className="mt-2 text-gray-600">Bienvenido al SIG-CM2.</p> <div>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
Panel principal
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{user?.nombre
? `Bienvenido, ${user.nombre}. Seleccioná un módulo para comenzar.`
: 'Bienvenido al SIG-CM 2.0. Seleccioná un módulo para comenzar.'}
</p>
</div>
{/* Module grid */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{modules.map((mod) => {
const Icon = mod.icon
return (
<Card
key={mod.title}
className={
mod.available ? 'hover:shadow-md transition-shadow' : 'opacity-75'
}
>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{mod.title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<CardDescription>{mod.description}</CardDescription>
</CardContent>
<CardFooter>
{mod.available ? (
<Badge>Disponible</Badge>
) : (
<Badge variant="secondary">Próximamente</Badge>
)}
</CardFooter>
</Card>
)
})}
</div>
</div> </div>
) )
} }