From fae06fb8b80a4662774c95635f85f8aaae081f0b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 12:58:08 -0300 Subject: [PATCH] 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 --- src/web/src/components/layout/AppSidebar.tsx | 13 + src/web/src/features/roles/api/createRole.ts | 7 + .../src/features/roles/api/deactivateRole.ts | 5 + src/web/src/features/roles/api/getRol.ts | 7 + src/web/src/features/roles/api/listRoles.ts | 7 + src/web/src/features/roles/api/types.ts | 29 +++ src/web/src/features/roles/api/updateRole.ts | 10 + .../src/features/roles/components/RolForm.tsx | 245 ++++++++++++++++++ .../features/roles/components/RolesList.tsx | 97 +++++++ .../src/features/roles/hooks/useCreateRole.ts | 14 + .../features/roles/hooks/useDeactivateRole.ts | 13 + src/web/src/features/roles/hooks/useRol.ts | 10 + src/web/src/features/roles/hooks/useRoles.ts | 12 + .../src/features/roles/hooks/useUpdateRole.ts | 20 ++ .../src/features/roles/pages/EditRolPage.tsx | 46 ++++ .../src/features/roles/pages/NewRolPage.tsx | 36 +++ .../src/features/roles/pages/RolesPage.tsx | 42 +++ .../features/users/components/UserForm.tsx | 44 ++-- .../features/users/hooks/useRolesForSelect.ts | 22 ++ src/web/src/router.tsx | 33 +++ .../src/tests/features/roles/RolForm.test.tsx | 167 ++++++++++++ .../tests/features/roles/RolesList.test.tsx | 88 +++++++ .../tests/features/users/UserForm.test.tsx | 159 ++++++------ 23 files changed, 1025 insertions(+), 101 deletions(-) create mode 100644 src/web/src/features/roles/api/createRole.ts create mode 100644 src/web/src/features/roles/api/deactivateRole.ts create mode 100644 src/web/src/features/roles/api/getRol.ts create mode 100644 src/web/src/features/roles/api/listRoles.ts create mode 100644 src/web/src/features/roles/api/types.ts create mode 100644 src/web/src/features/roles/api/updateRole.ts create mode 100644 src/web/src/features/roles/components/RolForm.tsx create mode 100644 src/web/src/features/roles/components/RolesList.tsx create mode 100644 src/web/src/features/roles/hooks/useCreateRole.ts create mode 100644 src/web/src/features/roles/hooks/useDeactivateRole.ts create mode 100644 src/web/src/features/roles/hooks/useRol.ts create mode 100644 src/web/src/features/roles/hooks/useRoles.ts create mode 100644 src/web/src/features/roles/hooks/useUpdateRole.ts create mode 100644 src/web/src/features/roles/pages/EditRolPage.tsx create mode 100644 src/web/src/features/roles/pages/NewRolPage.tsx create mode 100644 src/web/src/features/roles/pages/RolesPage.tsx create mode 100644 src/web/src/features/users/hooks/useRolesForSelect.ts create mode 100644 src/web/src/tests/features/roles/RolForm.test.tsx create mode 100644 src/web/src/tests/features/roles/RolesList.test.tsx diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 31a153a..73252da 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -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() { Crear Usuario + + + Roles + )} diff --git a/src/web/src/features/roles/api/createRole.ts b/src/web/src/features/roles/api/createRole.ts new file mode 100644 index 0000000..6563e2b --- /dev/null +++ b/src/web/src/features/roles/api/createRole.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { CreateRolRequest, RolCreatedDto } from './types' + +export async function createRole(payload: CreateRolRequest): Promise { + const response = await axiosClient.post('/api/v1/roles', payload) + return response.data +} diff --git a/src/web/src/features/roles/api/deactivateRole.ts b/src/web/src/features/roles/api/deactivateRole.ts new file mode 100644 index 0000000..ce59ab5 --- /dev/null +++ b/src/web/src/features/roles/api/deactivateRole.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '../../../api/axiosClient' + +export async function deactivateRole(codigo: string): Promise { + await axiosClient.delete(`/api/v1/roles/${encodeURIComponent(codigo)}`) +} diff --git a/src/web/src/features/roles/api/getRol.ts b/src/web/src/features/roles/api/getRol.ts new file mode 100644 index 0000000..2e9bed5 --- /dev/null +++ b/src/web/src/features/roles/api/getRol.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { RolDto } from './types' + +export async function getRol(codigo: string): Promise { + const response = await axiosClient.get(`/api/v1/roles/${encodeURIComponent(codigo)}`) + return response.data +} diff --git a/src/web/src/features/roles/api/listRoles.ts b/src/web/src/features/roles/api/listRoles.ts new file mode 100644 index 0000000..6cb2ffa --- /dev/null +++ b/src/web/src/features/roles/api/listRoles.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { RolDto } from './types' + +export async function listRoles(): Promise { + const response = await axiosClient.get('/api/v1/roles') + return response.data +} diff --git a/src/web/src/features/roles/api/types.ts b/src/web/src/features/roles/api/types.ts new file mode 100644 index 0000000..cba3615 --- /dev/null +++ b/src/web/src/features/roles/api/types.ts @@ -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 +} diff --git a/src/web/src/features/roles/api/updateRole.ts b/src/web/src/features/roles/api/updateRole.ts new file mode 100644 index 0000000..1a4e65c --- /dev/null +++ b/src/web/src/features/roles/api/updateRole.ts @@ -0,0 +1,10 @@ +import { axiosClient } from '../../../api/axiosClient' +import type { RolDto, UpdateRolRequest } from './types' + +export async function updateRole(codigo: string, payload: UpdateRolRequest): Promise { + const response = await axiosClient.put( + `/api/v1/roles/${encodeURIComponent(codigo)}`, + payload, + ) + return response.data +} diff --git a/src/web/src/features/roles/components/RolForm.tsx b/src/web/src/features/roles/components/RolForm.tsx new file mode 100644 index 0000000..76075a3 --- /dev/null +++ b/src/web/src/features/roles/components/RolForm.tsx @@ -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 +type UpdateFormValues = z.infer + +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({ + 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 ( +
+ + {backendErr && ( + + + {backendErr} + + )} + + ( + + Código + + + + + + )} + /> + + ( + + Nombre + + + + + + )} + /> + + ( + + Descripción (opcional) + + + + + + )} + /> + + + + + ) +} + +// ── Edit Form ────────────────────────────────────────────────────────────── + +export function EditRolForm({ initial, onSuccess }: { initial: RolDto; onSuccess?: () => void }) { + const mutation = useUpdateRole() + const form = useForm({ + 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 ( +
+ + {backendErr && ( + + + {backendErr} + + )} + + + Código + + + + + + ( + + Nombre + + + + + + )} + /> + + ( + + Descripción (opcional) + + + + + + )} + /> + + ( + + + field.onChange(e.target.checked)} + disabled={mutation.isPending} + aria-label="Activo" + className="h-4 w-4" + /> + + Activo + + + )} + /> + + + + + ) +} diff --git a/src/web/src/features/roles/components/RolesList.tsx b/src/web/src/features/roles/components/RolesList.tsx new file mode 100644 index 0000000..638b456 --- /dev/null +++ b/src/web/src/features/roles/components/RolesList.tsx @@ -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

