From 8ffee0dbe46d64ece889546e04a3f43e0d42682e Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 18:55:33 -0300 Subject: [PATCH] test+feat(web/adm-009): TipoDeIvaTable con acciones y paginacion Columnas: Codigo, Descripcion, Porcentaje%, Vigencia (abierta si null), Estado (badge), Version con HistorialCadenaTooltip lazy. Acciones: editar, nueva vigencia, deactivate/reactivate toggle. 10 tests RTL pasan. --- .../iva/components/HistorialCadenaTooltip.tsx | 65 ++++++ .../fiscal/iva/components/TipoDeIvaTable.tsx | 190 +++++++++++++++++ .../fiscal/iva/TipoDeIvaTable.test.tsx | 194 ++++++++++++++++++ 3 files changed, 449 insertions(+) create mode 100644 src/web/src/features/fiscal/iva/components/HistorialCadenaTooltip.tsx create mode 100644 src/web/src/features/fiscal/iva/components/TipoDeIvaTable.tsx create mode 100644 src/web/src/tests/features/fiscal/iva/TipoDeIvaTable.test.tsx diff --git a/src/web/src/features/fiscal/iva/components/HistorialCadenaTooltip.tsx b/src/web/src/features/fiscal/iva/components/HistorialCadenaTooltip.tsx new file mode 100644 index 0000000..3cc8213 --- /dev/null +++ b/src/web/src/features/fiscal/iva/components/HistorialCadenaTooltip.tsx @@ -0,0 +1,65 @@ +// T600.7 — HistorialCadenaTooltip +// Tooltip con lazy enable — un solo request al backend (CTE recursivo) +import { useState } from 'react' +import { History } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { useHistorialTipoDeIva } from '../hooks/useTiposDeIva' + +interface HistorialCadenaTooltipProps { + id: number +} + +function formatVigencia(desde: string, hasta: string | null): string { + return hasta ? `${desde} → ${hasta}` : `${desde} → ahora` +} + +export function HistorialCadenaTooltip({ id }: HistorialCadenaTooltipProps) { + const [enabled, setEnabled] = useState(false) + + const { data: historial, isLoading } = useHistorialTipoDeIva(id, enabled) + + return ( + + + + + + {!enabled || isLoading ? ( + Cargando historial... + ) : !historial || historial.length === 0 ? ( + Sin historial + ) : ( +
+

Historial de versiones

