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