chore(frontend): reorganizar sidebar en secciones + quitar items disabled #61
@@ -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,71 +25,103 @@ 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[] = [
|
||||
{ label: 'Usuarios', href: '/usuarios', icon: Users },
|
||||
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
|
||||
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
|
||||
{ label: 'Permisos', href: '/admin/permisos', icon: KeyRound },
|
||||
// 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: 'Auditoría',
|
||||
href: '/admin/audit',
|
||||
icon: FileClock,
|
||||
requiredPermission: 'administracion:auditoria:ver',
|
||||
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 },
|
||||
{ label: 'Permisos', href: '/admin/permisos', icon: KeyRound },
|
||||
{
|
||||
label: 'Auditoría',
|
||||
href: '/admin/audit',
|
||||
icon: FileClock,
|
||||
requiredPermission: 'administracion:auditoria:ver',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Medios',
|
||||
href: '/admin/medios',
|
||||
icon: Newspaper,
|
||||
requiredPermission: 'administracion:medios:gestionar',
|
||||
label: 'Maestros',
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Medios',
|
||||
href: '/admin/medios',
|
||||
icon: Newspaper,
|
||||
requiredPermission: 'administracion:medios:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Secciones',
|
||||
href: '/admin/secciones',
|
||||
icon: Columns3,
|
||||
requiredPermission: 'administracion:secciones:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Puntos de Venta',
|
||||
href: '/admin/puntos-de-venta',
|
||||
icon: Store,
|
||||
requiredPermission: 'administracion:puntos_de_venta:gestionar',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Secciones',
|
||||
href: '/admin/secciones',
|
||||
icon: Columns3,
|
||||
requiredPermission: 'administracion:secciones:gestionar',
|
||||
label: 'Catálogo',
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Rubros',
|
||||
href: '/admin/rubros',
|
||||
icon: Tag,
|
||||
requiredPermission: 'catalogo:rubros:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Tipos de Producto',
|
||||
href: '/admin/product-types',
|
||||
icon: Layers,
|
||||
requiredPermission: 'catalogo:tipos:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Productos',
|
||||
href: '/admin/products',
|
||||
icon: Package,
|
||||
requiredPermission: 'catalogo:productos:gestionar',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Puntos de Venta',
|
||||
href: '/admin/puntos-de-venta',
|
||||
icon: Store,
|
||||
requiredPermission: 'administracion:puntos_de_venta:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Rubros',
|
||||
href: '/admin/rubros',
|
||||
icon: Tag,
|
||||
requiredPermission: 'catalogo:rubros:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Tipos de Producto',
|
||||
href: '/admin/product-types',
|
||||
icon: Layers,
|
||||
requiredPermission: 'catalogo:tipos:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Productos',
|
||||
href: '/admin/products',
|
||||
icon: Package,
|
||||
requiredPermission: 'catalogo:productos:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Caracteres Tasables',
|
||||
href: '/admin/tasacion/chargeable-chars',
|
||||
icon: Hash,
|
||||
requiredPermission: 'tasacion:caracteres_especiales:gestionar',
|
||||
label: 'Tasación',
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Caracteres Tasables',
|
||||
href: '/admin/tasacion/chargeable-chars',
|
||||
icon: Hash,
|
||||
requiredPermission: 'tasacion:caracteres_especiales:gestionar',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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}
|
||||
collapsed={collapsed}
|
||||
active={isItemActive(item)}
|
||||
/>
|
||||
))}
|
||||
<NavRow
|
||||
item={dashboardItem}
|
||||
collapsed={collapsed}
|
||||
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>
|
||||
|
||||
161
src/web/src/components/layout/__tests__/AppSidebar.test.tsx
Normal file
161
src/web/src/components/layout/__tests__/AppSidebar.test.tsx
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user