UI Design System: shadcn/ui + Tailwind 4 + layout shell #2
97
src/web/src/components/layout/AppHeader.tsx
Normal file
97
src/web/src/components/layout/AppHeader.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
src/web/src/components/layout/AppSidebar.tsx
Normal file
85
src/web/src/components/layout/AppSidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/web/src/components/layout/ThemeToggle.tsx
Normal file
30
src/web/src/components/layout/ThemeToggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/web/src/hooks/useTheme.ts
Normal file
42
src/web/src/hooks/useTheme.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user