diff --git a/src/web/src/features/users/api/listUsers.ts b/src/web/src/features/users/api/listUsers.ts new file mode 100644 index 0000000..b778e9e --- /dev/null +++ b/src/web/src/features/users/api/listUsers.ts @@ -0,0 +1,15 @@ +import { axiosClient } from '@/api/axiosClient' +import type { PagedResult, UserListItem, UsuariosQuery } from '../types' + +export async function listUsers(query: UsuariosQuery): Promise> { + 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>('/api/v1/users', { params }) + return response.data +} diff --git a/src/web/src/features/users/components/UsersFilters.tsx b/src/web/src/features/users/components/UsersFilters.tsx new file mode 100644 index 0000000..e21c187 --- /dev/null +++ b/src/web/src/features/users/components/UsersFilters.tsx @@ -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 ( +
+ {/* Search input */} + setSearchRaw(e.target.value)} + className="max-w-xs" + aria-label="Buscar usuarios" + /> + + {/* Rol select */} + + + {/* Activo filter */} + +
+ ) +} diff --git a/src/web/src/features/users/components/UsersTable.tsx b/src/web/src/features/users/components/UsersTable.tsx new file mode 100644 index 0000000..11f6151 --- /dev/null +++ b/src/web/src/features/users/components/UsersTable.tsx @@ -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 ( +
+ Sin resultados — no se encontraron usuarios con los filtros seleccionados. +
+ ) + } + + return ( +
+ + + + + + + + + + + + + {rows.map((u) => ( + onRowClick(u)} + className="border-b border-border last:border-0 hover:bg-accent/50 cursor-pointer transition-colors" + > + + + + + + + + ))} + +
UsuarioNombreEmailRolEstadoÚltimo login
{u.username}{`${u.nombre} ${u.apellido}`}{u.email ?? '—'} + + {u.rol} + + + {u.activo ? ( + + Activo + + ) : ( + + Inactivo + + )} + {formatDate(u.ultimoLogin)}
+
+ ) +} diff --git a/src/web/src/features/users/hooks/useUsersList.ts b/src/web/src/features/users/hooks/useUsersList.ts new file mode 100644 index 0000000..3592207 --- /dev/null +++ b/src/web/src/features/users/hooks/useUsersList.ts @@ -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, + }) +} diff --git a/src/web/src/features/users/pages/UsersListPage.tsx b/src/web/src/features/users/pages/UsersListPage.tsx new file mode 100644 index 0000000..5aeb4df --- /dev/null +++ b/src/web/src/features/users/pages/UsersListPage.tsx @@ -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('') + const [activo, setActivo] = useState(undefined) + const [search, setSearch] = useState('') + + 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 ( +
+
+

Usuarios

+ +
+ + + + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} + + {/* Pagination */} +
+ + {data ? `${data.total} usuario${data.total !== 1 ? 's' : ''}` : ''} + +
+ + + {page} / {totalPages} + + +
+
+
+ ) +} diff --git a/src/web/src/features/users/types.ts b/src/web/src/features/users/types.ts new file mode 100644 index 0000000..b2a1732 --- /dev/null +++ b/src/web/src/features/users/types.ts @@ -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 { + 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 +} diff --git a/src/web/src/hooks/useDebouncedValue.ts b/src/web/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..76df41a --- /dev/null +++ b/src/web/src/hooks/useDebouncedValue.ts @@ -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(value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay) + return () => clearTimeout(timer) + }, [value, delay]) + + return debounced +} diff --git a/src/web/src/tests/features/users/UsersListPage.test.tsx b/src/web/src/tests/features/users/UsersListPage.test.tsx new file mode 100644 index 0000000..5d8da00 --- /dev/null +++ b/src/web/src/tests/features/users/UsersListPage.test.tsx @@ -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() + 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( + + + + } /> + Edit Page} /> + + + , + ) +} + +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( + + + + + , + ) + + 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') + }) +}) diff --git a/src/web/src/tests/features/users/listUsers.test.ts b/src/web/src/tests/features/users/listUsers.test.ts new file mode 100644 index 0000000..7273271 --- /dev/null +++ b/src/web/src/tests/features/users/listUsers.test.ts @@ -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=') + }) +}) diff --git a/src/web/src/tests/features/users/useUsersList.test.ts b/src/web/src/tests/features/users/useUsersList.test.ts new file mode 100644 index 0000000..57418d2 --- /dev/null +++ b/src/web/src/tests/features/users/useUsersList.test.ts @@ -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') + }) +})