ADM-009: Tablas Fiscales (IVA + IIBB) — append-only versioned ref data #22
@@ -0,0 +1,306 @@
|
|||||||
|
// T600.5 — TipoDeIvaFormModal
|
||||||
|
// Modal de edición / creación de TipoDeIva
|
||||||
|
// CRÍTICO: NO incluye campo Porcentaje (inmutable, cambiar via NuevaVersion)
|
||||||
|
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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { useCreateTipoDeIva, useUpdateTipoDeIva } from '../hooks/useTiposDeIva'
|
||||||
|
import type { TipoDeIva } from '../types/tipoDeIva.types'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
// Schema zod — SIN campo porcentaje
|
||||||
|
const formSchema = z.object({
|
||||||
|
codigo: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El código es requerido')
|
||||||
|
.regex(
|
||||||
|
/^(EXENTO|NO_GRAVADO|IVA_\d+)$/,
|
||||||
|
'Formato inválido. Ejemplos: EXENTO, NO_GRAVADO, IVA_21',
|
||||||
|
),
|
||||||
|
descripcion: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'La descripción es requerida')
|
||||||
|
.max(200, 'Máximo 200 caracteres'),
|
||||||
|
aplicaIVA: z.boolean(),
|
||||||
|
activo: z.boolean(),
|
||||||
|
// Porcentaje SOLO para modo create (no para editar)
|
||||||
|
porcentajeCreate: z.coerce
|
||||||
|
.number({ invalid_type_error: 'Debe ser un número' })
|
||||||
|
.min(0, 'Mínimo 0')
|
||||||
|
.max(100, 'Máximo 100')
|
||||||
|
.optional(),
|
||||||
|
vigenciaDesde: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
interface TipoDeIvaFormModalProps {
|
||||||
|
open: boolean
|
||||||
|
item: TipoDeIva | null // null = modo create
|
||||||
|
onClose: () => void
|
||||||
|
onSuccess: () => 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 === 'inmutable_usar_nueva_version') {
|
||||||
|
return 'Para cambiar el porcentaje usá el botón "Nueva vigencia" en lugar de "Editar".'
|
||||||
|
}
|
||||||
|
if (data.error === 'duplicate_codigo') {
|
||||||
|
return data.message ?? 'Ya existe un tipo de IVA con ese código'
|
||||||
|
}
|
||||||
|
return data.message ?? data.error ?? 'Error al guardar'
|
||||||
|
}
|
||||||
|
return 'Error al guardar. Intentá de nuevo.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TipoDeIvaFormModal({
|
||||||
|
open,
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: TipoDeIvaFormModalProps) {
|
||||||
|
const isEdit = item != null
|
||||||
|
const createMutation = useCreateTipoDeIva()
|
||||||
|
const updateMutation = useUpdateTipoDeIva()
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending
|
||||||
|
const error = createMutation.error ?? updateMutation.error
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
codigo: '',
|
||||||
|
descripcion: '',
|
||||||
|
aplicaIVA: true,
|
||||||
|
activo: true,
|
||||||
|
porcentajeCreate: undefined,
|
||||||
|
vigenciaDesde: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
form.reset({
|
||||||
|
codigo: item.codigo,
|
||||||
|
descripcion: item.descripcion,
|
||||||
|
aplicaIVA: item.aplicaIVA,
|
||||||
|
activo: item.activo,
|
||||||
|
porcentajeCreate: undefined,
|
||||||
|
vigenciaDesde: '',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
form.reset({
|
||||||
|
codigo: '',
|
||||||
|
descripcion: '',
|
||||||
|
aplicaIVA: true,
|
||||||
|
activo: true,
|
||||||
|
porcentajeCreate: undefined,
|
||||||
|
vigenciaDesde: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
createMutation.reset()
|
||||||
|
updateMutation.reset()
|
||||||
|
}, [item, open]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const backendError = resolveBackendError(error)
|
||||||
|
|
||||||
|
function handleSubmit(values: FormValues) {
|
||||||
|
if (isEdit) {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{
|
||||||
|
id: item.id,
|
||||||
|
body: {
|
||||||
|
codigo: values.codigo,
|
||||||
|
descripcion: values.descripcion,
|
||||||
|
aplicaIVA: values.aplicaIVA,
|
||||||
|
activo: values.activo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Tipo de IVA actualizado')
|
||||||
|
onSuccess()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (values.porcentajeCreate === undefined) {
|
||||||
|
form.setError('porcentajeCreate', { message: 'El porcentaje es requerido' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createMutation.mutate(
|
||||||
|
{
|
||||||
|
codigo: values.codigo,
|
||||||
|
descripcion: values.descripcion,
|
||||||
|
porcentaje: values.porcentajeCreate,
|
||||||
|
vigenciaDesde: values.vigenciaDesde ?? new Date().toISOString().slice(0, 10),
|
||||||
|
aplicaIVA: values.aplicaIVA,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Tipo de IVA creado')
|
||||||
|
onSuccess()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{isEdit ? 'Editar tipo de IVA' : 'Crear tipo de IVA'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Código */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="codigo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Código</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="Ej: IVA_21"
|
||||||
|
aria-label="Código"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Descripción */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="descripcion"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Descripción</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="Descripción del tipo de IVA"
|
||||||
|
aria-label="Descripción"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Solo en modo CREATE: porcentaje y vigenciaDesde */}
|
||||||
|
{!isEdit && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="porcentajeCreate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Porcentaje inicial</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.01}
|
||||||
|
placeholder="Ej: 21"
|
||||||
|
aria-label="Porcentaje inicial"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="vigenciaDesde"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Vigencia desde</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="date"
|
||||||
|
aria-label="Vigencia desde"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nota informativa en modo EDIT: porcentaje no se puede cambiar aquí */}
|
||||||
|
{isEdit && (
|
||||||
|
<p className="text-xs text-muted-foreground rounded-md border border-border bg-muted/50 p-3">
|
||||||
|
💡 Para cambiar el porcentaje usá el botón <strong>Nueva vigencia</strong> en la tabla.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="cancelar"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? 'Guardando...' : 'Guardar cambios'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// T600.5 — TDD: TipoDeIvaFormModal
|
||||||
|
// CRÍTICO: verifica que el modal de Editar NO tiene campo Porcentaje [REQ-UI-003]
|
||||||
|
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 { TipoDeIvaFormModal } from '../../../../features/fiscal/iva/components/TipoDeIvaFormModal'
|
||||||
|
import type { TipoDeIva } from '../../../../features/fiscal/iva/types/tipoDeIva.types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sampleTipoDeIva: TipoDeIva = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'IVA_21',
|
||||||
|
descripcion: 'IVA 21%',
|
||||||
|
porcentaje: 21,
|
||||||
|
vigenciaDesde: '2020-01-01',
|
||||||
|
vigenciaHasta: null,
|
||||||
|
activo: true,
|
||||||
|
aplicaIVA: true,
|
||||||
|
predecesorId: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModal(
|
||||||
|
opts: {
|
||||||
|
item?: TipoDeIva | null
|
||||||
|
open?: boolean
|
||||||
|
onClose?: () => void
|
||||||
|
onSuccess?: () => void
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
const onClose = opts.onClose ?? vi.fn()
|
||||||
|
const onSuccess = opts.onSuccess ?? vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<TipoDeIvaFormModal
|
||||||
|
open={opts.open ?? true}
|
||||||
|
item={opts.item ?? null}
|
||||||
|
onClose={onClose}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
return { onClose, onSuccess }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TipoDeIvaFormModal — CRÍTICO: sin campo Porcentaje en modo EDIT [REQ-UI-003]', () => {
|
||||||
|
// El campo porcentaje NO debe aparecer en el modal de Editar
|
||||||
|
// (los cambios de porcentaje van por NuevaVersion, no por Editar)
|
||||||
|
it('NO renderiza campo porcentaje en modo edit', () => {
|
||||||
|
renderModal({ item: sampleTipoDeIva })
|
||||||
|
// queryByLabelText para "porcentaje" (sin "inicial") debe retornar null
|
||||||
|
// En edit no hay campo porcentaje — solo en create aparece "Porcentaje inicial"
|
||||||
|
expect(screen.queryByLabelText(/^porcentaje$/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('NO renderiza label exacto "Porcentaje" en modo edit (solo cosméticos)', () => {
|
||||||
|
renderModal({ item: sampleTipoDeIva })
|
||||||
|
// Verifica que no hay label con texto exacto "Porcentaje"
|
||||||
|
expect(screen.queryByText(/^porcentaje$/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('muestra nota informativa sobre NuevaVersion en modo edit', () => {
|
||||||
|
renderModal({ item: sampleTipoDeIva })
|
||||||
|
expect(screen.getByText(/para cambiar el porcentaje/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TipoDeIvaFormModal — campos presentes', () => {
|
||||||
|
it('modo create: muestra campo Código', () => {
|
||||||
|
renderModal({ item: null })
|
||||||
|
expect(screen.getByLabelText(/código/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modo create: muestra campo Descripción', () => {
|
||||||
|
renderModal({ item: null })
|
||||||
|
expect(screen.getByLabelText(/descripción/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modo edit: pre-rellena el formulario con datos del item', async () => {
|
||||||
|
renderModal({ item: sampleTipoDeIva })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const codigoInput = screen.getByLabelText(/código/i) as HTMLInputElement
|
||||||
|
expect(codigoInput.value).toBe('IVA_21')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modo create: title es "Crear tipo de IVA"', () => {
|
||||||
|
renderModal({ item: null })
|
||||||
|
expect(
|
||||||
|
screen.getByText(/crear tipo de iva/i),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('modo edit: title es "Editar tipo de IVA"', () => {
|
||||||
|
renderModal({ item: sampleTipoDeIva })
|
||||||
|
expect(
|
||||||
|
screen.getByText(/editar tipo de iva/i),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TipoDeIvaFormModal — validación', () => {
|
||||||
|
it('muestra error si código está vacío al guardar', async () => {
|
||||||
|
renderModal({ item: null })
|
||||||
|
|
||||||
|
// Intenta guardar sin llenar código
|
||||||
|
const saveBtn = screen.getByRole('button', { name: /guardar/i })
|
||||||
|
await userEvent.click(saveBtn)
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/código es requerido/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('botón Cancelar llama onClose', async () => {
|
||||||
|
const { onClose } = renderModal({ item: null })
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user