feat(web): tabs.tsx + PermisosEditor tri-state [UDT-009]

This commit is contained in:
2026-04-15 21:49:48 -03:00
parent c1426b2257
commit 9dbf3e895d
5 changed files with 535 additions and 0 deletions

View File

@@ -18,6 +18,7 @@
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.99.0", "@tanstack/react-query": "^5.99.0",
"axios": "1.7", "axios": "1.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -2636,6 +2637,92 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",

View File

@@ -23,6 +23,7 @@
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.99.0", "@tanstack/react-query": "^5.99.0",
"axios": "1.7", "axios": "1.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@@ -0,0 +1,53 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-start rounded-lg bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
className,
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,197 @@
import { useState, useEffect } from 'react'
import { isAxiosError } from 'axios'
import { AlertCircle } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { useUserPermisos } from '../hooks/useUserPermisos'
import { useUpdateUserPermisosOverrides } from '../hooks/useUpdateUserPermisosOverrides'
import { usePermisos } from '@/features/permisos/hooks/usePermisos'
import type { PermisoOverrideState } from '../types'
import type { PermisoDto } from '@/features/permisos/api/types'
interface PermisosEditorProps {
userId: number
}
function groupByModulo(permisos: PermisoDto[]): Map<string, PermisoDto[]> {
const map = new Map<string, PermisoDto[]>()
for (const p of permisos) {
const modulo = p.codigo.split(':')[0] ?? p.modulo
if (!map.has(modulo)) map.set(modulo, [])
map.get(modulo)!.push(p)
}
return map
}
function resolveErrorMessage(err: unknown): string {
if (isAxiosError(err) && err.response?.data) {
const data = err.response.data as { title?: string; invalidCodes?: string[]; overlap?: string[] }
if (data.title === 'invalid-permiso-codes') {
const codes = data.invalidCodes?.join(', ') ?? ''
return `Códigos de permiso inválidos: ${codes}`
}
if (data.title === 'grant-deny-overlap') {
const codes = data.overlap?.join(', ') ?? ''
return `Los siguientes permisos están en grant y deny al mismo tiempo: ${codes}`
}
return 'No se pudieron guardar los cambios.'
}
return 'No se pudieron guardar los cambios.'
}
export function PermisosEditor({ userId }: PermisosEditorProps) {
const { data: permisoData, isLoading: loadingPermisos } = useUserPermisos(userId)
const { data: catalogo, isLoading: loadingCatalogo } = usePermisos()
const mutation = useUpdateUserPermisosOverrides(userId)
// Map<codigopermiso, PermisoOverrideState>
const [states, setStates] = useState<Map<string, PermisoOverrideState>>(new Map())
const [saveError, setSaveError] = useState<string | null>(null)
// Initialize state from loaded data
useEffect(() => {
if (!permisoData) return
const map = new Map<string, PermisoOverrideState>()
// Start all known codes as 'heredado'
for (const c of permisoData.rolPermisos) map.set(c, 'heredado')
// Apply grant overrides
for (const c of permisoData.grant) map.set(c, 'concedido')
// Apply deny overrides
for (const c of permisoData.deny) map.set(c, 'denegado')
setStates(map)
setSaveError(null)
}, [permisoData])
if (loadingPermisos || loadingCatalogo) {
return <p className="text-sm text-muted-foreground">Cargando permisos...</p>
}
if (!permisoData || !catalogo) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>No se pudieron cargar los permisos.</AlertDescription>
</Alert>
)
}
// Build complete set of all relevant permission codes from catalog
// Filter catalog to only show permisos that appear in rolPermisos, grant, deny, or all catalog
const allCodes = new Set([
...permisoData.rolPermisos,
...permisoData.grant,
...permisoData.deny,
])
// Use catalog for grouping and names, showing all permisos known plus any from overrides
const relevantPermisos = catalogo.filter(
(p) => allCodes.has(p.codigo) || catalogo.length <= 30,
)
const grupos = groupByModulo(relevantPermisos)
function getState(codigo: string): PermisoOverrideState {
return states.get(codigo) ?? 'heredado'
}
function setState(codigo: string, state: PermisoOverrideState) {
setSaveError(null)
setStates((prev) => {
const next = new Map(prev)
next.set(codigo, state)
return next
})
}
function handleSave() {
const grant: string[] = []
const deny: string[] = []
for (const [codigo, state] of states.entries()) {
if (state === 'concedido') grant.push(codigo)
else if (state === 'denegado') deny.push(codigo)
}
mutation.mutate(
{ grant, deny },
{
onError: (err) => {
setSaveError(resolveErrorMessage(err))
},
onSuccess: () => {
setSaveError(null)
},
},
)
}
return (
<div className="space-y-6">
{saveError && (
<Alert variant="destructive" role="alert">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{saveError}</AlertDescription>
</Alert>
)}
{Array.from(grupos.entries()).map(([modulo, permisos]) => (
<section key={modulo}>
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-3 capitalize">
{modulo}
</h3>
<div className="space-y-2">
{permisos.map((p) => {
const currentState = getState(p.codigo)
return (
<div
key={p.codigo}
data-testid={`permiso-row-${p.codigo}`}
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
>
<div className="flex flex-col min-w-0 mr-4">
<span className="text-sm font-medium">{p.nombre}</span>
<span className="font-mono text-xs text-muted-foreground/70">{p.codigo}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{(['heredado', 'concedido', 'denegado'] as PermisoOverrideState[]).map(
(state) => (
<button
key={state}
type="button"
role="button"
aria-label={state.charAt(0).toUpperCase() + state.slice(1)}
aria-pressed={currentState === state}
onClick={() => setState(p.codigo, state)}
className={`
px-2 py-1 rounded text-xs font-medium transition-colors capitalize
${
currentState === state
? state === 'heredado'
? 'bg-secondary text-secondary-foreground'
: state === 'concedido'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100'
: 'bg-transparent text-muted-foreground hover:bg-accent'
}
`}
>
{state}
</button>
),
)}
</div>
</div>
)
})}
</div>
</section>
))}
<div className="flex justify-end pt-2">
<Button onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending ? 'Guardando...' : 'Guardar cambios'}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,197 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { PermisosEditor } from '../../../features/users/components/PermisosEditor'
const API_URL = 'http://localhost:5000'
// Catalog of ALL known permissions (from /api/v1/permisos)
const catalogoPermisos = [
{ id: 1, codigo: 'ventas:contado:crear', nombre: 'Crear venta contado', descripcion: null, modulo: 'ventas' },
{ id: 2, codigo: 'ventas:contado:cobrar', nombre: 'Cobrar venta contado', descripcion: null, modulo: 'ventas' },
{ id: 3, codigo: 'textos:editar', nombre: 'Editar textos', descripcion: null, modulo: 'textos' },
]
// User permisos — from /api/v1/users/42/permisos
const mockUsuarioPermisos = {
usuarioId: 42,
rol: 'cajero',
rolPermisos: ['ventas:contado:crear', 'ventas:contado:cobrar'],
grant: ['textos:editar'],
deny: ['ventas:contado:cobrar'],
effective: ['ventas:contado:crear', 'textos:editar'],
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
function renderEditor(userId = 42) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<PermisosEditor userId={userId} />
</MemoryRouter>
</QueryClientProvider>,
)
}
function setupHandlers() {
server.use(
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
)
}
describe('PermisosEditor', () => {
it('calls GET /api/v1/users/:id/permisos on mount', async () => {
let getPermisosCallCount = 0
server.use(
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
http.get(`${API_URL}/api/v1/users/42/permisos`, () => {
getPermisosCallCount++
return HttpResponse.json(mockUsuarioPermisos)
}),
)
renderEditor(42)
await waitFor(() => expect(getPermisosCallCount).toBe(1))
})
it('renders permissions grouped by module', async () => {
setupHandlers()
renderEditor()
await waitFor(() => expect(screen.getByText('ventas')).toBeInTheDocument())
expect(screen.getByText('textos')).toBeInTheDocument()
expect(screen.getByText('Crear venta contado')).toBeInTheDocument()
expect(screen.getByText('Cobrar venta contado')).toBeInTheDocument()
expect(screen.getByText('Editar textos')).toBeInTheDocument()
})
it('shows Heredado state for permissions in role but not in grant or deny', async () => {
setupHandlers()
renderEditor()
// ventas:contado:crear is in rolPermisos, not in grant, not in deny
// expect a button/element indicating "Heredado" is active for that permission
await waitFor(() => expect(screen.getByText('Crear venta contado')).toBeInTheDocument())
// Should have a "Heredado" indicator active for ventas:contado:crear
// We look for the specific row container and check the selected state
const crearRow = screen.getByTestId('permiso-row-ventas:contado:crear')
expect(within(crearRow).getByRole('button', { name: /heredado/i })).toHaveAttribute(
'aria-pressed',
'true',
)
})
it('shows Concedido state for permissions in grant', async () => {
setupHandlers()
renderEditor()
await waitFor(() => expect(screen.getByText('Editar textos')).toBeInTheDocument())
const editarRow = screen.getByTestId('permiso-row-textos:editar')
expect(within(editarRow).getByRole('button', { name: /concedido/i })).toHaveAttribute(
'aria-pressed',
'true',
)
})
it('shows Denegado state for permissions in deny', async () => {
setupHandlers()
renderEditor()
await waitFor(() => expect(screen.getByText('Cobrar venta contado')).toBeInTheDocument())
const cobrarRow = screen.getByTestId('permiso-row-ventas:contado:cobrar')
expect(within(cobrarRow).getByRole('button', { name: /denegado/i })).toHaveAttribute(
'aria-pressed',
'true',
)
})
it('Guardar button calls PUT with correct { grant, deny } body', async () => {
let capturedBody: unknown = null
server.use(
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, async ({ request }) => {
capturedBody = await request.json()
return HttpResponse.json(mockUsuarioPermisos)
}),
)
const u = userEvent.setup()
renderEditor()
await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
await u.click(screen.getByRole('button', { name: /guardar cambios/i }))
await waitFor(() => expect(capturedBody).not.toBeNull())
// Initial state: grant=['textos:editar'], deny=['ventas:contado:cobrar']
expect(capturedBody).toMatchObject({
grant: expect.arrayContaining(['textos:editar']),
deny: expect.arrayContaining(['ventas:contado:cobrar']),
})
})
it('shows alert on 400 invalid-permiso-codes error', async () => {
server.use(
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () =>
HttpResponse.json(
{ title: 'invalid-permiso-codes', status: 400, invalidCodes: ['fake:codigo'] },
{ status: 400 },
),
),
)
const u = userEvent.setup()
renderEditor()
await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
await u.click(screen.getByRole('button', { name: /guardar cambios/i }))
await waitFor(() =>
expect(screen.getByRole('alert')).toBeInTheDocument(),
)
})
it('shows alert on 400 grant-deny-overlap error', async () => {
server.use(
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () =>
HttpResponse.json(
{ title: 'grant-deny-overlap', status: 400, overlap: ['textos:editar'] },
{ status: 400 },
),
),
)
const u = userEvent.setup()
renderEditor()
await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
await u.click(screen.getByRole('button', { name: /guardar cambios/i }))
await waitFor(() =>
expect(screen.getByRole('alert')).toBeInTheDocument(),
)
})
})