UDT-004: Gestión de Roles (tabla maestra + CRUD admin + validator dinámico + UI) #8

Merged
dmolinari merged 4 commits from feature/UDT-004 into main 2026-04-15 16:19:58 +00:00
23 changed files with 1025 additions and 101 deletions
Showing only changes of commit fae06fb8b8 - Show all commits

View File

@@ -6,6 +6,7 @@ import {
Zap, Zap,
Settings, Settings,
UserPlus, UserPlus,
ShieldCheck,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -104,6 +105,18 @@ export function SidebarNav() {
<UserPlus className="h-4 w-4 shrink-0" /> <UserPlus className="h-4 w-4 shrink-0" />
<span>Crear Usuario</span> <span>Crear Usuario</span>
</Link> </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> </nav>

View 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
}

View 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)}`)
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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>
)
}

View 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>
)
}

View 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 })
},
})
}

View 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 })
},
})
}

View 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),
})
}

View 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,
})
}

View 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] })
},
})
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -15,10 +15,9 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { useCreateUser } from '../hooks/useCreateUser' import { useCreateUser } from '../hooks/useCreateUser'
import { useRolesForSelect } from '../hooks/useRolesForSelect'
import type { CreatedUserDto } from '../api/createUser' import type { CreatedUserDto } from '../api/createUser'
const ROL_OPTIONS = ['admin', 'vendedor', 'tasador', 'consulta'] as const
const userFormSchema = z.object({ const userFormSchema = z.object({
username: z username: z
.string() .string()
@@ -32,11 +31,7 @@ const userFormSchema = z.object({
nombre: z.string().min(1, 'El nombre es requerido'), nombre: z.string().min(1, 'El nombre es requerido'),
apellido: z.string().min(1, 'El apellido es requerido'), apellido: z.string().min(1, 'El apellido es requerido'),
email: z.string().email('Email inválido').optional().or(z.literal('')), email: z.string().email('Email inválido').optional().or(z.literal('')),
rol: z rol: z.string().min(1, 'Seleccioná un rol válido'),
.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',
}),
}) })
type UserFormValues = z.infer<typeof userFormSchema> type UserFormValues = z.infer<typeof userFormSchema>
@@ -59,6 +54,7 @@ function resolveBackendError(err: unknown): string | null {
export function UserForm({ onSuccess }: UserFormProps) { export function UserForm({ onSuccess }: UserFormProps) {
const { mutate, isPending, error } = useCreateUser() const { mutate, isPending, error } = useCreateUser()
const { options: rolOptions, isLoading: rolesLoading, isError: rolesError } = useRolesForSelect()
const form = useForm<UserFormValues>({ const form = useForm<UserFormValues>({
resolver: zodResolver(userFormSchema), resolver: zodResolver(userFormSchema),
@@ -91,6 +87,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
} }
const backendError = resolveBackendError(error) const backendError = resolveBackendError(error)
const disabled = isPending || rolesLoading
return ( return (
<Form {...form}> <Form {...form}>
@@ -102,6 +99,15 @@ export function UserForm({ onSuccess }: UserFormProps) {
</Alert> </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 <FormField
control={form.control} control={form.control}
name="username" name="username"
@@ -113,7 +119,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
{...field} {...field}
type="text" type="text"
autoComplete="off" autoComplete="off"
disabled={isPending} disabled={disabled}
placeholder="Nombre de usuario" placeholder="Nombre de usuario"
/> />
</FormControl> </FormControl>
@@ -133,7 +139,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
{...field} {...field}
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
disabled={isPending} disabled={disabled}
placeholder="Mínimo 8 chars, letra y dígito" placeholder="Mínimo 8 chars, letra y dígito"
/> />
</FormControl> </FormControl>
@@ -149,7 +155,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
<FormItem> <FormItem>
<FormLabel>Nombre</FormLabel> <FormLabel>Nombre</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="text" disabled={isPending} placeholder="Nombre" /> <Input {...field} type="text" disabled={disabled} placeholder="Nombre" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -163,7 +169,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
<FormItem> <FormItem>
<FormLabel>Apellido</FormLabel> <FormLabel>Apellido</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="text" disabled={isPending} placeholder="Apellido" /> <Input {...field} type="text" disabled={disabled} placeholder="Apellido" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -181,7 +187,7 @@ export function UserForm({ onSuccess }: UserFormProps) {
{...field} {...field}
type="email" type="email"
autoComplete="off" autoComplete="off"
disabled={isPending} disabled={disabled}
placeholder="correo@ejemplo.com" placeholder="correo@ejemplo.com"
/> />
</FormControl> </FormControl>
@@ -199,14 +205,16 @@ export function UserForm({ onSuccess }: UserFormProps) {
<FormControl> <FormControl>
<select <select
{...field} {...field}
disabled={isPending} disabled={disabled || rolesError}
aria-label="Rol" 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" 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> <option value="">
{ROL_OPTIONS.map((r) => ( {rolesLoading ? 'Cargando roles...' : 'Seleccioná un rol'}
<option key={r} value={r}> </option>
{r.charAt(0).toUpperCase() + r.slice(1)} {rolOptions.map((r) => (
<option key={r.codigo} value={r.codigo}>
{r.nombre}
</option> </option>
))} ))}
</select> </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'} {isPending ? 'Creando...' : 'Crear usuario'}
</Button> </Button>
</form> </form>

View 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 }
}

View File

@@ -2,6 +2,9 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { useAuthStore } from './stores/authStore' import { useAuthStore } from './stores/authStore'
import { LoginPage } from './features/auth/pages/LoginPage' import { LoginPage } from './features/auth/pages/LoginPage'
import { CreateUserPage } from './features/users/pages/CreateUserPage' 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 { HomePage } from './pages/HomePage'
import { PublicLayout } from './layouts/PublicLayout' import { PublicLayout } from './layouts/PublicLayout'
import { ProtectedLayout } from './layouts/ProtectedLayout' import { ProtectedLayout } from './layouts/ProtectedLayout'
@@ -55,6 +58,36 @@ export function AppRoutes() {
</ProtectedRoute> </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 />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
) )

View 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())
})
})

View 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)
})
})
})

View File

@@ -15,10 +15,19 @@ const mockCreatedUser = {
nombre: 'Juan', nombre: 'Juan',
apellido: 'Doe', apellido: 'Doe',
email: null, email: null,
rol: 'vendedor', rol: 'cajero',
activo: true, 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() const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
@@ -27,7 +36,7 @@ afterAll(() => server.close())
function renderForm(onSuccess = vi.fn()) { function renderForm(onSuccess = vi.fn()) {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } }, defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
}) })
return render( return render(
@@ -41,8 +50,11 @@ function renderForm(onSuccess = vi.fn()) {
describe('UserForm — Zod validation', () => { describe('UserForm — Zod validation', () => {
it('shows error when username is too short (< 3 chars)', async () => { 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() const user = userEvent.setup()
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
renderForm() renderForm()
await user.type(screen.getByLabelText(/usuario/i), 'ab') 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 () => { 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() const user = userEvent.setup()
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
renderForm() renderForm()
await user.type(screen.getByLabelText(/^contraseña$/i), 'Ab1') 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() const user = userEvent.setup()
server.use(http.post(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockCreatedUser, { status: 201 })))
renderForm() 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(/usuario/i), 'jdoe123')
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12') await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
await user.type(screen.getByLabelText(/nombre/i), 'Juan') await user.type(screen.getByLabelText(/nombre/i), 'Juan')
@@ -121,51 +99,48 @@ describe('UserForm — Zod validation', () => {
expect(screen.getByText(/seleccioná un rol válido/i)).toBeInTheDocument() 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( server.use(
http.post(`${API_URL}/api/v1/users`, async () => { http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)),
return HttpResponse.json(mockCreatedUser, { status: 201 }) 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 onSuccess = vi.fn()
const user = userEvent.setup() const user = userEvent.setup()
renderForm(onSuccess) 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')
// Select rol via combobox
await user.selectOptions(screen.getByLabelText(/rol/i), 'vendedor')
// email left empty — valid
await user.click(screen.getByRole('button', { name: /crear usuario/i }))
await waitFor(() => { await waitFor(() => {
expect(onSuccess).toHaveBeenCalledWith(mockCreatedUser) expect(screen.getByRole('option', { name: 'Cajero' })).toBeInTheDocument()
}) })
})
})
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(/usuario/i), 'jdoe123')
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12') await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
await user.type(screen.getByLabelText(/nombre/i), 'Juan') await user.type(screen.getByLabelText(/nombre/i), 'Juan')
await user.type(screen.getByLabelText(/apellido/i), 'Doe') 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 })) await user.click(screen.getByRole('button', { name: /crear usuario/i }))
@@ -174,24 +149,42 @@ describe('UserForm — submit and backend error display', () => {
}) })
}) })
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 — backend error display', () => {
it('shows backend 409 username_taken error in alert', async () => { it('shows backend 409 username_taken error in alert', async () => {
server.use( server.use(
http.post(`${API_URL}/api/v1/users`, async () => { http.get(`${API_URL}/api/v1/roles`, () => HttpResponse.json(mockRoles)),
return HttpResponse.json( http.post(`${API_URL}/api/v1/users`, async () =>
HttpResponse.json(
{ error: 'username_taken', message: 'El usuario ya existe' }, { error: 'username_taken', message: 'El usuario ya existe' },
{ status: 409 }, { status: 409 },
) ),
}), ),
) )
const user = userEvent.setup() const user = userEvent.setup()
renderForm() renderForm()
await waitFor(() => {
expect(screen.getByRole('option', { name: 'Cajero' })).toBeInTheDocument()
})
await user.type(screen.getByLabelText(/usuario/i), 'existing') await user.type(screen.getByLabelText(/usuario/i), 'existing')
await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12') await user.type(screen.getByLabelText(/^contraseña$/i), 'Secret12')
await user.type(screen.getByLabelText(/nombre/i), 'Juan') await user.type(screen.getByLabelText(/nombre/i), 'Juan')
await user.type(screen.getByLabelText(/apellido/i), 'Doe') 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 })) await user.click(screen.getByRole('button', { name: /crear usuario/i }))