From 6b946f608035c4b3bba18f7d35e91367c2e0079b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 19:28:30 -0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20Medios=20+=20Secciones=20admin=20U?= =?UTF-8?q?I=20+=20hooks=20+=20routing=20=E2=80=94=20ADM-001=20B7+B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API clients + TanStack Query hooks for medios and secciones - CRUD pages: List, Create, Edit, Detail for both entities - Components: MediosTable, MedioForm, DeactivateMedioModal, SeccionesTable, SeccionForm, DeactivateSeccionModal, SeccionesFilters - TipoMedio enum (int→label) and TipoSeccion display helpers - CanPerform permission gates: administracion:medios:gestionar, administracion:secciones:gestionar - Routes /admin/medios/** and /admin/secciones/** in router.tsx - Sidebar items (Newspaper + Columns3 icons) with requiredPermission - Vitest+RTL+MSW tests: 11 test files, 38 new cases — 207 pass total --- src/web/src/components/layout/AppSidebar.tsx | 14 ++ .../src/features/medios/api/createMedio.ts | 7 + .../features/medios/api/deactivateMedio.ts | 5 + src/web/src/features/medios/api/getMedio.ts | 7 + src/web/src/features/medios/api/listMedios.ts | 17 ++ .../features/medios/api/reactivateMedio.ts | 5 + .../src/features/medios/api/updateMedio.ts | 7 + .../components/DeactivateMedioModal.tsx | 65 ++++++ .../features/medios/components/MedioForm.tsx | 188 +++++++++++++++++ .../medios/components/MediosTable.tsx | 103 +++++++++ .../features/medios/hooks/useCreateMedio.ts | 13 ++ .../medios/hooks/useDeactivateMedio.ts | 12 ++ src/web/src/features/medios/hooks/useMedio.ts | 11 + .../features/medios/hooks/useMediosList.ts | 13 ++ .../medios/hooks/useReactivateMedio.ts | 12 ++ .../features/medios/hooks/useUpdateMedio.ts | 13 ++ .../features/medios/pages/CreateMedioPage.tsx | 50 +++++ .../features/medios/pages/EditMedioPage.tsx | 81 ++++++++ .../features/medios/pages/MedioDetailPage.tsx | 97 +++++++++ .../features/medios/pages/MediosListPage.tsx | 138 +++++++++++++ src/web/src/features/medios/tipoMedio.ts | 18 ++ src/web/src/features/medios/types.ts | 58 ++++++ .../features/secciones/api/createSeccion.ts | 7 + .../secciones/api/deactivateSeccion.ts | 5 + .../src/features/secciones/api/getSeccion.ts | 7 + .../features/secciones/api/listSecciones.ts | 18 ++ .../secciones/api/reactivateSeccion.ts | 5 + .../features/secciones/api/updateSeccion.ts | 7 + .../components/DeactivateSeccionModal.tsx | 65 ++++++ .../secciones/components/SeccionForm.tsx | 195 ++++++++++++++++++ .../secciones/components/SeccionesFilters.tsx | 90 ++++++++ .../secciones/components/SeccionesTable.tsx | 103 +++++++++ .../secciones/hooks/useCreateSeccion.ts | 13 ++ .../secciones/hooks/useDeactivateSeccion.ts | 12 ++ .../secciones/hooks/useReactivateSeccion.ts | 12 ++ .../features/secciones/hooks/useSeccion.ts | 11 + .../secciones/hooks/useSeccionesList.ts | 13 ++ .../secciones/hooks/useUpdateSeccion.ts | 13 ++ .../secciones/pages/CreateSeccionPage.tsx | 50 +++++ .../secciones/pages/EditSeccionPage.tsx | 80 +++++++ .../secciones/pages/SeccionDetailPage.tsx | 99 +++++++++ .../secciones/pages/SeccionesListPage.tsx | 114 ++++++++++ src/web/src/features/secciones/tipoSeccion.ts | 12 ++ src/web/src/features/secciones/types.ts | 60 ++++++ src/web/src/router.tsx | 76 +++++++ .../features/medios/CreateMedioPage.test.tsx | 96 +++++++++ .../medios/DeactivateMedioModal.test.tsx | 91 ++++++++ .../features/medios/EditMedioPage.test.tsx | 112 ++++++++++ .../tests/features/medios/MedioForm.test.tsx | 110 ++++++++++ .../features/medios/MediosListPage.test.tsx | 168 +++++++++++++++ .../secciones/DeactivateSeccionModal.test.tsx | 74 +++++++ .../features/secciones/SeccionForm.test.tsx | 136 ++++++++++++ .../secciones/SeccionesFilters.test.tsx | 103 +++++++++ .../secciones/SeccionesListPage.test.tsx | 166 +++++++++++++++ 54 files changed, 3057 insertions(+) create mode 100644 src/web/src/features/medios/api/createMedio.ts create mode 100644 src/web/src/features/medios/api/deactivateMedio.ts create mode 100644 src/web/src/features/medios/api/getMedio.ts create mode 100644 src/web/src/features/medios/api/listMedios.ts create mode 100644 src/web/src/features/medios/api/reactivateMedio.ts create mode 100644 src/web/src/features/medios/api/updateMedio.ts create mode 100644 src/web/src/features/medios/components/DeactivateMedioModal.tsx create mode 100644 src/web/src/features/medios/components/MedioForm.tsx create mode 100644 src/web/src/features/medios/components/MediosTable.tsx create mode 100644 src/web/src/features/medios/hooks/useCreateMedio.ts create mode 100644 src/web/src/features/medios/hooks/useDeactivateMedio.ts create mode 100644 src/web/src/features/medios/hooks/useMedio.ts create mode 100644 src/web/src/features/medios/hooks/useMediosList.ts create mode 100644 src/web/src/features/medios/hooks/useReactivateMedio.ts create mode 100644 src/web/src/features/medios/hooks/useUpdateMedio.ts create mode 100644 src/web/src/features/medios/pages/CreateMedioPage.tsx create mode 100644 src/web/src/features/medios/pages/EditMedioPage.tsx create mode 100644 src/web/src/features/medios/pages/MedioDetailPage.tsx create mode 100644 src/web/src/features/medios/pages/MediosListPage.tsx create mode 100644 src/web/src/features/medios/tipoMedio.ts create mode 100644 src/web/src/features/medios/types.ts create mode 100644 src/web/src/features/secciones/api/createSeccion.ts create mode 100644 src/web/src/features/secciones/api/deactivateSeccion.ts create mode 100644 src/web/src/features/secciones/api/getSeccion.ts create mode 100644 src/web/src/features/secciones/api/listSecciones.ts create mode 100644 src/web/src/features/secciones/api/reactivateSeccion.ts create mode 100644 src/web/src/features/secciones/api/updateSeccion.ts create mode 100644 src/web/src/features/secciones/components/DeactivateSeccionModal.tsx create mode 100644 src/web/src/features/secciones/components/SeccionForm.tsx create mode 100644 src/web/src/features/secciones/components/SeccionesFilters.tsx create mode 100644 src/web/src/features/secciones/components/SeccionesTable.tsx create mode 100644 src/web/src/features/secciones/hooks/useCreateSeccion.ts create mode 100644 src/web/src/features/secciones/hooks/useDeactivateSeccion.ts create mode 100644 src/web/src/features/secciones/hooks/useReactivateSeccion.ts create mode 100644 src/web/src/features/secciones/hooks/useSeccion.ts create mode 100644 src/web/src/features/secciones/hooks/useSeccionesList.ts create mode 100644 src/web/src/features/secciones/hooks/useUpdateSeccion.ts create mode 100644 src/web/src/features/secciones/pages/CreateSeccionPage.tsx create mode 100644 src/web/src/features/secciones/pages/EditSeccionPage.tsx create mode 100644 src/web/src/features/secciones/pages/SeccionDetailPage.tsx create mode 100644 src/web/src/features/secciones/pages/SeccionesListPage.tsx create mode 100644 src/web/src/features/secciones/tipoSeccion.ts create mode 100644 src/web/src/features/secciones/types.ts create mode 100644 src/web/src/tests/features/medios/CreateMedioPage.test.tsx create mode 100644 src/web/src/tests/features/medios/DeactivateMedioModal.test.tsx create mode 100644 src/web/src/tests/features/medios/EditMedioPage.test.tsx create mode 100644 src/web/src/tests/features/medios/MedioForm.test.tsx create mode 100644 src/web/src/tests/features/medios/MediosListPage.test.tsx create mode 100644 src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx create mode 100644 src/web/src/tests/features/secciones/SeccionForm.test.tsx create mode 100644 src/web/src/tests/features/secciones/SeccionesFilters.test.tsx create mode 100644 src/web/src/tests/features/secciones/SeccionesListPage.test.tsx diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 7263153..5516319 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -12,6 +12,8 @@ import { FileClock, PanelLeftClose, PanelLeftOpen, + Newspaper, + Columns3, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' @@ -47,6 +49,18 @@ const adminItems: NavItem[] = [ icon: FileClock, requiredPermission: 'administracion:auditoria:ver', }, + { + label: 'Medios', + href: '/admin/medios', + icon: Newspaper, + requiredPermission: 'administracion:medios:gestionar', + }, + { + label: 'Secciones', + href: '/admin/secciones', + icon: Columns3, + requiredPermission: 'administracion:secciones:gestionar', + }, ] interface SidebarNavProps { diff --git a/src/web/src/features/medios/api/createMedio.ts b/src/web/src/features/medios/api/createMedio.ts new file mode 100644 index 0000000..9809f3b --- /dev/null +++ b/src/web/src/features/medios/api/createMedio.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { CreateMedioRequest, MedioCreated } from '../types' + +export async function createMedio(payload: CreateMedioRequest): Promise { + const response = await axiosClient.post('/api/v1/admin/medios', payload) + return response.data +} diff --git a/src/web/src/features/medios/api/deactivateMedio.ts b/src/web/src/features/medios/api/deactivateMedio.ts new file mode 100644 index 0000000..d893ce8 --- /dev/null +++ b/src/web/src/features/medios/api/deactivateMedio.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function deactivateMedio(id: number): Promise { + await axiosClient.post(`/api/v1/admin/medios/${id}/deactivate`) +} diff --git a/src/web/src/features/medios/api/getMedio.ts b/src/web/src/features/medios/api/getMedio.ts new file mode 100644 index 0000000..a0c10a6 --- /dev/null +++ b/src/web/src/features/medios/api/getMedio.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { MedioDetail } from '../types' + +export async function getMedio(id: number): Promise { + const response = await axiosClient.get(`/api/v1/admin/medios/${id}`) + return response.data +} diff --git a/src/web/src/features/medios/api/listMedios.ts b/src/web/src/features/medios/api/listMedios.ts new file mode 100644 index 0000000..75df9a9 --- /dev/null +++ b/src/web/src/features/medios/api/listMedios.ts @@ -0,0 +1,17 @@ +import { axiosClient } from '@/api/axiosClient' +import type { MedioListItem, MediosQuery, PagedResult } from '../types' + +export async function listMedios(query: MediosQuery): 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.activo !== undefined) params.set('activo', String(query.activo)) + if (query.tipo !== undefined) params.set('tipo', String(query.tipo)) + if (query.q !== undefined && query.q !== '') params.set('q', query.q) + + const response = await axiosClient.get>( + '/api/v1/admin/medios', + { params }, + ) + return response.data +} diff --git a/src/web/src/features/medios/api/reactivateMedio.ts b/src/web/src/features/medios/api/reactivateMedio.ts new file mode 100644 index 0000000..ba485f4 --- /dev/null +++ b/src/web/src/features/medios/api/reactivateMedio.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function reactivateMedio(id: number): Promise { + await axiosClient.post(`/api/v1/admin/medios/${id}/reactivate`) +} diff --git a/src/web/src/features/medios/api/updateMedio.ts b/src/web/src/features/medios/api/updateMedio.ts new file mode 100644 index 0000000..fcedcfe --- /dev/null +++ b/src/web/src/features/medios/api/updateMedio.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { MedioDetail, UpdateMedioRequest } from '../types' + +export async function updateMedio(id: number, payload: UpdateMedioRequest): Promise { + const response = await axiosClient.put(`/api/v1/admin/medios/${id}`, payload) + return response.data +} diff --git a/src/web/src/features/medios/components/DeactivateMedioModal.tsx b/src/web/src/features/medios/components/DeactivateMedioModal.tsx new file mode 100644 index 0000000..c4faa4d --- /dev/null +++ b/src/web/src/features/medios/components/DeactivateMedioModal.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { useDeactivateMedio } from '../hooks/useDeactivateMedio' +import { useReactivateMedio } from '../hooks/useReactivateMedio' + +interface DeactivateMedioModalProps { + medioId: number + medioNombre: string + activo: boolean +} + +export function DeactivateMedioModal({ medioId, medioNombre, activo }: DeactivateMedioModalProps) { + const [open, setOpen] = useState(false) + const { mutate: deactivate, isPending: deactivating } = useDeactivateMedio() + const { mutate: reactivate, isPending: reactivating } = useReactivateMedio() + + const isPending = deactivating || reactivating + + function handleConfirm() { + if (activo) { + deactivate(medioId, { onSuccess: () => setOpen(false) }) + } else { + reactivate(medioId, { onSuccess: () => setOpen(false) }) + } + } + + return ( + + + + + + + + {activo ? 'Desactivar medio' : 'Reactivar medio'} + + + {activo + ? `¿Confirmás que querés desactivar el medio "${medioNombre}"? El medio no podrá usarse hasta que sea reactivado.` + : `¿Confirmás que querés reactivar el medio "${medioNombre}"?`} + + + + Cancelar + + {isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'} + + + + + ) +} diff --git a/src/web/src/features/medios/components/MedioForm.tsx b/src/web/src/features/medios/components/MedioForm.tsx new file mode 100644 index 0000000..e802a76 --- /dev/null +++ b/src/web/src/features/medios/components/MedioForm.tsx @@ -0,0 +1,188 @@ +import { useEffect } from 'react' +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 { TIPO_MEDIO_OPTIONS } from '../tipoMedio' +import type { MedioDetail } from '../types' + +const medioFormSchema = z.object({ + codigo: z + .string() + .min(1, 'El código es requerido') + .max(20, 'Máximo 20 caracteres'), + nombre: z + .string() + .min(1, 'El nombre es requerido') + .max(100, 'Máximo 100 caracteres'), + tipo: z.coerce.number().refine((v) => v >= 1, 'Seleccioná un tipo válido'), + plataformaEmpresaId: z + .union([z.coerce.number().int().positive('Debe ser un número positivo'), z.literal('')]) + .optional() + .transform((v) => (v === '' || v === undefined ? null : Number(v))), +}) + +export type MedioFormValues = z.infer + +interface MedioFormProps { + /** If provided, the form is in edit mode (código is disabled, form is pre-filled). */ + initialData?: MedioDetail + isPending: boolean + error: unknown + onSubmit: (values: MedioFormValues) => void +} + +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 === 'medio_codigo_duplicado') { + return data.message ?? 'Ya existe un medio con ese código' + } + return data.message ?? data.error ?? 'Error al guardar el medio' + } + return 'Error al guardar el medio' +} + +export function MedioForm({ initialData, isPending, error, onSubmit }: MedioFormProps) { + const isEdit = !!initialData + + const form = useForm({ + resolver: zodResolver(medioFormSchema), + defaultValues: { + codigo: initialData?.codigo ?? '', + nombre: initialData?.nombre ?? '', + tipo: initialData?.tipo ?? ('' as unknown as number), + plataformaEmpresaId: (initialData?.plataformaEmpresaId ?? '') as unknown as undefined, + }, + }) + + useEffect(() => { + if (initialData) { + form.reset({ + codigo: initialData.codigo, + nombre: initialData.nombre, + tipo: initialData.tipo, + plataformaEmpresaId: (initialData.plataformaEmpresaId ?? '') as unknown as undefined, + }) + } + }, [initialData, form]) + + const backendError = resolveBackendError(error) + + return ( +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + Código + + + + + + )} + /> + + ( + + Nombre + + + + + + )} + /> + + ( + + Tipo + + + + + + )} + /> + + ( + + Plataforma Empresa ID (opcional) + + field.onChange(e.target.value)} + type="number" + min={1} + disabled={isPending} + placeholder="ID numérico (opcional)" + /> + + + + )} + /> + + + + + ) +} diff --git a/src/web/src/features/medios/components/MediosTable.tsx b/src/web/src/features/medios/components/MediosTable.tsx new file mode 100644 index 0000000..10113e8 --- /dev/null +++ b/src/web/src/features/medios/components/MediosTable.tsx @@ -0,0 +1,103 @@ +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import type { ColumnDef } from '@tanstack/react-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { DataTable } from '@/components/ui/data-table' +import { CanPerform } from '@/components/auth/CanPerform' +import type { MedioListItem } from '../types' +import { tipoMedioLabel } from '../tipoMedio' +import { DeactivateMedioModal } from './DeactivateMedioModal' + +interface MediosTableProps { + rows: MedioListItem[] +} + +export function MediosTable({ rows }: MediosTableProps) { + const navigate = useNavigate() + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'codigo', + header: 'Código', + cell: ({ row }) => ( + {row.original.codigo} + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'nombre', + header: 'Nombre', + meta: { priority: 'high' }, + }, + { + accessorKey: 'tipo', + header: 'Tipo', + cell: ({ row }) => ( + {tipoMedioLabel(row.original.tipo)} + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'plataformaEmpresaId', + header: 'Plataforma ID', + cell: ({ row }) => ( + + {row.original.plataformaEmpresaId ?? '—'} + + ), + meta: { priority: 'medium' }, + }, + { + accessorKey: 'activo', + header: 'Estado', + cell: ({ row }) => + row.original.activo ? ( + + Activo + + ) : ( + + Inactivo + + ), + meta: { priority: 'medium' }, + }, + { + id: 'acciones', + header: 'Acciones', + cell: ({ row }) => ( +
e.stopPropagation()}> + + + + +
+ ), + meta: { priority: 'high' }, + }, + ], + [navigate], + ) + + return ( + navigate(`/admin/medios/${row.id}`)} + getRowId={(row) => String(row.id)} + emptyMessage="Sin resultados — no se encontraron medios con los filtros seleccionados." + /> + ) +} diff --git a/src/web/src/features/medios/hooks/useCreateMedio.ts b/src/web/src/features/medios/hooks/useCreateMedio.ts new file mode 100644 index 0000000..2549cfb --- /dev/null +++ b/src/web/src/features/medios/hooks/useCreateMedio.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { createMedio } from '../api/createMedio' +import type { CreateMedioRequest } from '../types' + +export function useCreateMedio() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: CreateMedioRequest) => createMedio(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['medios'] }) + }, + }) +} diff --git a/src/web/src/features/medios/hooks/useDeactivateMedio.ts b/src/web/src/features/medios/hooks/useDeactivateMedio.ts new file mode 100644 index 0000000..d93304b --- /dev/null +++ b/src/web/src/features/medios/hooks/useDeactivateMedio.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deactivateMedio } from '../api/deactivateMedio' + +export function useDeactivateMedio() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deactivateMedio(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['medios'] }) + }, + }) +} diff --git a/src/web/src/features/medios/hooks/useMedio.ts b/src/web/src/features/medios/hooks/useMedio.ts new file mode 100644 index 0000000..bf005ee --- /dev/null +++ b/src/web/src/features/medios/hooks/useMedio.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import { getMedio } from '../api/getMedio' + +export function useMedio(id: number) { + return useQuery({ + queryKey: ['medios', 'detail', id], + queryFn: () => getMedio(id), + enabled: !!id, + staleTime: 15_000, + }) +} diff --git a/src/web/src/features/medios/hooks/useMediosList.ts b/src/web/src/features/medios/hooks/useMediosList.ts new file mode 100644 index 0000000..e74cd75 --- /dev/null +++ b/src/web/src/features/medios/hooks/useMediosList.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' +import { listMedios } from '../api/listMedios' +import type { MediosQuery } from '../types' + +export const mediosListQueryKey = (query: MediosQuery) => ['medios', 'list', query] as const + +export function useMediosList(query: MediosQuery) { + return useQuery({ + queryKey: mediosListQueryKey(query), + queryFn: () => listMedios(query), + staleTime: 15_000, + }) +} diff --git a/src/web/src/features/medios/hooks/useReactivateMedio.ts b/src/web/src/features/medios/hooks/useReactivateMedio.ts new file mode 100644 index 0000000..15f1781 --- /dev/null +++ b/src/web/src/features/medios/hooks/useReactivateMedio.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { reactivateMedio } from '../api/reactivateMedio' + +export function useReactivateMedio() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => reactivateMedio(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['medios'] }) + }, + }) +} diff --git a/src/web/src/features/medios/hooks/useUpdateMedio.ts b/src/web/src/features/medios/hooks/useUpdateMedio.ts new file mode 100644 index 0000000..b9702e3 --- /dev/null +++ b/src/web/src/features/medios/hooks/useUpdateMedio.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateMedio } from '../api/updateMedio' +import type { UpdateMedioRequest } from '../types' + +export function useUpdateMedio(id: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: UpdateMedioRequest) => updateMedio(id, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['medios'] }) + }, + }) +} diff --git a/src/web/src/features/medios/pages/CreateMedioPage.tsx b/src/web/src/features/medios/pages/CreateMedioPage.tsx new file mode 100644 index 0000000..432d016 --- /dev/null +++ b/src/web/src/features/medios/pages/CreateMedioPage.tsx @@ -0,0 +1,50 @@ +import { useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { MedioForm } from '../components/MedioForm' +import { useCreateMedio } from '../hooks/useCreateMedio' +import type { MedioFormValues } from '../components/MedioForm' + +export function CreateMedioPage() { + const navigate = useNavigate() + const { mutate, isPending, error } = useCreateMedio() + + function handleSubmit(values: MedioFormValues) { + mutate( + { + codigo: values.codigo, + nombre: values.nombre, + tipo: values.tipo, + plataformaEmpresaId: values.plataformaEmpresaId as number | null, + }, + { + onSuccess: () => { + toast.success('Medio creado correctamente') + void navigate('/admin/medios') + }, + }, + ) + } + + return ( +
+ + + Crear Medio + + Completá los datos para registrar un nuevo medio en el sistema. + + + + + + +
+ ) +} diff --git a/src/web/src/features/medios/pages/EditMedioPage.tsx b/src/web/src/features/medios/pages/EditMedioPage.tsx new file mode 100644 index 0000000..9c34969 --- /dev/null +++ b/src/web/src/features/medios/pages/EditMedioPage.tsx @@ -0,0 +1,81 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { MedioForm } from '../components/MedioForm' +import { useMedio } from '../hooks/useMedio' +import { useUpdateMedio } from '../hooks/useUpdateMedio' +import type { MedioFormValues } from '../components/MedioForm' + +export function EditMedioPage() { + const { id } = useParams<{ id: string }>() + const medioId = Number(id) + const navigate = useNavigate() + + const { data: medio, isLoading } = useMedio(medioId) + const { mutate, isPending, error } = useUpdateMedio(medioId) + + function handleSubmit(values: MedioFormValues) { + mutate( + { + nombre: values.nombre, + tipo: values.tipo, + plataformaEmpresaId: values.plataformaEmpresaId as number | null, + }, + { + onSuccess: () => { + toast.success('Medio actualizado correctamente') + void navigate(`/admin/medios/${medioId}`) + }, + }, + ) + } + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!medio) { + return ( +
+ Medio no encontrado. +
+ ) + } + + return ( +
+ + +
+ Editar Medio + +
+ + Editá los datos del medio {medio.nombre}. + +
+ + + +
+
+ ) +} diff --git a/src/web/src/features/medios/pages/MedioDetailPage.tsx b/src/web/src/features/medios/pages/MedioDetailPage.tsx new file mode 100644 index 0000000..2992e68 --- /dev/null +++ b/src/web/src/features/medios/pages/MedioDetailPage.tsx @@ -0,0 +1,97 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { CanPerform } from '@/components/auth/CanPerform' +import { useMedio } from '../hooks/useMedio' +import { DeactivateMedioModal } from '../components/DeactivateMedioModal' +import { tipoMedioLabel } from '../tipoMedio' + +function formatDate(iso: string | null): string { + if (!iso) return '—' + return new Date(iso).toLocaleDateString('es-AR', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +export function MedioDetailPage() { + const { id } = useParams<{ id: string }>() + const medioId = Number(id) + const navigate = useNavigate() + + const { data: medio, isLoading } = useMedio(medioId) + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!medio) { + return ( +
+ Medio no encontrado. +
+ ) + } + + return ( +
+
+

{medio.nombre}

+ +
+ +
+
+ Código + {medio.codigo} +
+
+ Tipo + {tipoMedioLabel(medio.tipo)} +
+
+ Plataforma ID + {medio.plataformaEmpresaId ?? '—'} +
+
+ Estado + {medio.activo + ? Activo + : Inactivo + } +
+
+ Creado + {formatDate(medio.fechaCreacion)} +
+
+ Modificado + {formatDate(medio.fechaModificacion)} +
+
+ + +
+ + +
+
+
+ ) +} diff --git a/src/web/src/features/medios/pages/MediosListPage.tsx b/src/web/src/features/medios/pages/MediosListPage.tsx new file mode 100644 index 0000000..5f91688 --- /dev/null +++ b/src/web/src/features/medios/pages/MediosListPage.tsx @@ -0,0 +1,138 @@ +import { useState, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { CanPerform } from '@/components/auth/CanPerform' +import { useDebouncedValue } from '@/hooks/useDebouncedValue' +import { MediosTable } from '../components/MediosTable' +import { useMediosList } from '../hooks/useMediosList' +import { TIPO_MEDIO_OPTIONS } from '../tipoMedio' + +export function MediosListPage() { + const navigate = useNavigate() + + const [page, setPage] = useState(1) + const [tipo, setTipo] = useState(undefined) + const [activo, setActivo] = useState(undefined) + const [searchRaw, setSearchRaw] = useState('') + const q = useDebouncedValue(searchRaw, 300) + + const query = { + page, + pageSize: 20, + ...(tipo !== undefined ? { tipo } : {}), + ...(activo !== undefined ? { activo } : {}), + ...(q ? { q } : {}), + } + + const { data, isLoading } = useMediosList(query) + + const handleTipoChange = useCallback((value: string) => { + setTipo(value === '' ? undefined : Number(value)) + setPage(1) + }, []) + + const handleActivoChange = useCallback((value: string) => { + if (value === '') setActivo(undefined) + else setActivo(value === 'true') + setPage(1) + }, []) + + const handleSearchChange = useCallback((value: string) => { + setSearchRaw(value) + setPage(1) + }, []) + + const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1 + const hasPrev = page > 1 + const hasNext = page < totalPages + + return ( +
+
+

Medios

+ + + +
+ + {/* Filters */} +
+ handleSearchChange(e.target.value)} + className="max-w-xs" + aria-label="Buscar medios" + /> + + + + +
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} + + {/* Pagination */} +
+ + {data ? `${data.total} medio${data.total !== 1 ? 's' : ''}` : ''} + +
+ + + {page} / {totalPages} + + +
+
+
+ ) +} diff --git a/src/web/src/features/medios/tipoMedio.ts b/src/web/src/features/medios/tipoMedio.ts new file mode 100644 index 0000000..5557342 --- /dev/null +++ b/src/web/src/features/medios/tipoMedio.ts @@ -0,0 +1,18 @@ +// TipoMedio enum helper: int on wire → display label +export const TIPO_MEDIO_LABELS: Record = { + 1: 'Diario', + 2: 'Radio', + 3: 'Web', + 4: 'Poster', +} + +export const TIPO_MEDIO_OPTIONS = [ + { value: 1, label: 'Diario' }, + { value: 2, label: 'Radio' }, + { value: 3, label: 'Web' }, + { value: 4, label: 'Poster' }, +] + +export function tipoMedioLabel(tipo: number): string { + return TIPO_MEDIO_LABELS[tipo] ?? String(tipo) +} diff --git a/src/web/src/features/medios/types.ts b/src/web/src/features/medios/types.ts new file mode 100644 index 0000000..a1370f8 --- /dev/null +++ b/src/web/src/features/medios/types.ts @@ -0,0 +1,58 @@ +// ADM-001 — shared types for medios feature + +export interface MedioListItem { + id: number + codigo: string + nombre: string + tipo: number + plataformaEmpresaId: number | null + activo: boolean +} + +export interface MedioDetail { + id: number + codigo: string + nombre: string + tipo: number + plataformaEmpresaId: number | null + activo: boolean + fechaCreacion: string + fechaModificacion: string | null +} + +export interface MedioCreated { + id: number + codigo: string + nombre: string + tipo: number + plataformaEmpresaId: number | null + activo: boolean +} + +export interface CreateMedioRequest { + codigo: string + nombre: string + tipo: number + plataformaEmpresaId?: number | null +} + +export interface UpdateMedioRequest { + nombre: string + tipo: number + plataformaEmpresaId?: number | null +} + +export interface MediosQuery { + page?: number + pageSize?: number + activo?: boolean + tipo?: number + q?: string +} + +export interface PagedResult { + items: T[] + page: number + pageSize: number + total: number +} diff --git a/src/web/src/features/secciones/api/createSeccion.ts b/src/web/src/features/secciones/api/createSeccion.ts new file mode 100644 index 0000000..b00a269 --- /dev/null +++ b/src/web/src/features/secciones/api/createSeccion.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { CreateSeccionRequest, SeccionCreated } from '../types' + +export async function createSeccion(payload: CreateSeccionRequest): Promise { + const response = await axiosClient.post('/api/v1/admin/secciones', payload) + return response.data +} diff --git a/src/web/src/features/secciones/api/deactivateSeccion.ts b/src/web/src/features/secciones/api/deactivateSeccion.ts new file mode 100644 index 0000000..db675a8 --- /dev/null +++ b/src/web/src/features/secciones/api/deactivateSeccion.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function deactivateSeccion(id: number): Promise { + await axiosClient.post(`/api/v1/admin/secciones/${id}/deactivate`) +} diff --git a/src/web/src/features/secciones/api/getSeccion.ts b/src/web/src/features/secciones/api/getSeccion.ts new file mode 100644 index 0000000..908cb79 --- /dev/null +++ b/src/web/src/features/secciones/api/getSeccion.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { SeccionDetail } from '../types' + +export async function getSeccion(id: number): Promise { + const response = await axiosClient.get(`/api/v1/admin/secciones/${id}`) + return response.data +} diff --git a/src/web/src/features/secciones/api/listSecciones.ts b/src/web/src/features/secciones/api/listSecciones.ts new file mode 100644 index 0000000..26d7c56 --- /dev/null +++ b/src/web/src/features/secciones/api/listSecciones.ts @@ -0,0 +1,18 @@ +import { axiosClient } from '@/api/axiosClient' +import type { SeccionListItem, SeccionesQuery, PagedResult } from '../types' + +export async function listSecciones(query: SeccionesQuery): 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.medioId !== undefined) params.set('medioId', String(query.medioId)) + if (query.tipo !== undefined) params.set('tipo', query.tipo) + if (query.activo !== undefined) params.set('activo', String(query.activo)) + if (query.q !== undefined && query.q !== '') params.set('q', query.q) + + const response = await axiosClient.get>( + '/api/v1/admin/secciones', + { params }, + ) + return response.data +} diff --git a/src/web/src/features/secciones/api/reactivateSeccion.ts b/src/web/src/features/secciones/api/reactivateSeccion.ts new file mode 100644 index 0000000..5c5185d --- /dev/null +++ b/src/web/src/features/secciones/api/reactivateSeccion.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function reactivateSeccion(id: number): Promise { + await axiosClient.post(`/api/v1/admin/secciones/${id}/reactivate`) +} diff --git a/src/web/src/features/secciones/api/updateSeccion.ts b/src/web/src/features/secciones/api/updateSeccion.ts new file mode 100644 index 0000000..f017207 --- /dev/null +++ b/src/web/src/features/secciones/api/updateSeccion.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { SeccionDetail, UpdateSeccionRequest } from '../types' + +export async function updateSeccion(id: number, payload: UpdateSeccionRequest): Promise { + const response = await axiosClient.put(`/api/v1/admin/secciones/${id}`, payload) + return response.data +} diff --git a/src/web/src/features/secciones/components/DeactivateSeccionModal.tsx b/src/web/src/features/secciones/components/DeactivateSeccionModal.tsx new file mode 100644 index 0000000..07d70cb --- /dev/null +++ b/src/web/src/features/secciones/components/DeactivateSeccionModal.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { useDeactivateSeccion } from '../hooks/useDeactivateSeccion' +import { useReactivateSeccion } from '../hooks/useReactivateSeccion' + +interface DeactivateSeccionModalProps { + seccionId: number + seccionNombre: string + activo: boolean +} + +export function DeactivateSeccionModal({ seccionId, seccionNombre, activo }: DeactivateSeccionModalProps) { + const [open, setOpen] = useState(false) + const { mutate: deactivate, isPending: deactivating } = useDeactivateSeccion() + const { mutate: reactivate, isPending: reactivating } = useReactivateSeccion() + + const isPending = deactivating || reactivating + + function handleConfirm() { + if (activo) { + deactivate(seccionId, { onSuccess: () => setOpen(false) }) + } else { + reactivate(seccionId, { onSuccess: () => setOpen(false) }) + } + } + + return ( + + + + + + + + {activo ? 'Desactivar sección' : 'Reactivar sección'} + + + {activo + ? `¿Confirmás que querés desactivar la sección "${seccionNombre}"?` + : `¿Confirmás que querés reactivar la sección "${seccionNombre}"?`} + + + + Cancelar + + {isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'} + + + + + ) +} diff --git a/src/web/src/features/secciones/components/SeccionForm.tsx b/src/web/src/features/secciones/components/SeccionForm.tsx new file mode 100644 index 0000000..701a0e4 --- /dev/null +++ b/src/web/src/features/secciones/components/SeccionForm.tsx @@ -0,0 +1,195 @@ +import { useEffect } from 'react' +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 { useMediosList } from '@/features/medios/hooks/useMediosList' +import { TIPO_SECCION_OPTIONS } from '../tipoSeccion' +import type { SeccionDetail, TipoSeccion } from '../types' + +const TIPO_SECCION_VALUES = ['clasificados', 'notables', 'suplementos'] as const + +const seccionFormSchema = z.object({ + medioId: z.coerce.number().refine((v) => v >= 1, 'Seleccioná un medio'), + codigo: z + .string() + .min(1, 'El código es requerido') + .max(20, 'Máximo 20 caracteres'), + nombre: z + .string() + .min(1, 'El nombre es requerido') + .max(100, 'Máximo 100 caracteres'), + tipo: z.enum(TIPO_SECCION_VALUES, { errorMap: () => ({ message: 'Seleccioná un tipo válido' }) }), +}) + +export type SeccionFormValues = z.infer + +interface SeccionFormProps { + initialData?: SeccionDetail + isPending: boolean + error: unknown + onSubmit: (values: SeccionFormValues) => void +} + +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 === 'seccion_codigo_duplicado_en_medio') { + return data.message ?? 'Ya existe una sección con ese código en el medio' + } + return data.message ?? data.error ?? 'Error al guardar la sección' + } + return 'Error al guardar la sección' +} + +export function SeccionForm({ initialData, isPending, error, onSubmit }: SeccionFormProps) { + const isEdit = !!initialData + + const { data: mediosData } = useMediosList({ page: 1, pageSize: 200 }) + const medios = mediosData?.items ?? [] + + const form = useForm({ + resolver: zodResolver(seccionFormSchema), + defaultValues: { + medioId: initialData?.medioId ?? ('' as unknown as number), + codigo: initialData?.codigo ?? '', + nombre: initialData?.nombre ?? '', + tipo: initialData?.tipo ?? ('' as TipoSeccion), + }, + }) + + useEffect(() => { + if (initialData) { + form.reset({ + medioId: initialData.medioId, + codigo: initialData.codigo, + nombre: initialData.nombre, + tipo: initialData.tipo, + }) + } + }, [initialData, form]) + + const backendError = resolveBackendError(error) + + return ( +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + Medio + + + + + + )} + /> + + ( + + Código + + + + + + )} + /> + + ( + + Nombre + + + + + + )} + /> + + ( + + Tipo + + + + + + )} + /> + + + + + ) +} diff --git a/src/web/src/features/secciones/components/SeccionesFilters.tsx b/src/web/src/features/secciones/components/SeccionesFilters.tsx new file mode 100644 index 0000000..5197b3f --- /dev/null +++ b/src/web/src/features/secciones/components/SeccionesFilters.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react' +import { Input } from '@/components/ui/input' +import { useDebouncedValue } from '@/hooks/useDebouncedValue' +import { useMediosList } from '@/features/medios/hooks/useMediosList' +import { TIPO_SECCION_OPTIONS } from '../tipoSeccion' +import type { TipoSeccion } from '../types' + +interface SeccionesFiltersProps { + onMedioIdChange: (medioId: number | undefined) => void + onTipoChange: (tipo: TipoSeccion | undefined) => void + onActivoChange: (activo: boolean | undefined) => void + onSearchChange: (q: string) => void +} + +export function SeccionesFilters({ + onMedioIdChange, + onTipoChange, + onActivoChange, + onSearchChange, +}: SeccionesFiltersProps) { + const [searchRaw, setSearchRaw] = useState('') + const debouncedSearch = useDebouncedValue(searchRaw, 300) + + useEffect(() => { + onSearchChange(debouncedSearch) + }, [debouncedSearch, onSearchChange]) + + // Load medios for the selector + const { data: mediosData } = useMediosList({ page: 1, pageSize: 200, activo: true }) + const medios = mediosData?.items ?? [] + + return ( +
+ setSearchRaw(e.target.value)} + className="max-w-xs" + aria-label="Buscar secciones" + /> + + + + + + +
+ ) +} diff --git a/src/web/src/features/secciones/components/SeccionesTable.tsx b/src/web/src/features/secciones/components/SeccionesTable.tsx new file mode 100644 index 0000000..7a6f455 --- /dev/null +++ b/src/web/src/features/secciones/components/SeccionesTable.tsx @@ -0,0 +1,103 @@ +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import type { ColumnDef } from '@tanstack/react-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { DataTable } from '@/components/ui/data-table' +import { CanPerform } from '@/components/auth/CanPerform' +import type { SeccionListItem } from '../types' +import { tipoSeccionLabel } from '../tipoSeccion' +import { DeactivateSeccionModal } from './DeactivateSeccionModal' + +interface SeccionesTableProps { + rows: SeccionListItem[] +} + +export function SeccionesTable({ rows }: SeccionesTableProps) { + const navigate = useNavigate() + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'codigo', + header: 'Código', + cell: ({ row }) => ( + {row.original.codigo} + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'nombre', + header: 'Nombre', + meta: { priority: 'high' }, + }, + { + accessorKey: 'tipo', + header: 'Tipo', + cell: ({ row }) => ( + + {tipoSeccionLabel(row.original.tipo)} + + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'medioId', + header: 'Medio ID', + cell: ({ row }) => ( + {row.original.medioId} + ), + meta: { priority: 'medium' }, + }, + { + accessorKey: 'activo', + header: 'Estado', + cell: ({ row }) => + row.original.activo ? ( + + Activo + + ) : ( + + Inactivo + + ), + meta: { priority: 'medium' }, + }, + { + id: 'acciones', + header: 'Acciones', + cell: ({ row }) => ( +
e.stopPropagation()}> + + + + +
+ ), + meta: { priority: 'high' }, + }, + ], + [navigate], + ) + + return ( + navigate(`/admin/secciones/${row.id}`)} + getRowId={(row) => String(row.id)} + emptyMessage="Sin resultados — no se encontraron secciones con los filtros seleccionados." + /> + ) +} diff --git a/src/web/src/features/secciones/hooks/useCreateSeccion.ts b/src/web/src/features/secciones/hooks/useCreateSeccion.ts new file mode 100644 index 0000000..b113b4c --- /dev/null +++ b/src/web/src/features/secciones/hooks/useCreateSeccion.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { createSeccion } from '../api/createSeccion' +import type { CreateSeccionRequest } from '../types' + +export function useCreateSeccion() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: CreateSeccionRequest) => createSeccion(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['secciones'] }) + }, + }) +} diff --git a/src/web/src/features/secciones/hooks/useDeactivateSeccion.ts b/src/web/src/features/secciones/hooks/useDeactivateSeccion.ts new file mode 100644 index 0000000..3c6f3eb --- /dev/null +++ b/src/web/src/features/secciones/hooks/useDeactivateSeccion.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deactivateSeccion } from '../api/deactivateSeccion' + +export function useDeactivateSeccion() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deactivateSeccion(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['secciones'] }) + }, + }) +} diff --git a/src/web/src/features/secciones/hooks/useReactivateSeccion.ts b/src/web/src/features/secciones/hooks/useReactivateSeccion.ts new file mode 100644 index 0000000..b97cbc3 --- /dev/null +++ b/src/web/src/features/secciones/hooks/useReactivateSeccion.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { reactivateSeccion } from '../api/reactivateSeccion' + +export function useReactivateSeccion() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => reactivateSeccion(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['secciones'] }) + }, + }) +} diff --git a/src/web/src/features/secciones/hooks/useSeccion.ts b/src/web/src/features/secciones/hooks/useSeccion.ts new file mode 100644 index 0000000..dea820a --- /dev/null +++ b/src/web/src/features/secciones/hooks/useSeccion.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import { getSeccion } from '../api/getSeccion' + +export function useSeccion(id: number) { + return useQuery({ + queryKey: ['secciones', 'detail', id], + queryFn: () => getSeccion(id), + enabled: !!id, + staleTime: 15_000, + }) +} diff --git a/src/web/src/features/secciones/hooks/useSeccionesList.ts b/src/web/src/features/secciones/hooks/useSeccionesList.ts new file mode 100644 index 0000000..aeb3b0f --- /dev/null +++ b/src/web/src/features/secciones/hooks/useSeccionesList.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' +import { listSecciones } from '../api/listSecciones' +import type { SeccionesQuery } from '../types' + +export const seccionesListQueryKey = (query: SeccionesQuery) => ['secciones', 'list', query] as const + +export function useSeccionesList(query: SeccionesQuery) { + return useQuery({ + queryKey: seccionesListQueryKey(query), + queryFn: () => listSecciones(query), + staleTime: 15_000, + }) +} diff --git a/src/web/src/features/secciones/hooks/useUpdateSeccion.ts b/src/web/src/features/secciones/hooks/useUpdateSeccion.ts new file mode 100644 index 0000000..ba65eb4 --- /dev/null +++ b/src/web/src/features/secciones/hooks/useUpdateSeccion.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateSeccion } from '../api/updateSeccion' +import type { UpdateSeccionRequest } from '../types' + +export function useUpdateSeccion(id: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: UpdateSeccionRequest) => updateSeccion(id, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['secciones'] }) + }, + }) +} diff --git a/src/web/src/features/secciones/pages/CreateSeccionPage.tsx b/src/web/src/features/secciones/pages/CreateSeccionPage.tsx new file mode 100644 index 0000000..72b1887 --- /dev/null +++ b/src/web/src/features/secciones/pages/CreateSeccionPage.tsx @@ -0,0 +1,50 @@ +import { useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { SeccionForm } from '../components/SeccionForm' +import { useCreateSeccion } from '../hooks/useCreateSeccion' +import type { SeccionFormValues } from '../components/SeccionForm' + +export function CreateSeccionPage() { + const navigate = useNavigate() + const { mutate, isPending, error } = useCreateSeccion() + + function handleSubmit(values: SeccionFormValues) { + mutate( + { + medioId: values.medioId, + codigo: values.codigo, + nombre: values.nombre, + tipo: values.tipo, + }, + { + onSuccess: () => { + toast.success('Sección creada correctamente') + void navigate('/admin/secciones') + }, + }, + ) + } + + return ( +
+ + + Crear Sección + + Completá los datos para registrar una nueva sección en el sistema. + + + + + + +
+ ) +} diff --git a/src/web/src/features/secciones/pages/EditSeccionPage.tsx b/src/web/src/features/secciones/pages/EditSeccionPage.tsx new file mode 100644 index 0000000..2464094 --- /dev/null +++ b/src/web/src/features/secciones/pages/EditSeccionPage.tsx @@ -0,0 +1,80 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { SeccionForm } from '../components/SeccionForm' +import { useSeccion } from '../hooks/useSeccion' +import { useUpdateSeccion } from '../hooks/useUpdateSeccion' +import type { SeccionFormValues } from '../components/SeccionForm' + +export function EditSeccionPage() { + const { id } = useParams<{ id: string }>() + const seccionId = Number(id) + const navigate = useNavigate() + + const { data: seccion, isLoading } = useSeccion(seccionId) + const { mutate, isPending, error } = useUpdateSeccion(seccionId) + + function handleSubmit(values: SeccionFormValues) { + mutate( + { + nombre: values.nombre, + tipo: values.tipo, + }, + { + onSuccess: () => { + toast.success('Sección actualizada correctamente') + void navigate(`/admin/secciones/${seccionId}`) + }, + }, + ) + } + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!seccion) { + return ( +
+ Sección no encontrada. +
+ ) + } + + return ( +
+ + +
+ Editar Sección + +
+ + Editá los datos de la sección {seccion.nombre}. + +
+ + + +
+
+ ) +} diff --git a/src/web/src/features/secciones/pages/SeccionDetailPage.tsx b/src/web/src/features/secciones/pages/SeccionDetailPage.tsx new file mode 100644 index 0000000..f02dca3 --- /dev/null +++ b/src/web/src/features/secciones/pages/SeccionDetailPage.tsx @@ -0,0 +1,99 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { CanPerform } from '@/components/auth/CanPerform' +import { useSeccion } from '../hooks/useSeccion' +import { DeactivateSeccionModal } from '../components/DeactivateSeccionModal' +import { tipoSeccionLabel } from '../tipoSeccion' + +function formatDate(iso: string | null): string { + if (!iso) return '—' + return new Date(iso).toLocaleDateString('es-AR', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +export function SeccionDetailPage() { + const { id } = useParams<{ id: string }>() + const seccionId = Number(id) + const navigate = useNavigate() + + const { data: seccion, isLoading } = useSeccion(seccionId) + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!seccion) { + return ( +
+ Sección no encontrada. +
+ ) + } + + return ( +
+
+

{seccion.nombre}

+ +
+ +
+
+ Código + {seccion.codigo} +
+
+ Medio ID + {seccion.medioId} +
+
+ Tipo + + {tipoSeccionLabel(seccion.tipo)} + +
+
+ Estado + {seccion.activo + ? Activo + : Inactivo + } +
+
+ Creado + {formatDate(seccion.fechaCreacion)} +
+
+ Modificado + {formatDate(seccion.fechaModificacion)} +
+
+ + +
+ + +
+
+
+ ) +} diff --git a/src/web/src/features/secciones/pages/SeccionesListPage.tsx b/src/web/src/features/secciones/pages/SeccionesListPage.tsx new file mode 100644 index 0000000..d3e7048 --- /dev/null +++ b/src/web/src/features/secciones/pages/SeccionesListPage.tsx @@ -0,0 +1,114 @@ +import { useState, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { CanPerform } from '@/components/auth/CanPerform' +import { SeccionesTable } from '../components/SeccionesTable' +import { SeccionesFilters } from '../components/SeccionesFilters' +import { useSeccionesList } from '../hooks/useSeccionesList' +import type { TipoSeccion } from '../types' + +export function SeccionesListPage() { + const navigate = useNavigate() + + const [page, setPage] = useState(1) + const [medioId, setMedioId] = useState(undefined) + const [tipo, setTipo] = useState(undefined) + const [activo, setActivo] = useState(undefined) + const [q, setQ] = useState('') + + const query = { + page, + pageSize: 20, + ...(medioId !== undefined ? { medioId } : {}), + ...(tipo !== undefined ? { tipo } : {}), + ...(activo !== undefined ? { activo } : {}), + ...(q ? { q } : {}), + } + + const { data, isLoading } = useSeccionesList(query) + + const handleMedioIdChange = useCallback((value: number | undefined) => { + setMedioId(value) + setPage(1) + }, []) + + const handleTipoChange = useCallback((value: TipoSeccion | undefined) => { + setTipo(value) + setPage(1) + }, []) + + const handleActivoChange = useCallback((value: boolean | undefined) => { + setActivo(value) + setPage(1) + }, []) + + const handleSearchChange = useCallback((value: string) => { + setQ(value) + setPage(1) + }, []) + + const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1 + const hasPrev = page > 1 + const hasNext = page < totalPages + + return ( +
+
+

Secciones

+ + + +
+ + + + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} + + {/* Pagination */} +
+ + {data ? `${data.total} sección${data.total !== 1 ? 'es' : ''}` : ''} + +
+ + + {page} / {totalPages} + + +
+
+
+ ) +} diff --git a/src/web/src/features/secciones/tipoSeccion.ts b/src/web/src/features/secciones/tipoSeccion.ts new file mode 100644 index 0000000..fd54729 --- /dev/null +++ b/src/web/src/features/secciones/tipoSeccion.ts @@ -0,0 +1,12 @@ +import type { TipoSeccion } from './types' + +export const TIPO_SECCION_OPTIONS: { value: TipoSeccion; label: string }[] = [ + { value: 'clasificados', label: 'Clasificados' }, + { value: 'notables', label: 'Notables' }, + { value: 'suplementos', label: 'Suplementos' }, +] + +export function tipoSeccionLabel(tipo: TipoSeccion): string { + const found = TIPO_SECCION_OPTIONS.find((o) => o.value === tipo) + return found ? found.label : tipo +} diff --git a/src/web/src/features/secciones/types.ts b/src/web/src/features/secciones/types.ts new file mode 100644 index 0000000..dab896f --- /dev/null +++ b/src/web/src/features/secciones/types.ts @@ -0,0 +1,60 @@ +// ADM-001 — shared types for secciones feature + +export type TipoSeccion = 'clasificados' | 'notables' | 'suplementos' + +export interface SeccionListItem { + id: number + medioId: number + codigo: string + nombre: string + tipo: TipoSeccion + activo: boolean +} + +export interface SeccionDetail { + id: number + medioId: number + codigo: string + nombre: string + tipo: TipoSeccion + activo: boolean + fechaCreacion: string + fechaModificacion: string | null +} + +export interface SeccionCreated { + id: number + medioId: number + codigo: string + nombre: string + tipo: TipoSeccion + activo: boolean +} + +export interface CreateSeccionRequest { + medioId: number + codigo: string + nombre: string + tipo: TipoSeccion +} + +export interface UpdateSeccionRequest { + nombre: string + tipo: TipoSeccion +} + +export interface SeccionesQuery { + page?: number + pageSize?: number + medioId?: number + tipo?: TipoSeccion + activo?: boolean + q?: string +} + +export interface PagedResult { + items: T[] + page: number + pageSize: number + total: number +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 2a83906..8290b0c 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -13,6 +13,14 @@ import { NewRolPage } from './features/roles/pages/NewRolPage' import { EditRolPage } from './features/roles/pages/EditRolPage' import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage' import { AuditPage } from './pages/admin/audit/AuditPage' +import { MediosListPage } from './features/medios/pages/MediosListPage' +import { CreateMedioPage } from './features/medios/pages/CreateMedioPage' +import { EditMedioPage } from './features/medios/pages/EditMedioPage' +import { MedioDetailPage } from './features/medios/pages/MedioDetailPage' +import { SeccionesListPage } from './features/secciones/pages/SeccionesListPage' +import { CreateSeccionPage } from './features/secciones/pages/CreateSeccionPage' +import { EditSeccionPage } from './features/secciones/pages/EditSeccionPage' +import { SeccionDetailPage } from './features/secciones/pages/SeccionDetailPage' import { HomePage } from './pages/HomePage' import { PublicLayout } from './layouts/PublicLayout' import { ProtectedLayout } from './layouts/ProtectedLayout' @@ -164,6 +172,74 @@ export function AppRoutes() { } /> + {/* Medios routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Secciones routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> ) diff --git a/src/web/src/tests/features/medios/CreateMedioPage.test.tsx b/src/web/src/tests/features/medios/CreateMedioPage.test.tsx new file mode 100644 index 0000000..9045f61 --- /dev/null +++ b/src/web/src/tests/features/medios/CreateMedioPage.test.tsx @@ -0,0 +1,96 @@ +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 { CreateMedioPage } from '../../../features/medios/pages/CreateMedioPage' + +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 } +}) + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +describe('CreateMedioPage', () => { + it('renders the create form with correct title', () => { + renderPage() + expect(screen.getByRole('heading', { name: /crear medio/i })).toBeInTheDocument() + }) + + it('successfully creates a medio and navigates away', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json( + { id: 10, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true }, + { status: 201 }, + ), + ), + ) + + renderPage() + + await userEvent.type(screen.getByLabelText(/código/i), 'RAD01') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Radio AM') + await userEvent.selectOptions(screen.getByLabelText(/tipo/i), '2') + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/admin/medios')) + }) + + it('shows backend error when codigo is duplicated', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json( + { error: 'medio_codigo_duplicado', message: 'Ya existe un medio con ese código' }, + { status: 409 }, + ), + ), + ) + + renderPage() + + await userEvent.type(screen.getByLabelText(/código/i), 'DUP01') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado') + await userEvent.selectOptions(screen.getByLabelText(/tipo/i), '1') + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/ya existe un medio con ese código/i), + ) + }) + + it('submit button is labeled "Crear medio"', () => { + renderPage() + expect(screen.getByRole('button', { name: /crear medio/i })).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/medios/DeactivateMedioModal.test.tsx b/src/web/src/tests/features/medios/DeactivateMedioModal.test.tsx new file mode 100644 index 0000000..aca974f --- /dev/null +++ b/src/web/src/tests/features/medios/DeactivateMedioModal.test.tsx @@ -0,0 +1,91 @@ +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 { DeactivateMedioModal } from '../../../features/medios/components/DeactivateMedioModal' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderModal(activo = true) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +describe('DeactivateMedioModal', () => { + it('shows "Desactivar" trigger when medio is active', () => { + renderModal(true) + expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument() + }) + + it('shows "Reactivar" trigger when medio is inactive', () => { + renderModal(false) + expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument() + }) + + it('opens dialog and shows confirmation text', async () => { + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => + expect(screen.getByText(/desactivar medio/i)).toBeInTheDocument(), + ) + expect(screen.getByText(/diario el día/i)).toBeInTheDocument() + }) + + it('calls deactivate endpoint on confirm and invalidates query', async () => { + let called = false + server.use( + http.post(`${API_URL}/api/v1/admin/medios/1/deactivate`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => screen.getByRole('alertdialog')) + await userEvent.click(screen.getByRole('button', { name: /desactivar$/i })) + + await waitFor(() => expect(called).toBe(true)) + }) + + it('calls reactivate endpoint on confirm when inactive', async () => { + let called = false + server.use( + http.post(`${API_URL}/api/v1/admin/medios/1/reactivate`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + + renderModal(false) + await userEvent.click(screen.getByRole('button', { name: /reactivar/i })) + await waitFor(() => screen.getByRole('alertdialog')) + await userEvent.click(screen.getByRole('button', { name: /reactivar$/i })) + + await waitFor(() => expect(called).toBe(true)) + }) +}) diff --git a/src/web/src/tests/features/medios/EditMedioPage.test.tsx b/src/web/src/tests/features/medios/EditMedioPage.test.tsx new file mode 100644 index 0000000..e1f99d0 --- /dev/null +++ b/src/web/src/tests/features/medios/EditMedioPage.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +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 { EditMedioPage } from '../../../features/medios/pages/EditMedioPage' + +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 } +}) + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const sampleMedio = { + id: 5, + codigo: 'WEB01', + nombre: 'Portal Web', + tipo: 3, + plataformaEmpresaId: 42, + activo: true, + fechaCreacion: '2026-01-01T00:00:00Z', + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage(id = '5') { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + } /> + + + , + ) +} + +describe('EditMedioPage', () => { + it('shows loading state initially', () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios/5`, () => + HttpResponse.json(sampleMedio), + ), + ) + + renderPage() + expect(screen.getByText(/cargando/i)).toBeInTheDocument() + }) + + it('loads and pre-fills form with medio data', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios/5`, () => + HttpResponse.json(sampleMedio), + ), + ) + + renderPage() + + await waitFor(() => + expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('Portal Web'), + ) + expect((screen.getByLabelText(/código/i) as HTMLInputElement).value).toBe('WEB01') + // Código disabled in edit mode + expect((screen.getByLabelText(/código/i) as HTMLInputElement).disabled).toBe(true) + }) + + it('shows "Medio no encontrado" when 404', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios/999`, () => + new HttpResponse(null, { status: 404 }), + ), + ) + + renderPage('999') + + await waitFor(() => + expect(screen.getByText(/medio no encontrado/i)).toBeInTheDocument(), + ) + }) + + it('shows "Guardar cambios" button in edit mode', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios/5`, () => + HttpResponse.json(sampleMedio), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument(), + ) + }) +}) diff --git a/src/web/src/tests/features/medios/MedioForm.test.tsx b/src/web/src/tests/features/medios/MedioForm.test.tsx new file mode 100644 index 0000000..8b8dc52 --- /dev/null +++ b/src/web/src/tests/features/medios/MedioForm.test.tsx @@ -0,0 +1,110 @@ +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 { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { MedioForm } from '../../../features/medios/components/MedioForm' +import type { MedioFormValues } from '../../../features/medios/components/MedioForm' +import type { MedioDetail } from '../../../features/medios/types' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const sampleMedio: MedioDetail = { + id: 1, + codigo: 'DIA01', + nombre: 'Diario Principal', + tipo: 1, + plataformaEmpresaId: null, + activo: true, + fechaCreacion: '2026-01-01T00:00:00Z', + fechaModificacion: null, +} + +function renderForm(opts: { initialData?: MedioDetail; onSubmit?: (v: MedioFormValues) => void } = {}) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const onSubmit = opts.onSubmit ?? vi.fn() + render( + + + + + , + ) + return { onSubmit } +} + +describe('MedioForm — create mode', () => { + it('shows validation error when código is empty', async () => { + renderForm() + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + await waitFor(() => + expect(screen.getByText(/código es requerido/i)).toBeInTheDocument(), + ) + }) + + it('shows validation error when nombre is empty', async () => { + renderForm() + await userEvent.type(screen.getByLabelText(/código/i), 'COD1') + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + await waitFor(() => + expect(screen.getByText(/nombre es requerido/i)).toBeInTheDocument(), + ) + }) + + it('shows validation error when tipo is not selected', async () => { + renderForm() + await userEvent.type(screen.getByLabelText(/código/i), 'COD1') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Medio') + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + // The form message for tipo validation appears as a

role + await waitFor(() => { + const messages = screen.getAllByText(/seleccioná un tipo/i) + // At least one message should exist (validation error, not just the placeholder option) + expect(messages.length).toBeGreaterThanOrEqual(1) + }) + }) + + it('calls onSubmit with correct values on valid form', async () => { + const onSubmit = vi.fn() + renderForm({ onSubmit }) + + await userEvent.type(screen.getByLabelText(/código/i), 'DIA99') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Diario') + await userEvent.selectOptions(screen.getByLabelText(/tipo/i), '1') + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + const firstArg = onSubmit.mock.calls[0][0] + expect(firstArg).toMatchObject({ codigo: 'DIA99', nombre: 'Mi Diario', tipo: 1 }) + }) + }) +}) + +describe('MedioForm — edit mode', () => { + it('código field is disabled in edit mode', () => { + renderForm({ initialData: sampleMedio }) + const codigoInput = screen.getByLabelText(/código/i) as HTMLInputElement + expect(codigoInput.disabled).toBe(true) + }) + + it('pre-fills form with initialData values', () => { + renderForm({ initialData: sampleMedio }) + expect((screen.getByLabelText(/código/i) as HTMLInputElement).value).toBe('DIA01') + expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('Diario Principal') + }) + + it('shows "Guardar cambios" button in edit mode', () => { + renderForm({ initialData: sampleMedio }) + expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/medios/MediosListPage.test.tsx b/src/web/src/tests/features/medios/MediosListPage.test.tsx new file mode 100644 index 0000000..6d48b92 --- /dev/null +++ b/src/web/src/tests/features/medios/MediosListPage.test.tsx @@ -0,0 +1,168 @@ +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 { MediosListPage } from '../../../features/medios/pages/MediosListPage' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +const adminUserWithMedios = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:medios:gestionar'], + mustChangePassword: false, +} + +const adminUserWithoutMedios = { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: [], + mustChangePassword: false, +} + +function makeMedios(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: i + 1, + codigo: `MEDIO${i + 1}`, + nombre: `Medio ${i + 1}`, + tipo: (i % 4) + 1, + plataformaEmpresaId: null, + activo: true, + })) +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage(user = adminUserWithMedios) { + useAuthStore.setState({ user }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + } /> + + + , + ) +} + +describe('MediosListPage', () => { + it('renders seed rows when API returns items', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: makeMedios(3), page: 1, pageSize: 20, total: 3 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('MEDIO1')).toBeInTheDocument()) + expect(screen.getByText('MEDIO2')).toBeInTheDocument() + expect(screen.getByText('MEDIO3')).toBeInTheDocument() + }) + + it('shows empty state when items is empty', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByText(/sin resultados/i)).toBeInTheDocument(), + ) + }) + + it('hides "Nuevo medio" button when user lacks permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: makeMedios(2), page: 1, pageSize: 20, total: 2 }), + ), + ) + + renderPage(adminUserWithoutMedios) + + // Wait for page to render + await waitFor(() => expect(screen.queryByRole('button', { name: /nuevo medio/i })).not.toBeInTheDocument()) + }) + + it('shows "Nuevo medio" button when user has permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: makeMedios(2), page: 1, pageSize: 20, total: 2 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByRole('button', { name: /nuevo medio/i })).toBeInTheDocument(), + ) + }) + + it('filter by tipo adds querystring tipo', async () => { + const requests: string[] = [] + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, ({ request }) => { + requests.push(request.url) + return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }) + }), + ) + + renderPage() + + await waitFor(() => expect(requests.length).toBeGreaterThan(0)) + + const tipoSelect = screen.getByRole('combobox', { name: /tipo/i }) + await userEvent.selectOptions(tipoSelect, '1') + + await waitFor(() => { + const filtered = requests.find((u) => u.includes('tipo=1')) + expect(filtered).toBeTruthy() + }) + }) + + it('prev button disabled on first page', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: makeMedios(3), page: 1, pageSize: 20, total: 3 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('MEDIO1')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled() + }) +}) diff --git a/src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx b/src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx new file mode 100644 index 0000000..310e087 --- /dev/null +++ b/src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx @@ -0,0 +1,74 @@ +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 { DeactivateSeccionModal } from '../../../features/secciones/components/DeactivateSeccionModal' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderModal(activo = true) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +describe('DeactivateSeccionModal', () => { + it('shows "Desactivar" trigger when sección is active', () => { + renderModal(true) + expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument() + }) + + it('shows "Reactivar" trigger when sección is inactive', () => { + renderModal(false) + expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument() + }) + + it('opens dialog and shows confirmation text', async () => { + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => + expect(screen.getByText(/desactivar sección/i)).toBeInTheDocument(), + ) + expect(screen.getByText(/clasificados autos/i)).toBeInTheDocument() + }) + + it('calls deactivate endpoint on confirm', async () => { + let called = false + server.use( + http.post(`${API_URL}/api/v1/admin/secciones/1/deactivate`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => screen.getByRole('alertdialog')) + await userEvent.click(screen.getByRole('button', { name: /desactivar$/i })) + + await waitFor(() => expect(called).toBe(true)) + }) +}) diff --git a/src/web/src/tests/features/secciones/SeccionForm.test.tsx b/src/web/src/tests/features/secciones/SeccionForm.test.tsx new file mode 100644 index 0000000..04f0000 --- /dev/null +++ b/src/web/src/tests/features/secciones/SeccionForm.test.tsx @@ -0,0 +1,136 @@ +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 { SeccionForm } from '../../../features/secciones/components/SeccionForm' +import type { SeccionFormValues } from '../../../features/secciones/components/SeccionForm' +import type { SeccionDetail } from '../../../features/secciones/types' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockMedios = [ + { id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true }, + { id: 2, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true }, +] + +const sampleSeccion: SeccionDetail = { + id: 1, + medioId: 1, + codigo: 'CLAS01', + nombre: 'Clasificados Autos', + tipo: 'clasificados', + activo: true, + fechaCreacion: '2026-01-01T00:00:00Z', + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderForm(opts: { initialData?: SeccionDetail; onSubmit?: (v: SeccionFormValues) => void } = {}) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const onSubmit = opts.onSubmit ?? vi.fn() + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 2 }), + ), + ) + render( + + + + + , + ) + return { onSubmit } +} + +describe('SeccionForm — create mode', () => { + it('shows validation error when código is empty', async () => { + renderForm() + await userEvent.click(screen.getByRole('button', { name: /crear sección/i })) + await waitFor(() => + expect(screen.getByText(/código es requerido/i)).toBeInTheDocument(), + ) + }) + + it('shows validation error when tipo is not selected', async () => { + renderForm() + await userEvent.type(screen.getByLabelText(/código/i), 'CLAS99') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Sección') + await userEvent.click(screen.getByRole('button', { name: /crear sección/i })) + await waitFor(() => + expect(screen.getByText(/seleccioná un tipo/i)).toBeInTheDocument(), + ) + }) + + it('calls onSubmit with correct values on valid form', async () => { + const onSubmit = vi.fn() + renderForm({ onSubmit }) + + // Wait for medios to load + await waitFor(() => + expect(screen.getByRole('option', { name: 'Diario El Día' })).toBeInTheDocument(), + ) + + await userEvent.selectOptions(screen.getByLabelText(/medio/i), '1') + await userEvent.type(screen.getByLabelText(/código/i), 'CLAS99') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Sección') + await userEvent.selectOptions(screen.getByLabelText(/tipo de sección/i), 'clasificados') + await userEvent.click(screen.getByRole('button', { name: /crear sección/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + const firstArg = onSubmit.mock.calls[0][0] + expect(firstArg).toMatchObject({ + medioId: 1, + codigo: 'CLAS99', + nombre: 'Mi Sección', + tipo: 'clasificados', + }) + }) + }) +}) + +describe('SeccionForm — edit mode', () => { + it('código and medioId are disabled in edit mode', async () => { + renderForm({ initialData: sampleSeccion }) + const codigoInput = screen.getByLabelText(/código/i) as HTMLInputElement + expect(codigoInput.disabled).toBe(true) + const medioSelect = screen.getByLabelText(/medio/i) as HTMLSelectElement + expect(medioSelect.disabled).toBe(true) + }) + + it('pre-fills form with initialData values', async () => { + renderForm({ initialData: sampleSeccion }) + await waitFor(() => + expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('Clasificados Autos'), + ) + expect((screen.getByLabelText(/código/i) as HTMLInputElement).value).toBe('CLAS01') + }) + + it('shows "Guardar cambios" button in edit mode', () => { + renderForm({ initialData: sampleSeccion }) + expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/secciones/SeccionesFilters.test.tsx b/src/web/src/tests/features/secciones/SeccionesFilters.test.tsx new file mode 100644 index 0000000..18cc3d0 --- /dev/null +++ b/src/web/src/tests/features/secciones/SeccionesFilters.test.tsx @@ -0,0 +1,103 @@ +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 { SeccionesFilters } from '../../../features/secciones/components/SeccionesFilters' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockMedios = [ + { id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true }, + { id: 2, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true }, + { id: 3, codigo: 'WEB01', nombre: 'Portal Web', tipo: 3, plataformaEmpresaId: null, activo: true }, +] + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderFilters(handlers = { + onMedioIdChange: vi.fn(), + onTipoChange: vi.fn(), + onActivoChange: vi.fn(), + onSearchChange: vi.fn(), +}) { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 3 }), + ), + ) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + render( + + + + + , + ) + return handlers +} + +describe('SeccionesFilters', () => { + it('loads medios options from API', async () => { + renderFilters() + + await waitFor(() => + expect(screen.getByRole('option', { name: 'Diario El Día' })).toBeInTheDocument(), + ) + expect(screen.getByRole('option', { name: 'Radio AM' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Portal Web' })).toBeInTheDocument() + }) + + it('calls onMedioIdChange when medio is selected', async () => { + const handlers = renderFilters() + + await waitFor(() => + expect(screen.getByRole('option', { name: 'Diario El Día' })).toBeInTheDocument(), + ) + + await userEvent.selectOptions(screen.getByLabelText(/medio/i), '1') + expect(handlers.onMedioIdChange).toHaveBeenCalledWith(1) + }) + + it('calls onTipoChange when tipo is selected', async () => { + const handlers = renderFilters() + + await waitFor(() => + expect(screen.getByRole('option', { name: 'Clasificados' })).toBeInTheDocument(), + ) + + await userEvent.selectOptions(screen.getByLabelText(/tipo de sección/i), 'clasificados') + expect(handlers.onTipoChange).toHaveBeenCalledWith('clasificados') + }) + + it('calls onActivoChange when estado is selected', async () => { + const handlers = renderFilters() + await userEvent.selectOptions(screen.getByLabelText(/estado/i), 'true') + expect(handlers.onActivoChange).toHaveBeenCalledWith(true) + }) + + it('renders all tipo options', async () => { + renderFilters() + + await waitFor(() => + expect(screen.getByRole('option', { name: 'Clasificados' })).toBeInTheDocument(), + ) + expect(screen.getByRole('option', { name: 'Notables' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Suplementos' })).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/secciones/SeccionesListPage.test.tsx b/src/web/src/tests/features/secciones/SeccionesListPage.test.tsx new file mode 100644 index 0000000..578831f --- /dev/null +++ b/src/web/src/tests/features/secciones/SeccionesListPage.test.tsx @@ -0,0 +1,166 @@ +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 { SeccionesListPage } from '../../../features/secciones/pages/SeccionesListPage' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +const adminWithSecciones = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:secciones:gestionar'], + mustChangePassword: false, +} + +const userWithoutSecciones = { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: [], + mustChangePassword: false, +} + +const mockMedios = [ + { id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true }, +] + +function makeSecciones(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: i + 1, + medioId: 1, + codigo: `SEC${i + 1}`, + nombre: `Sección ${i + 1}`, + tipo: 'clasificados', + activo: true, + })) +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage(user = adminWithSecciones) { + useAuthStore.setState({ user }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + } /> + + + , + ) +} + +describe('SeccionesListPage', () => { + it('renders seed rows when API returns items', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/secciones`, () => + HttpResponse.json({ items: makeSecciones(3), page: 1, pageSize: 20, total: 3 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('SEC1')).toBeInTheDocument()) + expect(screen.getByText('SEC2')).toBeInTheDocument() + expect(screen.getByText('SEC3')).toBeInTheDocument() + }) + + it('shows empty state when items is empty', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/secciones`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByText(/sin resultados/i)).toBeInTheDocument(), + ) + }) + + it('hides "Nueva sección" button when user lacks permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/secciones`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage(userWithoutSecciones) + + await waitFor(() => + expect(screen.queryByRole('button', { name: /nueva sección/i })).not.toBeInTheDocument(), + ) + }) + + it('shows "Nueva sección" button when user has permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/secciones`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByRole('button', { name: /nueva sección/i })).toBeInTheDocument(), + ) + }) + + it('prev button disabled on first page', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/secciones`, () => + HttpResponse.json({ items: makeSecciones(3), page: 1, pageSize: 20, total: 3 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('SEC1')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled() + }) +})