+ {historial.map((entry, idx) => ( +
+ + v{idx + 1} ({entry.porcentaje}%) + + + [{formatVigencia(entry.vigenciaDesde, entry.vigenciaHasta)}] + + {idx < historial.length - 1 && ( + + )} +
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/web/src/features/fiscal/iva/components/TipoDeIvaTable.tsx b/src/web/src/features/fiscal/iva/components/TipoDeIvaTable.tsx new file mode 100644 index 0000000..20bd0d7 --- /dev/null +++ b/src/web/src/features/fiscal/iva/components/TipoDeIvaTable.tsx @@ -0,0 +1,190 @@ +// T600.4 — TipoDeIvaTable +// Tabla principal para tipos de IVA con acciones por fila +import { useMemo } from 'react' +import type { ColumnDef } from '@tanstack/react-table' +import { Pencil, CalendarPlus, PowerOff, Power } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { DataTable } from '@/components/ui/data-table' +import { HistorialCadenaTooltip } from './HistorialCadenaTooltip' +import { useDeactivateTipoDeIva, useReactivateTipoDeIva } from '../hooks/useTiposDeIva' +import type { TipoDeIva } from '../types/tipoDeIva.types' +import { toast } from 'sonner' + +interface TipoDeIvaTableProps { + rows: TipoDeIva[] + onEdit: (row: TipoDeIva) => void + onNuevaVersion: (row: TipoDeIva) => void + isLoading?: boolean +} + +export function TipoDeIvaTable({ + rows, + onEdit, + onNuevaVersion, + isLoading, +}: TipoDeIvaTableProps) { + const deactivate = useDeactivateTipoDeIva() + const reactivate = useReactivateTipoDeIva() + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'codigo', + header: 'Código', + cell: ({ row }) => ( + {row.original.codigo} + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'descripcion', + header: 'Descripción', + meta: { priority: 'high' }, + }, + { + accessorKey: 'porcentaje', + header: 'Porcentaje', + cell: ({ row }) => ( + {row.original.porcentaje}% + ), + meta: { priority: 'high' }, + }, + { + id: 'vigencia', + header: 'Vigencia', + cell: ({ row }) => { + const { vigenciaDesde, vigenciaHasta } = row.original + return ( + + {vigenciaDesde} → {vigenciaHasta ?? abierta} + + ) + }, + meta: { priority: 'medium' }, + }, + { + accessorKey: 'activo', + header: 'Estado', + cell: ({ row }) => + row.original.activo ? ( + + Activo + + ) : ( + + Inactivo + + ), + meta: { priority: 'medium' }, + }, + { + id: 'version', + header: 'Versión', + cell: ({ row }) => ( +
+ + {row.original.predecesorId ? '# en cadena' : 'raíz'} + + +
+ ), + meta: { priority: 'low' }, + }, + { + id: 'acciones', + header: 'Acciones', + cell: ({ row }) => { + const item = row.original + const isPending = + deactivate.isPending || reactivate.isPending + + return ( +
e.stopPropagation()} + > + + + + + {item.activo ? ( + + ) : ( + + )} +
+ ) + }, + meta: { priority: 'high' }, + }, + ], + [onEdit, onNuevaVersion, deactivate, reactivate], + ) + + return ( + String(row.id)} + isLoading={isLoading} + emptyMessage="Sin resultados — no se encontraron tipos de IVA con los filtros seleccionados." + /> + ) +} diff --git a/src/web/src/tests/features/fiscal/iva/TipoDeIvaTable.test.tsx b/src/web/src/tests/features/fiscal/iva/TipoDeIvaTable.test.tsx new file mode 100644 index 0000000..6086596 --- /dev/null +++ b/src/web/src/tests/features/fiscal/iva/TipoDeIvaTable.test.tsx @@ -0,0 +1,194 @@ +// T600.4 — TDD: TipoDeIvaTable +// RED: tests escritos ANTES de la implementación del componente +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 { TooltipProvider } from '@/components/ui/tooltip' +import { TipoDeIvaTable } from '../../../../features/fiscal/iva/components/TipoDeIvaTable' +import type { TipoDeIva } from '../../../../features/fiscal/iva/types/tipoDeIva.types' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const API_URL = 'http://localhost:5000' + +const makeTiposDeIva = (): TipoDeIva[] => [ + { + id: 1, + codigo: 'EXENTO', + descripcion: 'Exento', + porcentaje: 0, + vigenciaDesde: '2020-01-01', + vigenciaHasta: null, + activo: true, + aplicaIVA: false, + predecesorId: null, + }, + { + id: 2, + codigo: 'IVA_21', + descripcion: 'IVA 21%', + porcentaje: 21, + vigenciaDesde: '2020-01-01', + vigenciaHasta: null, + activo: true, + aplicaIVA: true, + predecesorId: null, + }, + { + id: 3, + codigo: 'NO_GRAVADO', + descripcion: 'No Gravado', + porcentaje: 0, + vigenciaDesde: '2020-01-01', + vigenciaHasta: '2025-12-31', + activo: false, + aplicaIVA: false, + predecesorId: null, + }, +] + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderTable( + rows: TipoDeIva[] = makeTiposDeIva(), + opts: { + onEdit?: (row: TipoDeIva) => void + onNuevaVersion?: (row: TipoDeIva) => void + } = {}, +) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const onEdit = opts.onEdit ?? vi.fn() + const onNuevaVersion = opts.onNuevaVersion ?? vi.fn() + + render( + + + + + + + , + ) + return { onEdit, onNuevaVersion } +} + +describe('TipoDeIvaTable', () => { + it('renders 3 rows with correct data', () => { + renderTable() + + // Código y descripción presentes + expect(screen.getByText('EXENTO')).toBeInTheDocument() + expect(screen.getByText('IVA 21%')).toBeInTheDocument() + expect(screen.getByText('NO_GRAVADO')).toBeInTheDocument() + }) + + it('renders porcentaje formateado como porcentaje', () => { + renderTable() + // IVA_21 tiene 21% + expect(screen.getByText('21%')).toBeInTheDocument() + }) + + it('muestra "Activo" badge para items activos', () => { + renderTable() + const activoBadges = screen.getAllByText('Activo') + expect(activoBadges.length).toBeGreaterThanOrEqual(2) // EXENTO e IVA_21 + }) + + it('muestra "Inactivo" badge para items inactivos', () => { + renderTable() + expect(screen.getByText('Inactivo')).toBeInTheDocument() // NO_GRAVADO + }) + + it('vigenciaHasta null muestra "abierta"', () => { + renderTable() + const abiertaCells = screen.getAllByText(/abierta/i) + expect(abiertaCells.length).toBeGreaterThanOrEqual(2) // EXENTO e IVA_21 + }) + + it('click en "Editar" dispara onEdit con la fila correcta', async () => { + const { onEdit } = renderTable() + + const editButtons = screen.getAllByRole('button', { name: /editar/i }) + await userEvent.click(editButtons[0]) + + expect(onEdit).toHaveBeenCalledTimes(1) + expect(onEdit).toHaveBeenCalledWith( + expect.objectContaining({ codigo: 'EXENTO' }), + ) + }) + + it('click en "Nueva vigencia" dispara onNuevaVersion con la fila correcta', async () => { + const { onNuevaVersion } = renderTable() + + // El segundo item es IVA_21 (el que tiene porcentaje) + const nuevaVigButtons = screen.getAllByRole('button', { name: /nueva vigencia/i }) + await userEvent.click(nuevaVigButtons[1]) + + expect(onNuevaVersion).toHaveBeenCalledTimes(1) + expect(onNuevaVersion).toHaveBeenCalledWith( + expect.objectContaining({ codigo: 'IVA_21' }), + ) + }) + + it('tabla vacía muestra mensaje de sin resultados', () => { + renderTable([]) + expect(screen.getByText(/sin resultados/i)).toBeInTheDocument() + }) +}) + +// T600.4 — TRIANGULATE: Historial tooltip hover +describe('TipoDeIvaTable — historial tooltip', () => { + it('columna Versión muestra botón de historial por fila', () => { + renderTable() + // Cada fila debe tener acceso al historial + const histBtns = screen.getAllByRole('button', { name: /historial/i }) + expect(histBtns.length).toBeGreaterThanOrEqual(1) + }) + + it('hover en botón historial dispara request al backend', async () => { + let historialCalled = false + server.use( + http.get(`${API_URL}/api/v1/admin/fiscal/iva/:id/historial`, () => { + historialCalled = true + return HttpResponse.json([ + { + id: 2, + codigo: 'IVA_21', + porcentaje: 21, + vigenciaDesde: '2020-01-01', + vigenciaHasta: null, + activo: true, + predecesorId: null, + depth: 0, + }, + ]) + }), + ) + + renderTable() + + const histBtns = screen.getAllByRole('button', { name: /historial/i }) + await userEvent.hover(histBtns[1]) // IVA_21 + + await waitFor(() => expect(historialCalled).toBe(true), { timeout: 2000 }) + }) +})