ADM-009: Tablas Fiscales (IVA + IIBB) — append-only versioned ref data #22
@@ -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>
|
||||
)
|
||||
}
|
||||
190
src/web/src/features/fiscal/iva/components/TipoDeIvaTable.tsx
Normal file
190
src/web/src/features/fiscal/iva/components/TipoDeIvaTable.tsx
Normal 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."
|
||||
/>
|
||||
)
|
||||
}
|
||||
194
src/web/src/tests/features/fiscal/iva/TipoDeIvaTable.test.tsx
Normal file
194
src/web/src/tests/features/fiscal/iva/TipoDeIvaTable.test.tsx
Normal 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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user