chore(frontend): reorganizar sidebar en secciones + quitar items disabled #61

Merged
dmolinari merged 1 commits from chore/sidebar-categorization into main 2026-04-21 16:38:17 +00:00
2 changed files with 276 additions and 118 deletions

View File

@@ -1,12 +1,8 @@
import { Link, useLocation } from 'react-router-dom'
import {
LayoutDashboard,
ShoppingCart,
Calculator,
Zap,
Settings,
UserPlus,
Users,
UserPlus,
ShieldCheck,
KeyRound,
FileClock,
@@ -21,7 +17,6 @@ import {
Hash,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useAuthStore } from '@/stores/authStore'
import { useSidebar } from '@/hooks/useSidebar'
@@ -30,20 +25,32 @@ interface NavItem {
label: string
href: string
icon: React.ElementType
disabled?: boolean
/** Si se define, el item solo se muestra si el user tiene este permiso. */
requiredPermission?: string
}
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 },
]
interface NavSection {
label: string
/** Si true, la sección solo se muestra si el user tiene rol admin. */
adminOnly?: boolean
items: NavItem[]
}
const adminItems: NavItem[] = [
// Item principal — siempre visible para usuarios autenticados
const dashboardItem: NavItem = {
label: 'Dashboard',
href: '/',
icon: LayoutDashboard,
}
// Secciones de navegación agrupadas por dominio.
// Orden: Seguridad → Maestros → Catálogo → Tasación (coincide con la arquitectura del proyecto).
// Cada sección se oculta si todos sus items están filtrados por permisos.
const navSections: NavSection[] = [
{
label: 'Seguridad',
adminOnly: true,
items: [
{ label: 'Usuarios', href: '/usuarios', icon: Users },
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
@@ -54,6 +61,12 @@ const adminItems: NavItem[] = [
icon: FileClock,
requiredPermission: 'administracion:auditoria:ver',
},
],
},
{
label: 'Maestros',
adminOnly: true,
items: [
{
label: 'Medios',
href: '/admin/medios',
@@ -72,6 +85,12 @@ const adminItems: NavItem[] = [
icon: Store,
requiredPermission: 'administracion:puntos_de_venta:gestionar',
},
],
},
{
label: 'Catálogo',
adminOnly: true,
items: [
{
label: 'Rubros',
href: '/admin/rubros',
@@ -90,12 +109,20 @@ const adminItems: NavItem[] = [
icon: Package,
requiredPermission: 'catalogo:productos:gestionar',
},
],
},
{
label: 'Tasación',
adminOnly: true,
items: [
{
label: 'Caracteres Tasables',
href: '/admin/tasacion/chargeable-chars',
icon: Hash,
requiredPermission: 'tasacion:caracteres_especiales:gestionar',
},
],
},
]
interface SidebarNavProps {
@@ -111,7 +138,6 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
const collapsed = forceExpanded ? false : persisted
function isItemActive(item: NavItem): boolean {
if (item.disabled) return false
if (item.href === '/') return pathname === '/'
if (item.href === '/usuarios') {
return pathname.startsWith('/usuarios') && pathname !== '/usuarios/nuevo'
@@ -120,6 +146,20 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
return pathname.startsWith(item.href)
}
function hasAccess(item: NavItem): boolean {
return !item.requiredPermission || (user?.permisos.includes(item.requiredPermission) ?? false)
}
// Computa las secciones visibles (con items filtrados por permiso).
// Si una sección queda sin items tras el filtro, se oculta el header.
const visibleSections = navSections
.filter((section) => !section.adminOnly || isAdmin)
.map((section) => ({
...section,
items: section.items.filter(hasAccess),
}))
.filter((section) => section.items.length > 0)
return (
<aside
className={cn(
@@ -128,7 +168,7 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
)}
data-collapsed={collapsed}
>
{/* Brand + Toggle (top header) */}
{/* Brand + Toggle */}
<div
className={cn(
'flex h-14 items-center border-b border-border shrink-0',
@@ -166,25 +206,17 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
{/* Nav */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-3 px-2 space-y-1">
{navItems.map((item) => (
<NavRow
key={item.href}
item={item}
item={dashboardItem}
collapsed={collapsed}
active={isItemActive(item)}
active={isItemActive(dashboardItem)}
/>
))}
{isAdmin && (
<>
<SectionLabel collapsed={collapsed}>Administración</SectionLabel>
{adminItems
.filter(
(item) =>
!item.requiredPermission ||
user?.permisos.includes(item.requiredPermission),
)
.map((item) => (
{visibleSections.map((section) => (
<div key={section.label} className="pt-2">
<SectionLabel collapsed={collapsed}>{section.label}</SectionLabel>
<div className="space-y-1">
{section.items.map((item) => (
<NavRow
key={item.href}
item={item}
@@ -192,8 +224,9 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
active={isItemActive(item)}
/>
))}
</>
)}
</div>
</div>
))}
</nav>
</aside>
)
@@ -217,41 +250,6 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
collapsed ? 'justify-center h-10 w-10 mx-auto' : 'gap-3 px-3 py-2',
)
// Disabled item
if (item.disabled) {
const content = (
<div
className={cn(
baseClasses,
'text-muted-foreground/70 cursor-not-allowed opacity-60',
)}
aria-disabled="true"
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && (
<>
<span className="flex-1 truncate">{item.label}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 shrink-0">
Próx.
</Badge>
</>
)}
</div>
)
if (!collapsed) return content
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right">
{item.label}{' '}
<span className="text-muted-foreground">· Próximamente</span>
</TooltipContent>
</Tooltip>
)
}
// Active link
const link = (
<Link
to={item.href}
@@ -263,7 +261,6 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
)}
aria-current={active ? 'page' : undefined}
>
{/* Active indicator bar (left edge) when expanded */}
{active && !collapsed && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-0.5 rounded-r-full bg-primary" />
)}
@@ -292,7 +289,7 @@ function SectionLabel({
return <div className="my-2 mx-2 border-t border-border" aria-hidden="true" />
}
return (
<div className="pt-3 pb-1 px-3">
<div className="pt-1 pb-1 px-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
{children}
</span>

View File

@@ -0,0 +1,161 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { SidebarNav } from '../AppSidebar'
import { useAuthStore } from '@/stores/authStore'
/**
* Estado inicial del authStore para cada test.
* El rol admin + set de permisos completos activa todas las secciones.
*/
function setUser(overrides: Partial<{ rol: string; permisos: string[] }> = {}) {
useAuthStore.setState({
user: {
id: 1,
username: 'admin',
nombre: 'Admin Test',
rol: 'admin',
permisos: [
'administracion:auditoria:ver',
'administracion:medios:gestionar',
'administracion:secciones:gestionar',
'administracion:puntos_de_venta:gestionar',
'catalogo:rubros:gestionar',
'catalogo:tipos:gestionar',
'catalogo:productos:gestionar',
'tasacion:caracteres_especiales:gestionar',
],
mustChangePassword: false,
...overrides,
} as never,
})
}
function renderSidebar(initialPath = '/') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<SidebarNav forceExpanded />
</MemoryRouter>,
)
}
describe('AppSidebar', () => {
beforeEach(() => {
setUser()
})
it('Dashboard visible para todo usuario autenticado', () => {
renderSidebar()
expect(screen.getByRole('link', { name: /dashboard/i })).toBeInTheDocument()
})
it('No muestra items disabled con badge "Próx." (limpieza)', () => {
renderSidebar()
// Los 4 items removidos del sidebar: Ventas, Tasación (nivel top), Integraciones, Administración (nivel top)
expect(screen.queryByText(/próx/i)).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /^ventas$/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /^integraciones$/i })).not.toBeInTheDocument()
// "Administración" como link top-level también se elimina (queda solo como label de sección)
expect(screen.queryByRole('link', { name: /^administración$/i })).not.toBeInTheDocument()
})
it('Muestra las 4 secciones agrupadas para admin con todos los permisos', () => {
renderSidebar()
expect(screen.getByText('Seguridad')).toBeInTheDocument()
expect(screen.getByText('Maestros')).toBeInTheDocument()
expect(screen.getByText('Catálogo')).toBeInTheDocument()
expect(screen.getByText('Tasación')).toBeInTheDocument()
})
it('Cada item vive en la sección correcta', () => {
renderSidebar()
// Seguridad
expect(screen.getByRole('link', { name: /usuarios/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /crear usuario/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /roles/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /permisos/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /auditoría/i })).toBeInTheDocument()
// Maestros
expect(screen.getByRole('link', { name: /^medios$/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /^secciones$/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /puntos de venta/i })).toBeInTheDocument()
// Catálogo
expect(screen.getByRole('link', { name: /rubros/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /tipos de producto/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /^productos$/i })).toBeInTheDocument()
// Tasación
expect(screen.getByRole('link', { name: /caracteres tasables/i })).toBeInTheDocument()
})
it('Oculta secciones sin items permitidos (ej: user sin permisos de catálogo)', () => {
setUser({
rol: 'admin',
permisos: [
// Solo permisos de Seguridad + Maestros
'administracion:medios:gestionar',
],
})
renderSidebar()
// Seguridad se muestra (Usuarios/Crear Usuario/Roles/Permisos no requieren permiso custom)
expect(screen.getByText('Seguridad')).toBeInTheDocument()
// Maestros se muestra (tiene Medios con su permiso)
expect(screen.getByText('Maestros')).toBeInTheDocument()
// Catálogo desaparece (ningún permiso catalogo:*)
expect(screen.queryByText('Catálogo')).not.toBeInTheDocument()
// Tasación desaparece
expect(screen.queryByText('Tasación')).not.toBeInTheDocument()
// Items filtrados
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /caracteres tasables/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /^secciones$/i })).not.toBeInTheDocument()
})
it('Usuario no-admin no ve ninguna sección adminOnly', () => {
setUser({ rol: 'cajero', permisos: [] })
renderSidebar()
expect(screen.getByRole('link', { name: /dashboard/i })).toBeInTheDocument()
expect(screen.queryByText('Seguridad')).not.toBeInTheDocument()
expect(screen.queryByText('Maestros')).not.toBeInTheDocument()
expect(screen.queryByText('Catálogo')).not.toBeInTheDocument()
expect(screen.queryByText('Tasación')).not.toBeInTheDocument()
})
it('Marca el item activo según la ruta actual (Caracteres Tasables)', () => {
renderSidebar('/admin/tasacion/chargeable-chars')
const link = screen.getByRole('link', { name: /caracteres tasables/i })
expect(link).toHaveAttribute('aria-current', 'page')
})
it('Dashboard activo solo en raíz exacta', () => {
const { unmount } = renderSidebar('/')
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('aria-current', 'page')
unmount()
renderSidebar('/admin/medios')
expect(screen.getByRole('link', { name: /dashboard/i })).not.toHaveAttribute('aria-current', 'page')
})
it('Header "SIG-CM 2.0" visible en modo expandido', () => {
renderSidebar()
expect(screen.getByText('SIG-CM 2.0')).toBeInTheDocument()
})
it('Orden de secciones: Seguridad → Maestros → Catálogo → Tasación', () => {
renderSidebar()
const nav = screen.getByRole('navigation')
const labels = within(nav).getAllByText(/Seguridad|Maestros|Catálogo|Tasación/)
expect(labels.map((n) => n.textContent)).toEqual([
'Seguridad',
'Maestros',
'Catálogo',
'Tasación',
])
})
})