Cargando roles...

+ + if (isError) { + const msg = isAxiosError(error) ? (error.message ?? 'Error al cargar roles') : 'Error al cargar roles' + return ( + + + {msg} + + ) + } + + if (!roles || roles.length === 0) { + return

No hay roles registrados.

+ } + + 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 ( +
+ {deactivateErrMsg && ( + + + {deactivateErrMsg} + + )} + + + + + + + + + + + + {roles.map((r) => ( + + + + + + + + ))} + +
CódigoNombreDescripciónEstadoAcciones
{r.codigo}{r.nombre}{r.descripcion ?? '—'} + {r.activo ? ( + Activo + ) : ( + Inactivo + )} + + + + + {r.activo && ( + + )} +
+
+ ) +} diff --git a/src/web/src/features/roles/hooks/useCreateRole.ts b/src/web/src/features/roles/hooks/useCreateRole.ts new file mode 100644 index 0000000..99713d1 --- /dev/null +++ b/src/web/src/features/roles/hooks/useCreateRole.ts @@ -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 }) + }, + }) +} diff --git a/src/web/src/features/roles/hooks/useDeactivateRole.ts b/src/web/src/features/roles/hooks/useDeactivateRole.ts new file mode 100644 index 0000000..755fb7e --- /dev/null +++ b/src/web/src/features/roles/hooks/useDeactivateRole.ts @@ -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 }) + }, + }) +} diff --git a/src/web/src/features/roles/hooks/useRol.ts b/src/web/src/features/roles/hooks/useRol.ts new file mode 100644 index 0000000..87bb3f2 --- /dev/null +++ b/src/web/src/features/roles/hooks/useRol.ts @@ -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), + }) +} diff --git a/src/web/src/features/roles/hooks/useRoles.ts b/src/web/src/features/roles/hooks/useRoles.ts new file mode 100644 index 0000000..68c3caf --- /dev/null +++ b/src/web/src/features/roles/hooks/useRoles.ts @@ -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, + }) +} diff --git a/src/web/src/features/roles/hooks/useUpdateRole.ts b/src/web/src/features/roles/hooks/useUpdateRole.ts new file mode 100644 index 0000000..b9fc025 --- /dev/null +++ b/src/web/src/features/roles/hooks/useUpdateRole.ts @@ -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] }) + }, + }) +} diff --git a/src/web/src/features/roles/pages/EditRolPage.tsx b/src/web/src/features/roles/pages/EditRolPage.tsx new file mode 100644 index 0000000..b5e7973 --- /dev/null +++ b/src/web/src/features/roles/pages/EditRolPage.tsx @@ -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 ( +
+ + + Editar rol + Modificá nombre, descripción o estado del rol. + + + {isLoading &&

Cargando...

} + {isError && ( + + + No se pudo cargar el rol. + + )} + {rol && navigate('/admin/roles')} />} +
+
+
+ ) +} diff --git a/src/web/src/features/roles/pages/NewRolPage.tsx b/src/web/src/features/roles/pages/NewRolPage.tsx new file mode 100644 index 0000000..3bcd68e --- /dev/null +++ b/src/web/src/features/roles/pages/NewRolPage.tsx @@ -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 ( +
+ + + Nuevo rol + + Creá un nuevo rol del sistema. El código es inmutable una vez creado. + + + + navigate('/admin/roles')} /> + + +
+ ) +} diff --git a/src/web/src/features/roles/pages/RolesPage.tsx b/src/web/src/features/roles/pages/RolesPage.tsx new file mode 100644 index 0000000..3741377 --- /dev/null +++ b/src/web/src/features/roles/pages/RolesPage.tsx @@ -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 ( +
+ + +
+ Roles del sistema + + Gestión de roles canónicos. Los roles inactivos no pueden asignarse a nuevos usuarios. + +
+ + + +
+ + + +
+
+ ) +} diff --git a/src/web/src/features/users/components/UserForm.tsx b/src/web/src/features/users/components/UserForm.tsx index c5ed7ee..792adc3 100644 --- a/src/web/src/features/users/components/UserForm.tsx +++ b/src/web/src/features/users/components/UserForm.tsx @@ -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 @@ -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({ resolver: zodResolver(userFormSchema), @@ -91,6 +87,7 @@ export function UserForm({ onSuccess }: UserFormProps) { } const backendError = resolveBackendError(error) + const disabled = isPending || rolesLoading return (
@@ -102,6 +99,15 @@ export function UserForm({ onSuccess }: UserFormProps) { )} + {rolesError && ( + + + + No se pudieron cargar los roles. Intentá refrescar la página. + + + )} + @@ -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" /> @@ -149,7 +155,7 @@ export function UserForm({ onSuccess }: UserFormProps) { Nombre - + @@ -163,7 +169,7 @@ export function UserForm({ onSuccess }: UserFormProps) { Apellido - + @@ -181,7 +187,7 @@ export function UserForm({ onSuccess }: UserFormProps) { {...field} type="email" autoComplete="off" - disabled={isPending} + disabled={disabled} placeholder="correo@ejemplo.com" /> @@ -199,14 +205,16 @@ export function UserForm({ onSuccess }: UserFormProps) { @@ -216,7 +224,7 @@ export function UserForm({ onSuccess }: UserFormProps) { )} /> - diff --git a/src/web/src/features/users/hooks/useRolesForSelect.ts b/src/web/src/features/users/hooks/useRolesForSelect.ts new file mode 100644 index 0000000..84a5b71 --- /dev/null +++ b/src/web/src/features/users/hooks/useRolesForSelect.ts @@ -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 } +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 1ea7daa..d43145d 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -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() { } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> } /> ) diff --git a/src/web/src/tests/features/roles/RolForm.test.tsx b/src/web/src/tests/features/roles/RolForm.test.tsx new file mode 100644 index 0000000..c28b5d2 --- /dev/null +++ b/src/web/src/tests/features/roles/RolForm.test.tsx @@ -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( + + + + + , + ) +} + +function renderEdit(initial: RolDto, onSuccess = vi.fn()) { + const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + return render( + + + + + , + ) +} + +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()) + }) +}) diff --git a/src/web/src/tests/features/roles/RolesList.test.tsx b/src/web/src/tests/features/roles/RolesList.test.tsx new file mode 100644 index 0000000..7d09221 --- /dev/null +++ b/src/web/src/tests/features/roles/RolesList.test.tsx @@ -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( + + + + + , + ) +} + +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) + }) + }) +}) diff --git a/src/web/src/tests/features/users/UserForm.test.tsx b/src/web/src/tests/features/users/UserForm.test.tsx index 89ab027..cd9f92a 100644 --- a/src/web/src/tests/features/users/UserForm.test.tsx +++ b/src/web/src/tests/features/users/UserForm.test.tsx @@ -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 }))