feat(web): UDT-004 gestion de roles + UserForm dinamico
- features/roles: API clients (list/get/create/update/deactivate), TanStack Query hooks, RolForm (create + edit variants), RolesList con acciones y guard 409, paginas RolesPage/NewRolPage/EditRolPage - router.tsx: rutas /admin/roles, /admin/roles/nuevo, /admin/roles/:codigo/editar - AppSidebar: nav Roles (admin-only) - features/users: useRolesForSelect wrapper (filtra activo=true), UserForm fetchea roles async con loading/error states; elimina ROL_OPTIONS hardcoded - tests: 47 vitest verdes (10 authStore + 5 auth api + 7 axios + 3 useCreateUser + 3 RolesList + 5 LoginPage + 7 UserForm + 7 RolForm). Typecheck limpio
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
Zap,
|
||||
Settings,
|
||||
UserPlus,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -104,6 +105,18 @@ export function SidebarNav() {
|
||||
<UserPlus className="h-4 w-4 shrink-0" />
|
||||
<span>Crear Usuario</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/roles"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
pathname.startsWith('/admin/roles')
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 shrink-0" />
|
||||
<span>Roles</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
7
src/web/src/features/roles/api/createRole.ts
Normal file
7
src/web/src/features/roles/api/createRole.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '../../../api/axiosClient'
|
||||
import type { CreateRolRequest, RolCreatedDto } from './types'
|
||||
|
||||
export async function createRole(payload: CreateRolRequest): Promise<RolCreatedDto> {
|
||||
const response = await axiosClient.post<RolCreatedDto>('/api/v1/roles', payload)
|
||||
return response.data
|
||||
}
|
||||
5
src/web/src/features/roles/api/deactivateRole.ts
Normal file
5
src/web/src/features/roles/api/deactivateRole.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { axiosClient } from '../../../api/axiosClient'
|
||||
|
||||
export async function deactivateRole(codigo: string): Promise<void> {
|
||||
await axiosClient.delete(`/api/v1/roles/${encodeURIComponent(codigo)}`)
|
||||
}
|
||||
7
src/web/src/features/roles/api/getRol.ts
Normal file
7
src/web/src/features/roles/api/getRol.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '../../../api/axiosClient'
|
||||
import type { RolDto } from './types'
|
||||
|
||||
export async function getRol(codigo: string): Promise<RolDto> {
|
||||
const response = await axiosClient.get<RolDto>(`/api/v1/roles/${encodeURIComponent(codigo)}`)
|
||||
return response.data
|
||||
}
|
||||
7
src/web/src/features/roles/api/listRoles.ts
Normal file
7
src/web/src/features/roles/api/listRoles.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '../../../api/axiosClient'
|
||||
import type { RolDto } from './types'
|
||||
|
||||
export async function listRoles(): Promise<RolDto[]> {
|
||||
const response = await axiosClient.get<RolDto[]>('/api/v1/roles')
|
||||
return response.data
|
||||
}
|
||||
29
src/web/src/features/roles/api/types.ts
Normal file
29
src/web/src/features/roles/api/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface RolDto {
|
||||
id: number
|
||||
codigo: string
|
||||
nombre: string
|
||||
descripcion: string | null
|
||||
activo: boolean
|
||||
fechaCreacion: string
|
||||
fechaModificacion: string | null
|
||||
}
|
||||
|
||||
export interface RolCreatedDto {
|
||||
id: number
|
||||
codigo: string
|
||||
nombre: string
|
||||
descripcion: string | null
|
||||
activo: boolean
|
||||
}
|
||||
|
||||
export interface CreateRolRequest {
|
||||
codigo: string
|
||||
nombre: string
|
||||
descripcion?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateRolRequest {
|
||||
nombre: string
|
||||
descripcion?: string | null
|
||||
activo: boolean
|
||||
}
|
||||
10
src/web/src/features/roles/api/updateRole.ts
Normal file
10
src/web/src/features/roles/api/updateRole.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { axiosClient } from '../../../api/axiosClient'
|
||||
import type { RolDto, UpdateRolRequest } from './types'
|
||||
|
||||
export async function updateRole(codigo: string, payload: UpdateRolRequest): Promise<RolDto> {
|
||||
const response = await axiosClient.put<RolDto>(
|
||||
`/api/v1/roles/${encodeURIComponent(codigo)}`,
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
245
src/web/src/features/roles/components/RolForm.tsx
Normal file
245
src/web/src/features/roles/components/RolForm.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { useCreateRole } from '../hooks/useCreateRole'
|
||||
import { useUpdateRole } from '../hooks/useUpdateRole'
|
||||
import type { RolDto } from '../api/types'
|
||||
|
||||
const CODIGO_REGEX = /^[a-z][a-z0-9_]*$/
|
||||
|
||||
const createSchema = z.object({
|
||||
codigo: z
|
||||
.string()
|
||||
.min(3, 'Mínimo 3 caracteres')
|
||||
.max(30, 'Máximo 30 caracteres')
|
||||
.regex(CODIGO_REGEX, 'Solo minúsculas, dígitos y guion bajo; debe empezar con letra'),
|
||||
nombre: z.string().min(1, 'El nombre es requerido').max(60, 'Máximo 60 caracteres'),
|
||||
descripcion: z.string().max(250, 'Máximo 250 caracteres').optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
const updateSchema = z.object({
|
||||
nombre: z.string().min(1, 'El nombre es requerido').max(60, 'Máximo 60 caracteres'),
|
||||
descripcion: z.string().max(250, 'Máximo 250 caracteres').optional().or(z.literal('')),
|
||||
activo: z.boolean(),
|
||||
})
|
||||
|
||||
type CreateFormValues = z.infer<typeof createSchema>
|
||||
type UpdateFormValues = z.infer<typeof updateSchema>
|
||||
|
||||
function resolveBackendError(err: unknown): string | null {
|
||||
if (!err) return null
|
||||
if (isAxiosError(err) && err.response?.data) {
|
||||
const data = err.response.data as { error?: string; message?: string }
|
||||
if (data.error === 'rol_already_exists') return data.message ?? 'El rol ya existe'
|
||||
if (data.error === 'rol_not_found') return data.message ?? 'Rol no encontrado'
|
||||
return data.message ?? data.error ?? 'Error al guardar el rol'
|
||||
}
|
||||
return 'Error al guardar el rol'
|
||||
}
|
||||
|
||||
// ── Create Form ────────────────────────────────────────────────────────────
|
||||
|
||||
export function CreateRolForm({ onSuccess }: { onSuccess?: () => void }) {
|
||||
const mutation = useCreateRole()
|
||||
const form = useForm<CreateFormValues>({
|
||||
resolver: zodResolver(createSchema),
|
||||
defaultValues: { codigo: '', nombre: '', descripcion: '' },
|
||||
})
|
||||
|
||||
function onSubmit(values: CreateFormValues) {
|
||||
mutation.mutate(
|
||||
{
|
||||
codigo: values.codigo,
|
||||
nombre: values.nombre,
|
||||
descripcion: values.descripcion ? values.descripcion : null,
|
||||
},
|
||||
{ onSuccess: () => onSuccess?.() },
|
||||
)
|
||||
}
|
||||
|
||||
const backendErr = resolveBackendError(mutation.error)
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
{backendErr && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendErr}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="codigo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Código</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={mutation.isPending}
|
||||
placeholder="Ej: cajero_senior"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nombre"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={mutation.isPending} placeholder="Ej: Cajero Senior" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="descripcion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Descripción (opcional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={mutation.isPending}
|
||||
placeholder="Descripción del rol"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={mutation.isPending} className="w-full">
|
||||
{mutation.isPending ? 'Creando...' : 'Crear rol'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Edit Form ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function EditRolForm({ initial, onSuccess }: { initial: RolDto; onSuccess?: () => void }) {
|
||||
const mutation = useUpdateRole()
|
||||
const form = useForm<UpdateFormValues>({
|
||||
resolver: zodResolver(updateSchema),
|
||||
defaultValues: {
|
||||
nombre: initial.nombre,
|
||||
descripcion: initial.descripcion ?? '',
|
||||
activo: initial.activo,
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(values: UpdateFormValues) {
|
||||
mutation.mutate(
|
||||
{
|
||||
codigo: initial.codigo,
|
||||
payload: {
|
||||
nombre: values.nombre,
|
||||
descripcion: values.descripcion ? values.descripcion : null,
|
||||
activo: values.activo,
|
||||
},
|
||||
},
|
||||
{ onSuccess: () => onSuccess?.() },
|
||||
)
|
||||
}
|
||||
|
||||
const backendErr = resolveBackendError(mutation.error)
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
{backendErr && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendErr}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>Código</FormLabel>
|
||||
<FormControl>
|
||||
<Input value={initial.codigo} disabled readOnly />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nombre"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={mutation.isPending} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="descripcion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Descripción (opcional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={mutation.isPending} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="activo"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-3">
|
||||
<FormControl>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
disabled={mutation.isPending}
|
||||
aria-label="Activo"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">Activo</FormLabel>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={mutation.isPending} className="w-full">
|
||||
{mutation.isPending ? 'Guardando...' : 'Guardar cambios'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
97
src/web/src/features/roles/components/RolesList.tsx
Normal file
97
src/web/src/features/roles/components/RolesList.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { useRoles } from '../hooks/useRoles'
|
||||
import { useDeactivateRole } from '../hooks/useDeactivateRole'
|
||||
|
||||
export function RolesList() {
|
||||
const { data: roles, isLoading, isError, error } = useRoles()
|
||||
const deactivateMut = useDeactivateRole()
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Cargando roles...</p>
|
||||
|
||||
if (isError) {
|
||||
const msg = isAxiosError(error) ? (error.message ?? 'Error al cargar roles') : 'Error al cargar roles'
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{msg}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (!roles || roles.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No hay roles registrados.</p>
|
||||
}
|
||||
|
||||
function handleDeactivate(codigo: string) {
|
||||
deactivateMut.mutate(codigo)
|
||||
}
|
||||
|
||||
const deactivateErr = deactivateMut.error
|
||||
const deactivateErrMsg =
|
||||
deactivateErr && isAxiosError(deactivateErr)
|
||||
? (deactivateErr.response?.data as { message?: string } | undefined)?.message ??
|
||||
'No se pudo desactivar el rol'
|
||||
: deactivateErr
|
||||
? 'No se pudo desactivar el rol'
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{deactivateErrMsg && (
|
||||
<Alert variant="destructive" role="alert">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{deactivateErrMsg}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2 pr-4">Código</th>
|
||||
<th className="py-2 pr-4">Nombre</th>
|
||||
<th className="py-2 pr-4">Descripción</th>
|
||||
<th className="py-2 pr-4">Estado</th>
|
||||
<th className="py-2 pr-4 text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roles.map((r) => (
|
||||
<tr key={r.codigo} className="border-t border-border">
|
||||
<td className="py-2 pr-4 font-mono text-xs">{r.codigo}</td>
|
||||
<td className="py-2 pr-4">{r.nombre}</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">{r.descripcion ?? '—'}</td>
|
||||
<td className="py-2 pr-4">
|
||||
{r.activo ? (
|
||||
<Badge>Activo</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Inactivo</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right space-x-2">
|
||||
<Link to={`/admin/roles/${encodeURIComponent(r.codigo)}/editar`}>
|
||||
<Button size="sm" variant="outline">
|
||||
Editar
|
||||
</Button>
|
||||
</Link>
|
||||
{r.activo && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={deactivateMut.isPending}
|
||||
onClick={() => handleDeactivate(r.codigo)}
|
||||
>
|
||||
Desactivar
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
src/web/src/features/roles/hooks/useCreateRole.ts
Normal file
14
src/web/src/features/roles/hooks/useCreateRole.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { createRole } from '../api/createRole'
|
||||
import type { CreateRolRequest } from '../api/types'
|
||||
import { rolesQueryKey } from './useRoles'
|
||||
|
||||
export function useCreateRole() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateRolRequest) => createRole(payload),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: rolesQueryKey })
|
||||
},
|
||||
})
|
||||
}
|
||||
13
src/web/src/features/roles/hooks/useDeactivateRole.ts
Normal file
13
src/web/src/features/roles/hooks/useDeactivateRole.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { deactivateRole } from '../api/deactivateRole'
|
||||
import { rolesQueryKey } from './useRoles'
|
||||
|
||||
export function useDeactivateRole() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (codigo: string) => deactivateRole(codigo),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: rolesQueryKey })
|
||||
},
|
||||
})
|
||||
}
|
||||
10
src/web/src/features/roles/hooks/useRol.ts
Normal file
10
src/web/src/features/roles/hooks/useRol.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRol } from '../api/getRol'
|
||||
|
||||
export function useRol(codigo: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['roles', codigo],
|
||||
queryFn: () => getRol(codigo!),
|
||||
enabled: Boolean(codigo),
|
||||
})
|
||||
}
|
||||
12
src/web/src/features/roles/hooks/useRoles.ts
Normal file
12
src/web/src/features/roles/hooks/useRoles.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { listRoles } from '../api/listRoles'
|
||||
|
||||
export const rolesQueryKey = ['roles'] as const
|
||||
|
||||
export function useRoles() {
|
||||
return useQuery({
|
||||
queryKey: rolesQueryKey,
|
||||
queryFn: listRoles,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
20
src/web/src/features/roles/hooks/useUpdateRole.ts
Normal file
20
src/web/src/features/roles/hooks/useUpdateRole.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { updateRole } from '../api/updateRole'
|
||||
import type { UpdateRolRequest } from '../api/types'
|
||||
import { rolesQueryKey } from './useRoles'
|
||||
|
||||
interface UpdateRoleVars {
|
||||
codigo: string
|
||||
payload: UpdateRolRequest
|
||||
}
|
||||
|
||||
export function useUpdateRole() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ codigo, payload }: UpdateRoleVars) => updateRole(codigo, payload),
|
||||
onSuccess: (_data, vars) => {
|
||||
void qc.invalidateQueries({ queryKey: rolesQueryKey })
|
||||
void qc.invalidateQueries({ queryKey: ['roles', vars.codigo] })
|
||||
},
|
||||
})
|
||||
}
|
||||
46
src/web/src/features/roles/pages/EditRolPage.tsx
Normal file
46
src/web/src/features/roles/pages/EditRolPage.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { useRol } from '../hooks/useRol'
|
||||
import { EditRolForm } from '../components/RolForm'
|
||||
|
||||
export function EditRolPage() {
|
||||
const navigate = useNavigate()
|
||||
const { codigo } = useParams<{ codigo: string }>()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const { data: rol, isLoading, isError } = useRol(codigo)
|
||||
|
||||
if (!user || user.rol !== 'admin') {
|
||||
void navigate('/', { replace: true })
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-xl">Editar rol</CardTitle>
|
||||
<CardDescription>Modificá nombre, descripción o estado del rol.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Cargando...</p>}
|
||||
{isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>No se pudo cargar el rol.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{rol && <EditRolForm initial={rol} onSuccess={() => navigate('/admin/roles')} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/web/src/features/roles/pages/NewRolPage.tsx
Normal file
36
src/web/src/features/roles/pages/NewRolPage.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { CreateRolForm } from '../components/RolForm'
|
||||
|
||||
export function NewRolPage() {
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
|
||||
if (!user || user.rol !== 'admin') {
|
||||
void navigate('/', { replace: true })
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-xl">Nuevo rol</CardTitle>
|
||||
<CardDescription>
|
||||
Creá un nuevo rol del sistema. El código es inmutable una vez creado.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CreateRolForm onSuccess={() => navigate('/admin/roles')} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
src/web/src/features/roles/pages/RolesPage.tsx
Normal file
42
src/web/src/features/roles/pages/RolesPage.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { RolesList } from '../components/RolesList'
|
||||
|
||||
export function RolesPage() {
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
|
||||
if (!user || user.rol !== 'admin') {
|
||||
void navigate('/', { replace: true })
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Card className="w-full max-w-4xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl">Roles del sistema</CardTitle>
|
||||
<CardDescription>
|
||||
Gestión de roles canónicos. Los roles inactivos no pueden asignarse a nuevos usuarios.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link to="/admin/roles/nuevo">
|
||||
<Button>Nuevo rol</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RolesList />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,10 +15,9 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { useCreateUser } from '../hooks/useCreateUser'
|
||||
import { useRolesForSelect } from '../hooks/useRolesForSelect'
|
||||
import type { CreatedUserDto } from '../api/createUser'
|
||||
|
||||
const ROL_OPTIONS = ['admin', 'vendedor', 'tasador', 'consulta'] as const
|
||||
|
||||
const userFormSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
@@ -32,11 +31,7 @@ const userFormSchema = z.object({
|
||||
nombre: z.string().min(1, 'El nombre es requerido'),
|
||||
apellido: z.string().min(1, 'El apellido es requerido'),
|
||||
email: z.string().email('Email inválido').optional().or(z.literal('')),
|
||||
rol: z
|
||||
.string({ required_error: 'Seleccioná un rol válido' })
|
||||
.refine((v): v is (typeof ROL_OPTIONS)[number] => (ROL_OPTIONS as readonly string[]).includes(v), {
|
||||
message: 'Seleccioná un rol válido',
|
||||
}),
|
||||
rol: z.string().min(1, 'Seleccioná un rol válido'),
|
||||
})
|
||||
|
||||
type UserFormValues = z.infer<typeof userFormSchema>
|
||||
@@ -59,6 +54,7 @@ function resolveBackendError(err: unknown): string | null {
|
||||
|
||||
export function UserForm({ onSuccess }: UserFormProps) {
|
||||
const { mutate, isPending, error } = useCreateUser()
|
||||
const { options: rolOptions, isLoading: rolesLoading, isError: rolesError } = useRolesForSelect()
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userFormSchema),
|
||||
@@ -91,6 +87,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
||||
}
|
||||
|
||||
const backendError = resolveBackendError(error)
|
||||
const disabled = isPending || rolesLoading
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -102,6 +99,15 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{rolesError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
No se pudieron cargar los roles. Intentá refrescar la página.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
@@ -113,7 +119,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
||||
{...field}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
disabled={isPending}
|
||||
disabled={disabled}
|
||||
placeholder="Nombre de usuario"
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -133,7 +139,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isPending}
|
||||
disabled={disabled}
|
||||
placeholder="Mínimo 8 chars, letra y dígito"
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -149,7 +155,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
||||
<FormItem>
|
||||
<FormLabel>Nombre</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" disabled={isPending} placeholder="Nombre" />
|
||||
<Input {...field} type="text" disabled={disabled} placeholder="Nombre" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -163,7 +169,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
||||
<FormItem>
|
||||
<FormLabel>Apellido</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" disabled={isPending} placeholder="Apellido" />
|
||||
<Input {...field} type="text" disabled={disabled} placeholder="Apellido" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -181,7 +187,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
||||
{...field}
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
disabled={isPending}
|
||||
disabled={disabled}
|
||||
placeholder="correo@ejemplo.com"
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -199,14 +205,16 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
||||
<FormControl>
|
||||
<select
|
||||
{...field}
|
||||
disabled={isPending}
|
||||
disabled={disabled || rolesError}
|
||||
aria-label="Rol"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Seleccioná un rol</option>
|
||||
{ROL_OPTIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r.charAt(0).toUpperCase() + r.slice(1)}
|
||||
<option value="">
|
||||
{rolesLoading ? 'Cargando roles...' : 'Seleccioná un rol'}
|
||||
</option>
|
||||
{rolOptions.map((r) => (
|
||||
<option key={r.codigo} value={r.codigo}>
|
||||
{r.nombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -216,7 +224,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
<Button type="submit" disabled={disabled} className="w-full">
|
||||
{isPending ? 'Creando...' : 'Crear usuario'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
22
src/web/src/features/users/hooks/useRolesForSelect.ts
Normal file
22
src/web/src/features/users/hooks/useRolesForSelect.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useRoles } from '../../roles/hooks/useRoles'
|
||||
import type { RolDto } from '../../roles/api/types'
|
||||
|
||||
export interface RolOption {
|
||||
codigo: string
|
||||
nombre: string
|
||||
}
|
||||
|
||||
interface UseRolesForSelectResult {
|
||||
options: RolOption[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
// Returns only ACTIVE roles, mapped to a select-friendly shape.
|
||||
export function useRolesForSelect(): UseRolesForSelectResult {
|
||||
const { data, isLoading, isError } = useRoles()
|
||||
const options: RolOption[] = (data ?? [])
|
||||
.filter((r: RolDto) => r.activo)
|
||||
.map((r) => ({ codigo: r.codigo, nombre: r.nombre }))
|
||||
return { options, isLoading, isError }
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
import { LoginPage } from './features/auth/pages/LoginPage'
|
||||
import { CreateUserPage } from './features/users/pages/CreateUserPage'
|
||||
import { RolesPage } from './features/roles/pages/RolesPage'
|
||||
import { NewRolPage } from './features/roles/pages/NewRolPage'
|
||||
import { EditRolPage } from './features/roles/pages/EditRolPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { PublicLayout } from './layouts/PublicLayout'
|
||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||
@@ -55,6 +58,36 @@ export function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/roles"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedLayout>
|
||||
<RolesPage />
|
||||
</ProtectedLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/roles/nuevo"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedLayout>
|
||||
<NewRolPage />
|
||||
</ProtectedLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/roles/:codigo/editar"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedLayout>
|
||||
<EditRolPage />
|
||||
</ProtectedLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
167
src/web/src/tests/features/roles/RolForm.test.tsx
Normal file
167
src/web/src/tests/features/roles/RolForm.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } 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 { CreateRolForm, EditRolForm } from '../../../features/roles/components/RolForm'
|
||||
import type { RolDto } from '../../../features/roles/api/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockCreated = {
|
||||
id: 10,
|
||||
codigo: 'cajero_senior',
|
||||
nombre: 'Cajero Senior',
|
||||
descripcion: 'Con más permisos',
|
||||
activo: true,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderCreate(onSuccess = vi.fn()) {
|
||||
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<CreateRolForm onSuccess={onSuccess} />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function renderEdit(initial: RolDto, onSuccess = vi.fn()) {
|
||||
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<EditRolForm initial={initial} onSuccess={onSuccess} />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('CreateRolForm — zod validation', () => {
|
||||
it('shows error when codigo is too short (< 3 chars)', async () => {
|
||||
const u = userEvent.setup()
|
||||
renderCreate()
|
||||
|
||||
await u.type(screen.getByLabelText(/código/i), 'ab')
|
||||
await u.type(screen.getByLabelText(/nombre/i), 'Test')
|
||||
await u.click(screen.getByRole('button', { name: /crear rol/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/mínimo 3 caracteres/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error for uppercase codigo', async () => {
|
||||
const u = userEvent.setup()
|
||||
renderCreate()
|
||||
|
||||
await u.type(screen.getByLabelText(/código/i), 'Cajero')
|
||||
await u.type(screen.getByLabelText(/nombre/i), 'Test')
|
||||
await u.click(screen.getByRole('button', { name: /crear rol/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/solo minúsculas/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when nombre is empty', async () => {
|
||||
const u = userEvent.setup()
|
||||
renderCreate()
|
||||
|
||||
await u.type(screen.getByLabelText(/código/i), 'cajero_test')
|
||||
await u.click(screen.getByRole('button', { name: /crear rol/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/el nombre es requerido/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreateRolForm — submit', () => {
|
||||
it('posts to /api/v1/roles and calls onSuccess on 201', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/roles`, async () => HttpResponse.json(mockCreated, { status: 201 })),
|
||||
)
|
||||
|
||||
const onSuccess = vi.fn()
|
||||
const u = userEvent.setup()
|
||||
renderCreate(onSuccess)
|
||||
|
||||
await u.type(screen.getByLabelText(/código/i), 'cajero_senior')
|
||||
await u.type(screen.getByLabelText(/nombre/i), 'Cajero Senior')
|
||||
await u.type(screen.getByLabelText(/descripción/i), 'Con más permisos')
|
||||
await u.click(screen.getByRole('button', { name: /crear rol/i }))
|
||||
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('shows 409 rol_already_exists error', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/roles`, async () =>
|
||||
HttpResponse.json(
|
||||
{ error: 'rol_already_exists', message: "El rol 'cajero' ya existe." },
|
||||
{ status: 409 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const u = userEvent.setup()
|
||||
renderCreate()
|
||||
|
||||
await u.type(screen.getByLabelText(/código/i), 'cajero')
|
||||
await u.type(screen.getByLabelText(/nombre/i), 'Duplicated')
|
||||
await u.click(screen.getByRole('button', { name: /crear rol/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/ya existe/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EditRolForm', () => {
|
||||
const initial: RolDto = {
|
||||
id: 5,
|
||||
codigo: 'picadora',
|
||||
nombre: 'Picadora',
|
||||
descripcion: 'Edición de textos',
|
||||
activo: true,
|
||||
fechaCreacion: '2026-04-15T00:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
it('shows initial values and renders codigo as read-only', () => {
|
||||
renderEdit(initial)
|
||||
|
||||
expect(screen.getByDisplayValue('picadora')).toHaveAttribute('readOnly')
|
||||
expect(screen.getByDisplayValue('Picadora')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Edición de textos')).toBeInTheDocument()
|
||||
expect(screen.getByRole('checkbox', { name: /activo/i })).toBeChecked()
|
||||
})
|
||||
|
||||
it('submits PUT to /api/v1/roles/{codigo} with updated values', async () => {
|
||||
const updated: RolDto = { ...initial, nombre: 'Picadora V2', fechaModificacion: '2026-04-15T10:00:00Z' }
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/roles/picadora`, async () => HttpResponse.json(updated, { status: 200 })),
|
||||
)
|
||||
|
||||
const onSuccess = vi.fn()
|
||||
const u = userEvent.setup()
|
||||
renderEdit(initial, onSuccess)
|
||||
|
||||
const nombreInput = screen.getByDisplayValue('Picadora')
|
||||
await u.clear(nombreInput)
|
||||
await u.type(nombreInput, 'Picadora V2')
|
||||
await u.click(screen.getByRole('button', { name: /guardar cambios/i }))
|
||||
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
88
src/web/src/tests/features/roles/RolesList.test.tsx
Normal file
88
src/web/src/tests/features/roles/RolesList.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } 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 { RolesList } from '../../../features/roles/components/RolesList'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const canonical = [
|
||||
{ id: 1, codigo: 'admin', nombre: 'Administrador', descripcion: 'Todo', activo: true, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null },
|
||||
{ id: 2, codigo: 'cajero', nombre: 'Cajero', descripcion: 'Mostrador', activo: true, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null },
|
||||
{ id: 3, codigo: 'reportes', nombre: 'Reportes', descripcion: null, activo: false, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null },
|
||||
]
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderList() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<RolesList />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('RolesList', () => {
|
||||
it('renders all roles including inactive with correct badge', async () => {
|
||||
server.use(http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(canonical)))
|
||||
|
||||
renderList()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Administrador')).toBeInTheDocument())
|
||||
expect(screen.getByText('Cajero')).toBeInTheDocument()
|
||||
expect(screen.getByText('Reportes')).toBeInTheDocument()
|
||||
|
||||
// Active badge for cajero, inactive for reportes
|
||||
const activeBadges = screen.getAllByText(/^Activo$/)
|
||||
expect(activeBadges.length).toBeGreaterThanOrEqual(2)
|
||||
expect(screen.getByText(/^Inactivo$/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Deactivate button for already-inactive roles', async () => {
|
||||
server.use(http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(canonical)))
|
||||
|
||||
renderList()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Reportes')).toBeInTheDocument())
|
||||
|
||||
// There should be Deactivate buttons only for active roles (2: admin, cajero).
|
||||
const deactivateButtons = screen.getAllByRole('button', { name: /desactivar/i })
|
||||
expect(deactivateButtons).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows 409 error alert when deactivate blocked by active usuarios', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(canonical)),
|
||||
http.delete(`${API_URL}/api/v1/roles/cajero`, () =>
|
||||
HttpResponse.json(
|
||||
{ error: 'rol_in_use', message: "El rol 'cajero' no puede desactivarse porque existen usuarios activos." },
|
||||
{ status: 409 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const u = userEvent.setup()
|
||||
renderList()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Cajero')).toBeInTheDocument())
|
||||
|
||||
// Click Deactivate on cajero row (first active row with deactivate button after admin).
|
||||
const buttons = screen.getAllByRole('button', { name: /desactivar/i })
|
||||
// Admin is listed first in canonical; cajero deactivate button is index 1.
|
||||
await u.click(buttons[1]!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/no puede desactivarse/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,10 +15,19 @@ const mockCreatedUser = {
|
||||
nombre: 'Juan',
|
||||
apellido: 'Doe',
|
||||
email: null,
|
||||
rol: 'vendedor',
|
||||
rol: 'cajero',
|
||||
activo: true,
|
||||
}
|
||||
|
||||
// Mock the 8 canonical active roles served by /api/v1/roles.
|
||||
// UserForm filters active=true via useRolesForSelect.
|
||||
const mockRoles = [
|
||||
{ id: 1, codigo: 'admin', nombre: 'Administrador', descripcion: null, activo: true, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null },
|
||||
{ id: 2, codigo: 'cajero', nombre: 'Cajero', descripcion: null, activo: true, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null },
|
||||
{ id: 3, codigo: 'picadora', nombre: 'Picadora/Correctora', descripcion: null, activo: true, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null },
|
||||
{ id: 4, codigo: 'reportes', nombre: 'Reportes', descripcion: null, activo: false, fechaCreacion: '2026-04-15T00:00:00Z', fechaModificacion: null },
|
||||
]
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
@@ -27,7 +36,7 @@ afterAll(() => server.close())
|
||||
|
||||
function renderForm(onSuccess = vi.fn()) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
|
||||
return render(
|
||||
@@ -41,8 +50,11 @@ function renderForm(onSuccess = vi.fn()) {
|
||||
|
||||
describe('UserForm — Zod validation', () => {
|
||||
it('shows error when username is too short (< 3 chars)', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)),
|
||||
http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
||||
renderForm()
|
||||
|
||||
await user.type(screen.getByLabelText(/usuario/i), 'ab')
|
||||
@@ -53,22 +65,12 @@ describe('UserForm — Zod validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when username exceeds 50 chars', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
||||
renderForm()
|
||||
|
||||
await user.type(screen.getByLabelText(/usuario/i), 'a'.repeat(51))
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/máximo 50 caracteres/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when password is too short (< 8 chars)', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)),
|
||||
http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
||||
renderForm()
|
||||
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Ab1')
|
||||
@@ -79,38 +81,14 @@ describe('UserForm — Zod validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when password has no letter', async () => {
|
||||
it('shows error when rol is not selected', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)),
|
||||
http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
||||
renderForm()
|
||||
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), '12345678')
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/debe contener al menos una letra/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when password has no digit', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
||||
renderForm()
|
||||
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'abcdefgh')
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/debe contener al menos un dígito/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when rol is not in whitelist', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
|
||||
renderForm()
|
||||
|
||||
// Fill valid fields, leave rol empty (default placeholder)
|
||||
await user.type(screen.getByLabelText(/usuario/i), 'jdoe123')
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
||||
await user.type(screen.getByLabelText(/nombre/i), 'Juan')
|
||||
@@ -121,25 +99,48 @@ describe('UserForm — Zod validation', () => {
|
||||
expect(screen.getByText(/seleccioná un rol válido/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts optional empty email', async () => {
|
||||
describe('UserForm — roles dropdown integration', () => {
|
||||
it('renders only ACTIVE canonical roles fetched from /api/v1/roles', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/users`, async () => {
|
||||
return HttpResponse.json(mockCreatedUser, { status: 201 })
|
||||
}),
|
||||
http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)),
|
||||
http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })),
|
||||
)
|
||||
renderForm()
|
||||
|
||||
// Wait for roles to load — active options should appear.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'Administrador' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByRole('option', { name: 'Cajero' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: 'Picadora/Correctora' })).toBeInTheDocument()
|
||||
// Inactive 'reportes' MUST NOT appear
|
||||
expect(screen.queryByRole('option', { name: 'Reportes' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects cajero and submits successfully', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)),
|
||||
http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })),
|
||||
)
|
||||
|
||||
const onSuccess = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
renderForm(onSuccess)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: 'Cajero' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.type(screen.getByLabelText(/usuario/i), 'jdoe123')
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
||||
await user.type(screen.getByLabelText(/nombre/i), 'Juan')
|
||||
await user.type(screen.getByLabelText(/apellido/i), 'Doe')
|
||||
// Select rol via combobox
|
||||
await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor')
|
||||
// email left empty — valid
|
||||
await user.selectOptions(screen.getByLabelText(/rol/i), 'cajero')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
@@ -147,51 +148,43 @@ describe('UserForm — Zod validation', () => {
|
||||
expect(onSuccess).toHaveBeenCalledWith(mockCreatedUser)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error alert when roles fetch fails', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/roles`, () => new HttpResponse(null, { status: 500 })),
|
||||
)
|
||||
renderForm()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/no se pudieron cargar los roles/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserForm — submit and backend error display', () => {
|
||||
it('calls mutation on valid submit and invokes onSuccess callback', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/users`, async () => {
|
||||
return HttpResponse.json(mockCreatedUser, { status: 201 })
|
||||
}),
|
||||
)
|
||||
|
||||
const onSuccess = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
renderForm(onSuccess)
|
||||
|
||||
await user.type(screen.getByLabelText(/usuario/i), 'jdoe123')
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
||||
await user.type(screen.getByLabelText(/nombre/i), 'Juan')
|
||||
await user.type(screen.getByLabelText(/apellido/i), 'Doe')
|
||||
await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalledWith(mockCreatedUser)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserForm — backend error display', () => {
|
||||
it('shows backend 409 username_taken error in alert', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/users`, async () => {
|
||||
return HttpResponse.json(
|
||||
http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)),
|
||||
http.post(`${API_URL}/api/v1/users`, async () =>
|
||||
HttpResponse.json(
|
||||
{ error: 'username_taken', message: 'El usuario ya existe' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderForm()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: 'Cajero' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.type(screen.getByLabelText(/usuario/i), 'existing')
|
||||
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
|
||||
await user.type(screen.getByLabelText(/nombre/i), 'Juan')
|
||||
await user.type(screen.getByLabelText(/apellido/i), 'Doe')
|
||||
await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor')
|
||||
await user.selectOptions(screen.getByLabelText(/rol/i), 'cajero')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user