From 038a2ade7000e151bd48ee435dcaec4be2f604de Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 18:55:38 -0300 Subject: [PATCH] test+feat(web/adm-009): TipoDeIvaFormModal sin campo Porcentaje MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modal de edicion solo cosmeticos (Codigo, Descripcion, AplicaIVA, Activo). Campo Porcentaje ausente en modo edit — verificado con queryByLabelText null [REQ-UI-003]. Modo create incluye Porcentaje inicial + VigenciaDesde. 10 tests RTL pasan. --- .../iva/components/TipoDeIvaFormModal.tsx | 306 ++++++++++++++++++ .../fiscal/iva/TipoDeIvaFormModal.test.tsx | 133 ++++++++ 2 files changed, 439 insertions(+) create mode 100644 src/web/src/features/fiscal/iva/components/TipoDeIvaFormModal.tsx create mode 100644 src/web/src/tests/features/fiscal/iva/TipoDeIvaFormModal.test.tsx diff --git a/src/web/src/features/fiscal/iva/components/TipoDeIvaFormModal.tsx b/src/web/src/features/fiscal/iva/components/TipoDeIvaFormModal.tsx new file mode 100644 index 0000000..b3164cc --- /dev/null +++ b/src/web/src/features/fiscal/iva/components/TipoDeIvaFormModal.tsx @@ -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 + +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({ + 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 ( + { if (!v) onClose() }}> + + + + {isEdit ? 'Editar tipo de IVA' : 'Crear tipo de IVA'} + + + +
+ + {backendError && ( + + + {backendError} + + )} + + {/* Código */} + ( + + Código + + + + + + )} + /> + + {/* Descripción */} + ( + + Descripción + + + + + + )} + /> + + {/* Solo en modo CREATE: porcentaje y vigenciaDesde */} + {!isEdit && ( + <> + ( + + Porcentaje inicial + + + + + + )} + /> + + ( + + Vigencia desde + + + + + + )} + /> + + )} + + {/* Nota informativa en modo EDIT: porcentaje no se puede cambiar aquí */} + {isEdit && ( +

+ 💡 Para cambiar el porcentaje usá el botón Nueva vigencia en la tabla. +

+ )} + + + + + + + +
+
+ ) +} diff --git a/src/web/src/tests/features/fiscal/iva/TipoDeIvaFormModal.test.tsx b/src/web/src/tests/features/fiscal/iva/TipoDeIvaFormModal.test.tsx new file mode 100644 index 0000000..53edf89 --- /dev/null +++ b/src/web/src/tests/features/fiscal/iva/TipoDeIvaFormModal.test.tsx @@ -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( + + + + + , + ) + 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) + }) +})