feat(web): UserDetailPage + UserEditPage — get/update/deactivate/reactivate hooks y pages [UDT-008]

This commit is contained in:
2026-04-15 18:06:54 -03:00
parent 9512f4125d
commit 64e0a8b5fb
13 changed files with 635 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
import { axiosClient } from '@/api/axiosClient'
import type { UserDetail } from '../types'
export async function deactivateUser(id: number): Promise<UserDetail> {
const response = await axiosClient.patch<UserDetail>(`/api/v1/users/${id}/deactivate`)
return response.data
}

View File

@@ -0,0 +1,7 @@
import { axiosClient } from '@/api/axiosClient'
import type { UserDetail } from '../types'
export async function getUser(id: number): Promise<UserDetail> {
const response = await axiosClient.get<UserDetail>(`/api/v1/users/${id}`)
return response.data
}

View File

@@ -0,0 +1,7 @@
import { axiosClient } from '@/api/axiosClient'
import type { UserDetail } from '../types'
export async function reactivateUser(id: number): Promise<UserDetail> {
const response = await axiosClient.patch<UserDetail>(`/api/v1/users/${id}/reactivate`)
return response.data
}

View File

@@ -0,0 +1,13 @@
import { axiosClient } from '@/api/axiosClient'
import type { UserDetail, UpdateUserPayload } from '../types'
export async function updateUser(id: number, payload: UpdateUserPayload): Promise<UserDetail> {
const response = await axiosClient.put<UserDetail>(`/api/v1/users/${id}`, {
nombre: payload.nombre,
apellido: payload.apellido,
email: payload.email,
rol: payload.rol,
activo: payload.activo,
})
return response.data
}

View File

@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { deactivateUser } from '../api/deactivateUser'
export function useDeactivateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => deactivateUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}

View File

@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { reactivateUser } from '../api/reactivateUser'
export function useReactivateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => reactivateUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}

View File

@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateUser } from '../api/updateUser'
import type { UpdateUserPayload } from '../types'
export function useUpdateUser(userId: number) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: UpdateUserPayload) => updateUser(userId, payload),
onSuccess: () => {
// Invalidate both the detail and the list
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}

View File

@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query'
import { getUser } from '../api/getUser'
export const userQueryKey = (id: number) => ['users', id] as const
export function useUser(id: number) {
return useQuery({
queryKey: userQueryKey(id),
queryFn: () => getUser(id),
staleTime: 15_000,
enabled: id > 0,
})
}

View File

