ADM-009: Tablas Fiscales (IVA + IIBB) — append-only versioned ref data #22

Merged
dmolinari merged 36 commits from feature/ADM-009 into main 2026-04-18 11:45:13 +00:00
3 changed files with 449 additions and 0 deletions
Showing only changes of commit 8ffee0dbe4 - Show all commits

View File

@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="historial"
onMouseEnter={() => setEnabled(true)}
className="h-7 w-7 p-0"
>
<History className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-sm">
{!enabled || isLoading ? (
<span className="text-xs text-muted-foreground">Cargando historial...</span>
) : !historial || historial.length === 0 ? (
<span className="text-xs text-muted-foreground">Sin historial</span>
) : (
<div className="space-y-1">
<p className="text-xs font-medium mb-1">Historial de versiones</p>
{historial.map((entry, idx) => (
<div key={entry.id} className="text-xs">
<span className="font-mono font-medium">
v{idx + 1} ({entry.porcentaje}%)
</span>
<span className="text-muted-foreground ml-1">
[{formatVigencia(entry.vigenciaDesde, entry.vigenciaHasta)}]
</span>
{idx < historial.length - 1 && (
<span className="text-muted-foreground"> </span>
)}
</div>
))}
</div>
)}
</TooltipContent>
</Tooltip>
)
}

View File

@@ -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<ColumnDef<TipoDeIva>[]>(
() => [
{
accessorKey: 'codigo',
header: 'Código',
cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.codigo}</span>
),
meta: { priority: 'high' },
},
{
accessorKey: 'descripcion',
header: 'Descripción',
meta: { priority: 'high' },
},
{
accessorKey: 'porcentaje',
header: 'Porcentaje',
cell: ({ row }) => (
<span className="tabular-nums">{row.original.porcentaje}%</span>
),
meta: { priority: 'high' },
},
{
id: 'vigencia',
header: 'Vigencia',
cell: ({ row }) => {
const { vigenciaDesde, vigenciaHasta } = row.original
return (
<span className="text-sm text-muted-foreground">
{vigenciaDesde} {vigenciaHasta ?? <em>abierta</em>}
</span>
)
},
meta: { priority: 'medium' },
},
{
accessorKey: 'activo',
header: 'Estado',
cell: ({ row }) =>
row.original.activo ? (
<Badge
variant="secondary"
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
>
Activo
</Badge>
) : (
<Badge
variant="secondary"
className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
>
Inactivo
</Badge>
),
meta: { priority: 'medium' },
},
{
id: 'version',
header: 'Versión',
cell: ({ row }) => (
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
{row.original.predecesorId ? '# en cadena' : 'raíz'}
</span>
<HistorialCadenaTooltip id={row.original.id} />
</div>
),
meta: { priority: 'low' },
},
{
id: 'acciones',
header: 'Acciones',
cell: ({ row }) => {
const item = row.original
const isPending =
deactivate.isPending || reactivate.isPending
return (
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="sm"
aria-label="editar"
title="Editar"
className="h-7 w-7 p-0"
onClick={() => onEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
aria-label="nueva vigencia"
title="Nueva vigencia"
className="h-7 w-7 p-0"
onClick={() => onNuevaVersion(item)}
>
<CalendarPlus className="h-4 w-4" />
</Button>
{item.activo ? (
<Button
variant="ghost"
size="sm"
aria-label="desactivar"
title="Desactivar"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
disabled={isPending}
onClick={() => {
deactivate.mutate(item.id, {
onSuccess: () => toast.success(`${item.codigo} desactivado`),
onError: () =>
toast.error('Error al desactivar. Intentá de nuevo.'),
})
}}
>
<PowerOff className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
aria-label="reactivar"
title="Reactivar"
className="h-7 w-7 p-0"
disabled={isPending}
onClick={() => {
reactivate.mutate(item.id, {
onSuccess: () => toast.success(`${item.codigo} reactivado`),
onError: () =>
toast.error('Error al reactivar. Intentá de nuevo.'),
})
}}
>
<Power className="h-4 w-4" />
</Button>
)}
</div>
)
},
meta: { priority: 'high' },
},
],
[onEdit, onNuevaVersion, deactivate, reactivate],
)
return (
<DataTable
columns={columns}
data={rows}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
emptyMessage="Sin resultados — no se encontraron tipos de IVA con los filtros seleccionados."
/>
)
}

View File

@@ -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(
<QueryClientProvider client={qc}>
<MemoryRouter>
<TooltipProvider>
<TipoDeIvaTable
rows={rows}
onEdit={onEdit}
onNuevaVersion={onNuevaVersion}
/>
</TooltipProvider>
</MemoryRouter>
</QueryClientProvider>,
)
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 })
})
})