feat(web): UsersListPage — api client, hook, filters, table, pagination [UDT-008]

This commit is contained in:
2026-04-15 18:05:07 -03:00
parent d998d215e0
commit 9512f4125d
10 changed files with 718 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
import { axiosClient } from '@/api/axiosClient'
import type { PagedResult, UserListItem, UsuariosQuery } from '../types'
export async function listUsers(query: UsuariosQuery): Promise<PagedResult<UserListItem>> {
const params = new URLSearchParams()
if (query.page !== undefined) params.set('page', String(query.page))
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
if (query.rol !== undefined && query.rol !== '') params.set('rol', query.rol)
if (query.activo !== undefined) params.set('activo', String(query.activo))
if (query.search !== undefined && query.search !== '') params.set('search', query.search)
const response = await axiosClient.get<PagedResult<UserListItem>>('/api/v1/users', { params })
return response.data
}

View File

@@ -0,0 +1,69 @@
import { useState, useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
interface UsersFiltersProps {
onRolChange: (rol: string) => void
onActivoChange: (activo: boolean | undefined) => void
/** Called with the debounced search string (300ms) */
onSearchChange: (search: string) => void
}
const ROL_OPTIONS = [
{ value: '', label: 'Todos los roles' },
{ value: 'admin', label: 'Admin' },
{ value: 'cajero', label: 'Cajero' },
{ value: 'reportes', label: 'Reportes' },
]
export function UsersFilters({ onRolChange, onActivoChange, onSearchChange }: UsersFiltersProps) {
const [searchRaw, setSearchRaw] = useState('')
const debouncedSearch = useDebouncedValue(searchRaw, 300)
// Propagate debounced search to parent
useEffect(() => {
onSearchChange(debouncedSearch)
}, [debouncedSearch, onSearchChange])
return (
<div className="flex flex-wrap gap-3 items-center mb-4">
{/* Search input */}
<Input
type="text"
placeholder="Buscar por usuario, nombre, email..."
value={searchRaw}
onChange={(e) => setSearchRaw(e.target.value)}
className="max-w-xs"
aria-label="Buscar usuarios"
/>
{/* Rol select */}
<select
aria-label="Rol"
onChange={(e) => onRolChange(e.target.value)}
className="flex h-9 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"
>
{ROL_OPTIONS.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
{/* Activo filter */}
<select
aria-label="Estado"
onChange={(e) => {
const v = e.target.value
if (v === '') onActivoChange(undefined)
else onActivoChange(v === 'true')
}}
className="flex h-9 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"
>
<option value="">Todos</option>
<option value="true">Activos</option>
<option value="false">Inactivos</option>
</select>
</div>
)
}

View File

@@ -0,0 +1,73 @@
import type { UserListItem } from '../types'
import { Badge } from '@/components/ui/badge'
interface UsersTableProps {
rows: UserListItem[]
onRowClick: (user: UserListItem) => void
}
function formatDate(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('es-AR', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function UsersTable({ rows, onRowClick }: UsersTableProps) {
if (rows.length === 0) {
return (
<div className="py-12 text-center text-muted-foreground">
Sin resultados no se encontraron usuarios con los filtros seleccionados.
</div>
)
}
return (
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Usuario</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Nombre</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Email</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Rol</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Estado</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Último login</th>
</tr>
</thead>
<tbody>
{rows.map((u) => (
<tr
key={u.id}
onClick={() => onRowClick(u)}
className="border-b border-border last:border-0 hover:bg-accent/50 cursor-pointer transition-colors"
>
<td className="px-4 py-3 font-mono text-xs">{u.username}</td>
<td className="px-4 py-3">{`${u.nombre} ${u.apellido}`}</td>
<td className="px-4 py-3 text-muted-foreground">{u.email ?? '—'}</td>
<td className="px-4 py-3">
<Badge variant="secondary" className="capitalize">
{u.rol}
</Badge>
</td>
<td className="px-4 py-3">
{u.activo ? (
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Activo
</Badge>
) : (
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Inactivo
</Badge>
)}
</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(u.ultimoLogin)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query'
import { listUsers } from '../api/listUsers'
import type { UsuariosQuery } from '../types'
export const usersListQueryKey = (query: UsuariosQuery) => ['users', 'list', query] as const
export function useUsersList(query: UsuariosQuery) {
return useQuery({
queryKey: usersListQueryKey(query),
queryFn: () => listUsers(query),
staleTime: 15_000,
})
}

View File

@@ -0,0 +1,119 @@
import { useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { UsersTable } from '../components/UsersTable'
import { UsersFilters } from '../components/UsersFilters'
import { useUsersList } from '../hooks/useUsersList'
import type { UserListItem } from '../types'
export function UsersListPage() {
const navigate = useNavigate()
const [page, setPage] = useState(1)
const [rol, setRol] = useState<string>('')
const [activo, setActivo] = useState<boolean | undefined>(undefined)
const [search, setSearch] = useState<string>('')
const query = {
page,
pageSize: 20,
...(rol ? { rol } : {}),
...(activo !== undefined ? { activo } : {}),
...(search ? { search } : {}),
}
const { data, isLoading } = useUsersList(query)
const handleRolChange = useCallback(
(newRol: string) => {
setRol(newRol)
setPage(1)
},
[],
)
const handleActivoChange = useCallback(
(newActivo: boolean | undefined) => {
setActivo(newActivo)
setPage(1)
},
[],
)
const handleSearchChange = useCallback(
(newSearch: string) => {
setSearch(newSearch)
setPage(1)
},
[],
)
const handleRowClick = useCallback(
(user: UserListItem) => {
navigate(`/usuarios/${user.id}/editar`)
},
[navigate],
)
const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1
const hasPrev = page > 1
const hasNext = page < totalPages
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Usuarios</h1>
<Button onClick={() => navigate('/usuarios/nuevo')} size="sm">
Nuevo usuario
</Button>
</div>
<UsersFilters
onRolChange={handleRolChange}
onActivoChange={handleActivoChange}
onSearchChange={handleSearchChange}
/>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-md" />
))}
</div>
) : (
<UsersTable rows={data?.items ?? []} onRowClick={handleRowClick} />
)}
{/* Pagination */}
<div className="flex items-center justify-between pt-2">
<span className="text-sm text-muted-foreground">
{data ? `${data.total} usuario${data.total !== 1 ? 's' : ''}` : ''}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={!hasPrev}
onClick={() => setPage((p) => p - 1)}
aria-label="Anterior"
>
Anterior
</Button>
<span className="flex items-center px-2 text-sm text-muted-foreground">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasNext}
onClick={() => setPage((p) => p + 1)}
aria-label="Siguiente"
>
Siguiente
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
// UDT-008 — shared types for users feature
export interface UserListItem {
id: number
username: string
nombre: string
apellido: string
email: string | null
rol: string
activo: boolean
ultimoLogin: string | null // ISO datetime or null
}
export interface UserDetail {
id: number
username: string
nombre: string
apellido: string
email: string | null
rol: string
activo: boolean
mustChangePassword: boolean
ultimoLogin: string | null
fechaModificacion: string | null
}
export interface PagedResult<T> {
items: T[]
page: number
pageSize: number
total: number
}
export interface UsuariosQuery {
page?: number
pageSize?: number
rol?: string
activo?: boolean
search?: string
}
export interface UpdateUserPayload {
nombre: string
apellido: string
email: string | null
rol: string
activo: boolean
}

View File

@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react'
/**
* Returns a debounced version of the value.
* The debounced value only updates after `delay` ms have elapsed
* since the last change.
*/
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}

View File

@@ -0,0 +1,227 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import { UsersListPage } from '../../../features/users/pages/UsersListPage'
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,
}
function makeItems(n: number) {
return Array.from({ length: n }, (_, i) => ({
id: i + 1,
username: `user${i + 1}`,
nombre: `Nombre${i + 1}`,
apellido: `Apellido${i + 1}`,
email: `user${i + 1}@test.com`,
rol: 'cajero',
activo: true,
ultimoLogin: null,
}))
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
useAuthStore.getState().clearAuth()
vi.clearAllMocks()
})
afterAll(() => server.close())
function renderPage() {
useAuthStore.setState({ user: adminUser })
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/usuarios']}>
<Routes>
<Route path="/usuarios" element={<UsersListPage />} />
<Route path="/usuarios/:id/editar" element={<div>Edit Page</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('UsersListPage', () => {
it('renders 5 rows when API returns 5 items', async () => {
server.use(
http.get(`${API_URL}/api/v1/users`, () =>
HttpResponse.json({ items: makeItems(5), page: 1, pageSize: 20, total: 5 }),
),
)
renderPage()
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
// All 5 usernames visible
for (let i = 1; i <= 5; i++) {
expect(screen.getByText(`user${i}`)).toBeInTheDocument()
}
})
it('shows empty state when items is empty', async () => {
server.use(
http.get(`${API_URL}/api/v1/users`, () =>
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
),
)
renderPage()
await waitFor(() => expect(screen.getByText(/sin resultados|no se encontraron/i)).toBeInTheDocument())
})
it('prev button disabled on first page', async () => {
server.use(
http.get(`${API_URL}/api/v1/users`, () =>
HttpResponse.json({ items: makeItems(5), page: 1, pageSize: 20, total: 5 }),
),
)
renderPage()
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
const prevBtn = screen.getByRole('button', { name: /anterior|prev/i })
expect(prevBtn).toBeDisabled()
})
it('next button disabled when on last page', async () => {
server.use(
http.get(`${API_URL}/api/v1/users`, () =>
HttpResponse.json({ items: makeItems(5), page: 1, pageSize: 20, total: 5 }),
),
)
renderPage()
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
const nextBtn = screen.getByRole('button', { name: /siguiente|next/i })
expect(nextBtn).toBeDisabled()
})
it('next button enabled when more pages exist, click requests page 2', async () => {
const requests: string[] = []
server.use(
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
requests.push(request.url)
const url = new URL(request.url)
const page = parseInt(url.searchParams.get('page') ?? '1')
return HttpResponse.json({
items: makeItems(3),
page,
pageSize: 3,
total: 6,
})
}),
)
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
useAuthStore.setState({ user: adminUser })
render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<UsersListPage />
</MemoryRouter>
</QueryClientProvider>,
)
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
const nextBtn = screen.getByRole('button', { name: /siguiente|next/i })
expect(nextBtn).not.toBeDisabled()
await userEvent.click(nextBtn)
await waitFor(() => {
const page2Req = requests.find((u) => u.includes('page=2'))
expect(page2Req).toBeTruthy()
})
})
it('selecting rol filter adds querystring rol', async () => {
const requests: string[] = []
server.use(
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
requests.push(request.url)
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
}),
)
renderPage()
await waitFor(() => expect(requests.length).toBeGreaterThan(0))
const rolSelect = screen.getByRole('combobox', { name: /rol/i })
await userEvent.selectOptions(rolSelect, 'admin')
await waitFor(() => {
const filtered = requests.find((u) => u.includes('rol=admin'))
expect(filtered).toBeTruthy()
})
})
it('typing in search input triggers request with search param (debounced)', async () => {
const requests: string[] = []
server.use(
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
requests.push(request.url)
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
}),
)
renderPage()
await waitFor(() => expect(requests.length).toBeGreaterThan(0))
const searchInput = screen.getByPlaceholderText(/buscar/i)
// Use fireEvent to type quickly without delay — then wait for debounce naturally
await userEvent.type(searchInput, 'juan')
// After debounce (300ms + render cycles), should include search param
await waitFor(
() => {
const searched = requests.find((u) => u.includes('search='))
expect(searched).toBeTruthy()
},
{ timeout: 3000 },
)
}, 10000)
it('click row navigates to edit page', async () => {
server.use(
http.get(`${API_URL}/api/v1/users`, () =>
HttpResponse.json({ items: makeItems(2), page: 1, pageSize: 20, total: 2 }),
),
)
renderPage()
// Wait for data to load
await waitFor(() => expect(screen.getByText('user1')).toBeInTheDocument())
// Click on the username cell which is inside the row
const usernameCell = screen.getByText('user1')
await userEvent.click(usernameCell)
expect(mockNavigate).toHaveBeenCalledWith('/usuarios/1/editar')
})
})

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { listUsers } from '../../../features/users/api/listUsers'
const API_URL = 'http://localhost:5000'
const mockPage1 = {
items: [
{ id: 1, username: 'admin', nombre: 'Admin', apellido: 'Sistema', email: null, rol: 'admin', activo: true, ultimoLogin: null },
{ id: 2, username: 'cajero1', nombre: 'Juan', apellido: 'Pérez', email: 'j@x.com', rol: 'cajero', activo: true, ultimoLogin: '2026-04-10T10:00:00Z' },
],
page: 1,
pageSize: 20,
total: 2,
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('listUsers api client', () => {
it('calls GET /api/v1/users and returns PagedResult', async () => {
server.use(
http.get(`${API_URL}/api/v1/users`, () => HttpResponse.json(mockPage1)),
)
const result = await listUsers({})
expect(result.items).toHaveLength(2)
expect(result.page).toBe(1)
expect(result.pageSize).toBe(20)
expect(result.total).toBe(2)
})
it('passes query params: page, pageSize, rol, activo, search', async () => {
let capturedUrl: string | null = null
server.use(
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
}),
)
await listUsers({ page: 2, pageSize: 10, rol: 'cajero', activo: false, search: 'juan' })
expect(capturedUrl).toContain('page=2')
expect(capturedUrl).toContain('pageSize=10')
expect(capturedUrl).toContain('rol=cajero')
expect(capturedUrl).toContain('activo=false')
expect(capturedUrl).toContain('search=juan')
})
it('omits undefined params from querystring', async () => {
let capturedUrl: string | null = null
server.use(
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
}),
)
await listUsers({ page: 1 })
expect(capturedUrl).not.toContain('rol=')
expect(capturedUrl).not.toContain('activo=')
expect(capturedUrl).not.toContain('search=')
})
})

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import { useUsersList } from '../../../features/users/hooks/useUsersList'
const API_URL = 'http://localhost:5000'
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
function createWrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: qc }, children)
}
describe('useUsersList', () => {
it('fetches page 1 by default', async () => {
server.use(
http.get(`${API_URL}/api/v1/users`, () =>
HttpResponse.json({ items: [{ id: 1, username: 'admin', nombre: 'Admin', apellido: 'S', email: null, rol: 'admin', activo: true, ultimoLogin: null }], page: 1, pageSize: 20, total: 1 }),
),
)
const { result } = renderHook(() => useUsersList({}), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.page).toBe(1)
expect(result.current.data?.items).toHaveLength(1)
})
it('passes rol filter in query string', async () => {
let capturedUrl: string | null = null
server.use(
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
}),
)
const { result } = renderHook(() => useUsersList({ rol: 'admin' }), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(capturedUrl).toContain('rol=admin')
})
it('passes activo filter', async () => {
let capturedUrl: string | null = null
server.use(
http.get(`${API_URL}/api/v1/users`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
}),
)
const { result } = renderHook(() => useUsersList({ activo: false }), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(capturedUrl).toContain('activo=false')
})
})