@@ -0,0 +1,96 @@
import { useNavigate, useParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useUser } from '../hooks/useUser'
import { useDeactivateUser } from '../hooks/useDeactivateUser'
import { useReactivateUser } from '../hooks/useReactivateUser'
export function UserDetailPage() {
const { id } = useParams<{ id: string }>()
const userId = Number(id)
const navigate = useNavigate()
const { data: user, isLoading } = useUser(userId)
const { mutate: deactivate, isPending: deactivating } = useDeactivateUser()
const { mutate: reactivate, isPending: reactivating } = useReactivateUser()
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<span className="text-muted-foreground">Cargando...</span>
</div>
)
}
if (!user) {
return (
<div className="py-12 text-center text-muted-foreground">
Usuario no encontrado.
</div>
)
}
const busy = deactivating || reactivating
return (
<div className="max-w-xl space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">
{user.nombre} {user.apellido}
</h1>
<Button variant="ghost" size="sm" onClick={() => navigate('/usuarios')}>
Volver
</Button>
</div>
<div className="rounded-md border border-border p-4 space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Usuario</span>
<span className="font-mono">{user.username}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Email</span>
<span>{user.email ?? '—'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Rol</span>
<Badge variant="secondary" className="capitalize">{user.rol}</Badge>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Estado</span>
{user.activo
? <Badge variant="secondary" className="bg-green-100 text-green-800">Activo</Badge>
: <Badge variant="secondary" className="bg-red-100 text-red-800">Inactivo</Badge>
}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => navigate(`/usuarios/${userId}/editar`)}
>
Editar
</Button>
{user.activo ? (
<Button
variant="outline"
disabled={busy}
onClick={() => deactivate(userId)}
>
{deactivating ? 'Desactivando...' : 'Desactivar'}
</Button>
) : (
<Button
variant="outline"
disabled={busy}
onClick={() => reactivate(userId)}
>
{reactivating ? 'Reactivando...' : 'Reactivar'}
</Button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,227 @@
import { useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
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 { useUser } from '../hooks/useUser'
import { useUpdateUser } from '../hooks/useUpdateUser'
const editSchema = 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().min(1, 'Seleccioná un rol válido'),
activo: z.boolean(),
})
type EditFormValues = z.infer<typeof editSchema>
function resolveBackendError(err: unknown): string | null {
if (!err) return null
if (isAxiosError(err) && err.response?.data) {
const data = err.response.data as { title?: string; error?: string; message?: string }
if (data.title === 'last-admin-lockout' || data.error === 'last-admin-lockout') {
return 'No podés cambiar el rol o desactivar al último administrador activo'
}
return data.message ?? data.error ?? 'Error al actualizar el usuario'
}
return 'Error al actualizar el usuario'
}
export function UserEditPage() {
const { id } = useParams<{ id: string }>()
const userId = Number(id)
const navigate = useNavigate()
const { data: user, isLoading } = useUser(userId)
const { mutate, isPending, error } = useUpdateUser(userId)
const form = useForm<EditFormValues>({
resolver: zodResolver(editSchema),
defaultValues: {
nombre: '',
apellido: '',
email: '',
rol: '',
activo: true,
},
})
// Prefill form when user data loads
useEffect(() => {
if (user) {
form.reset({
nombre: user.nombre,
apellido: user.apellido,
email: user.email ?? '',
rol: user.rol,
activo: user.activo,
})
}
}, [user, form])
function handleSubmit(values: EditFormValues) {
mutate(
{
nombre: values.nombre,
apellido: values.apellido,
email: values.email || null,
rol: values.rol,
activo: values.activo,
},
{
onSuccess: () => {
navigate('/usuarios')
},
},
)
}
const backendError = resolveBackendError(error)
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<span className="text-muted-foreground">Cargando...</span>
</div>
)
}
if (!user) {
return (
<div className="py-12 text-center text-muted-foreground">
Usuario no encontrado.
</div>
)
}
return (
<div className="max-w-xl space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Editar Usuario</h1>
<Button variant="ghost" size="sm" onClick={() => navigate('/usuarios')}>
Volver
</Button>
</div>
{/* Username — display only, not editable */}
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Usuario</p>
<p className="text-sm font-mono bg-muted rounded px-3 py-2">{user.username}</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
{backendError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{backendError}</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="nombre"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre</FormLabel>
<FormControl>
<Input {...field} disabled={isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apellido"
render={({ field }) => (
<FormItem>
<FormLabel>Apellido</FormLabel>
<FormControl>
<Input {...field} disabled={isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email (opcional)</FormLabel>
<FormControl>
<Input {...field} type="email" disabled={isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="rol"
render={({ field }) => (
<FormItem>
<FormLabel>Rol</FormLabel>
<FormControl>
<select
{...field}
disabled={isPending}
aria-label="Rol"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="admin">Admin</option>
<option value="cajero">Cajero</option>
<option value="reportes">Reportes</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="activo"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3">
<FormControl>
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
disabled={isPending}
aria-label="Activo"
className="h-4 w-4"
/>
</FormControl>
<FormLabel className="!mt-0">Activo</FormLabel>
</FormItem>
)}
/>
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? 'Guardando...' : 'Guardar cambios'}
</Button>
</form>
</Form>
</div>
)
}

View File

@@ -0,0 +1,141 @@
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, Routes, Route } from 'react-router-dom'
import { UserEditPage } from '../../../features/users/pages/UserEditPage'
import { useAuthStore } from '../../../stores/authStore'
const API_URL = 'http://localhost:5000'
const mockNavigate = vi.fn()
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router-dom')>()
return { ...actual, useNavigate: () => mockNavigate }
})
const adminUser = {
id: 1, username: 'admin', nombre: 'Admin', rol: 'admin',
permisos: ['administracion:usuarios:gestionar'],
mustChangePassword: false,
}
const mockUserDetail = {
id: 5,
username: 'cajero1',
nombre: 'Juan',
apellido: 'Pérez',
email: 'j@x.com',
rol: 'cajero',
activo: true,
mustChangePassword: false,
ultimoLogin: null,
fechaModificacion: null,
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
useAuthStore.getState().clearAuth()
vi.clearAllMocks()
})
afterAll(() => server.close())
function renderEditPage(userId = 5) {
useAuthStore.setState({ user: adminUser })
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[`/usuarios/${userId}/editar`]}>
<Routes>
<Route path="/usuarios/:id/editar" element={<UserEditPage />} />
<Route path="/usuarios" element={<div>Users List</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('UserEditPage', () => {
it('prefills form with user data', async () => {
server.use(
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
)
renderEditPage()
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
expect(screen.getByDisplayValue('Pérez')).toBeInTheDocument()
expect(screen.getByDisplayValue('j@x.com')).toBeInTheDocument()
})
it('username field is displayed but not an editable input', async () => {
server.use(
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
)
renderEditPage()
await waitFor(() => expect(screen.getByText('cajero1')).toBeInTheDocument())
// Username should NOT be an editable input
const inputs = screen.queryAllByRole('textbox')
const usernameInput = inputs.find((el) => (el as HTMLInputElement).value === 'cajero1')
expect(usernameInput).toBeUndefined()
})
it('submit calls PUT with correct payload then navigates to /usuarios', async () => {
let capturedBody: unknown = null
server.use(
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
http.put(`${API_URL}/api/v1/users/5`, async ({ request }) => {
capturedBody = await request.json()
return HttpResponse.json({ ...mockUserDetail, nombre: 'Pedro' })
}),
)
renderEditPage()
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
// Clear and update nombre
const nombreInput = screen.getByDisplayValue('Juan')
await userEvent.clear(nombreInput)
await userEvent.type(nombreInput, 'Pedro')
// Submit
await userEvent.click(screen.getByRole('button', { name: /guardar|actualizar|save/i }))
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/usuarios'))
expect(capturedBody).toMatchObject({ nombre: 'Pedro' })
})
it('shows last-admin-lockout error message on 400', async () => {
server.use(
http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockUserDetail)),
http.put(`${API_URL}/api/v1/users/5`, () =>
HttpResponse.json(
{ title: 'last-admin-lockout', status: 400 },
{ status: 400 },
),
),
)
renderEditPage()
await waitFor(() => expect(screen.getByDisplayValue('Juan')).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /guardar|actualizar|save/i }))
await waitFor(() =>
expect(screen.getByText(/último administrador|last.admin.lockout/i)).toBeInTheDocument(),
)
// Should NOT navigate
expect(mockNavigate).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { getUser } from '../../../features/users/api/getUser'
const API_URL = 'http://localhost:5000'
const mockDetail = {
id: 5,
username: 'cajero1',
nombre: 'Juan',
apellido: 'Pérez',
email: 'j@x.com',
rol: 'cajero',
activo: true,
mustChangePassword: false,
ultimoLogin: '2026-04-10T10:00:00Z',
fechaModificacion: null,
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('getUser api client', () => {
it('calls GET /api/v1/users/:id and returns UserDetail', async () => {
server.use(http.get(`${API_URL}/api/v1/users/5`, () => HttpResponse.json(mockDetail)))
const result = await getUser(5)
expect(result.id).toBe(5)
expect(result.username).toBe('cajero1')
expect(result.mustChangePassword).toBe(false)
})
})

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { updateUser } from '../../../features/users/api/updateUser'
const API_URL = 'http://localhost:5000'
const mockDetail = {
id: 5,
username: 'cajero1',
nombre: 'Pedro',
apellido: 'Gómez',
email: 'new@x.com',
rol: 'cajero',
activo: true,
mustChangePassword: false,
ultimoLogin: null,
fechaModificacion: '2026-04-15T18:00:00Z',
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('updateUser api client', () => {
it('calls PUT /api/v1/users/:id with payload and returns updated UserDetail', async () => {
let capturedBody: unknown = null
server.use(
http.put(`${API_URL}/api/v1/users/5`, async ({ request }) => {
capturedBody = await request.json()
return HttpResponse.json(mockDetail)
}),
)
const result = await updateUser(5, {
nombre: 'Pedro',
apellido: 'Gómez',
email: 'new@x.com',
rol: 'cajero',
activo: true,
})
expect(result.nombre).toBe('Pedro')
expect(capturedBody).toMatchObject({ nombre: 'Pedro', apellido: 'Gómez', email: 'new@x.com' })
})
})