diff --git a/src/web/src/features/fiscal/iva/components/NuevaVigenciaModal.tsx b/src/web/src/features/fiscal/iva/components/NuevaVigenciaModal.tsx new file mode 100644 index 0000000..bb75e37 --- /dev/null +++ b/src/web/src/features/fiscal/iva/components/NuevaVigenciaModal.tsx @@ -0,0 +1,252 @@ +// T600.6 — NuevaVigenciaModal +// Modal para crear una nueva vigencia/versión de un TipoDeIva +// Color distinto al modal de Editar: usa tokens --warning-bg para diferenciación visual +import { useEffect } from 'react' +import { useForm, useWatch } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { isAxiosError } from 'axios' +import { AlertCircle, TriangleAlert } 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 { useNuevaVersionTipoDeIva } from '../hooks/useTiposDeIva' +import type { TipoDeIva } from '../types/tipoDeIva.types' +import { toast } from 'sonner' + +const formSchema = z.object({ + porcentaje: z.coerce + .number({ invalid_type_error: 'Debe ser un número' }) + .min(0, 'Mínimo 0%') + .max(100, 'Máximo 100%'), + vigenciaDesde: z + .string() + .min(1, 'La vigencia desde es requerida') + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato: YYYY-MM-DD'), +}) + +type FormValues = z.infer + +interface NuevaVigenciaModalProps { + open: boolean + item: TipoDeIva | null + onClose: () => void + onSuccess: () => void +} + +/** Devuelve la fecha anterior (vigenciaDesde - 1 día) como string "yyyy-MM-dd" */ +function fechaCierre(vigenciaDesde: string): string { + if (!vigenciaDesde || !/^\d{4}-\d{2}-\d{2}$/.test(vigenciaDesde)) return '—' + const d = new Date(vigenciaDesde + 'T00:00:00') + d.setDate(d.getDate() - 1) + return d.toISOString().slice(0, 10) +} + +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 === 'predecesora_ya_cerrada') { + return 'La versión actual ya fue cerrada. No se puede crear una nueva versión sobre ella.' + } + if (data.error === 'vigencia_desde_invalida') { + return data.message ?? 'La fecha de vigencia debe ser posterior a la versión actual.' + } + return data.message ?? data.error ?? 'Error al crear versión' + } + return 'Error al crear versión. Intentá de nuevo.' +} + +export function NuevaVigenciaModal({ + open, + item, + onClose, + onSuccess, +}: NuevaVigenciaModalProps) { + const mutation = useNuevaVersionTipoDeIva() + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + porcentaje: '' as unknown as number, + vigenciaDesde: '', + }, + mode: 'onChange', + }) + + const watchedPorcentaje = useWatch({ control: form.control, name: 'porcentaje' }) + const watchedVigencia = useWatch({ control: form.control, name: 'vigenciaDesde' }) + + const formState = form.formState + const isFormValid = formState.isValid && !formState.isValidating + + useEffect(() => { + if (open) { + form.reset({ + porcentaje: '' as unknown as number, + vigenciaDesde: '', + }) + mutation.reset() + } + }, [open]) // eslint-disable-line react-hooks/exhaustive-deps + + const backendError = resolveBackendError(mutation.error) + const showPreview = + isFormValid && + watchedPorcentaje !== undefined && + watchedVigencia?.match(/^\d{4}-\d{2}-\d{2}$/) + + function handleSubmit(values: FormValues) { + if (!item) return + mutation.mutate( + { + id: item.id, + body: { + porcentaje: values.porcentaje, + vigenciaDesde: values.vigenciaDesde, + }, + }, + { + onSuccess: () => { + toast.success(`Nueva versión de ${item.codigo} creada`) + onSuccess() + onClose() + }, + }, + ) + } + + return ( + { if (!v) onClose() }}> + + + + + Nueva vigencia — {item?.codigo} + + + + {/* Banner de advertencia — usa token --warning-bg */} +
+ Esta acción crea una nueva versión del tipo de IVA. La versión actual quedará + cerrada con la fecha anterior a la nueva vigencia. +
+ +
+ + {backendError && ( + + + {backendError} + + )} + + {/* Porcentaje nuevo */} + ( + + Porcentaje nuevo + + + + + + )} + /> + + {/* Vigencia desde */} + ( + + Vigencia desde + + + + + + )} + /> + + {/* Preview — visible solo cuando form es válido */} + {showPreview && item && ( +
+

Vista previa:

+

+ Nueva versión {item.codigo} con alícuota{' '} + {watchedPorcentaje}% vigente desde{' '} + {watchedVigencia}. +

+

+ Versión actual ({item.porcentaje}%) quedará cerrada el{' '} + {fechaCierre(watchedVigencia)}. +

+

+ Esta acción no se puede deshacer. +

+
+ )} + + + + + + + +
+
+ ) +} diff --git a/src/web/src/tests/features/fiscal/iva/NuevaVigenciaModal.test.tsx b/src/web/src/tests/features/fiscal/iva/NuevaVigenciaModal.test.tsx new file mode 100644 index 0000000..ccc688f --- /dev/null +++ b/src/web/src/tests/features/fiscal/iva/NuevaVigenciaModal.test.tsx @@ -0,0 +1,192 @@ +// T600.6 — TDD: NuevaVigenciaModal +// Tests: preview con fechas correctas + botón disabled si form inválido [REQ-UI-004] +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 { NuevaVigenciaModal } from '../../../../features/fiscal/iva/components/NuevaVigenciaModal' +import type { TipoDeIva } from '../../../../features/fiscal/iva/types/tipoDeIva.types' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, + Toaster: () => null, +})) + +const API_URL = 'http://localhost:5000' + +const sampleTipoDeIva: TipoDeIva = { + id: 2, + codigo: 'IVA_21', + descripcion: 'IVA 21%', + porcentaje: 21, + vigenciaDesde: '2020-01-01', + vigenciaHasta: null, + activo: true, + aplicaIVA: true, + predecesorId: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +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('NuevaVigenciaModal — botón disabled cuando form inválido', () => { + it('botón "Confirmar" está disabled cuando form está vacío', () => { + renderModal() + const confirmBtn = screen.getByRole('button', { name: /confirmar/i }) + expect(confirmBtn).toBeDisabled() + }) + + it('botón "Confirmar" está habilitado cuando form es válido', async () => { + renderModal() + + const porcentajeInput = screen.getByLabelText(/porcentaje nuevo/i) + await userEvent.clear(porcentajeInput) + await userEvent.type(porcentajeInput, '23.5') + + const vigenciaInput = screen.getByLabelText(/vigencia desde/i) + await userEvent.type(vigenciaInput, '2026-05-01') + + await waitFor(() => { + const confirmBtn = screen.getByRole('button', { name: /confirmar/i }) + expect(confirmBtn).not.toBeDisabled() + }) + }) +}) + +describe('NuevaVigenciaModal — preview con fechas correctas [REQ-UI-004]', () => { + it('muestra preview con porcentaje correcto al completar form', async () => { + renderModal() + + const porcentajeInput = screen.getByLabelText(/porcentaje nuevo/i) + await userEvent.clear(porcentajeInput) + await userEvent.type(porcentajeInput, '23.5') + + const vigenciaInput = screen.getByLabelText(/vigencia desde/i) + await userEvent.type(vigenciaInput, '2026-05-01') + + // Preview debe mostrar el nuevo porcentaje + await waitFor(() => + expect(screen.getByText(/23\.5%/)).toBeInTheDocument(), + ) + }) + + it('muestra en el preview la versión actual (IVA_21 con 21%)', async () => { + renderModal() + + const porcentajeInput = screen.getByLabelText(/porcentaje nuevo/i) + await userEvent.clear(porcentajeInput) + await userEvent.type(porcentajeInput, '23.5') + + const vigenciaInput = screen.getByLabelText(/vigencia desde/i) + await userEvent.type(vigenciaInput, '2026-05-01') + + // Preview debe mencionar el porcentaje actual (21%) + await waitFor(() => + expect(screen.getByText(/21%/)).toBeInTheDocument(), + ) + }) + + it('preview muestra fecha de cierre = vigenciaDesde - 1 día', async () => { + renderModal() + + const porcentajeInput = screen.getByLabelText(/porcentaje nuevo/i) + await userEvent.clear(porcentajeInput) + await userEvent.type(porcentajeInput, '23.5') + + const vigenciaInput = screen.getByLabelText(/vigencia desde/i) + await userEvent.type(vigenciaInput, '2026-05-01') + + // La versión anterior cierra el día anterior → 2026-04-30 + await waitFor(() => + expect(screen.getByText(/2026-04-30/)).toBeInTheDocument(), + ) + }) +}) + +describe('NuevaVigenciaModal — submit llama mutation', () => { + it('click confirmar con form válido dispara request al backend', async () => { + let requestBody: unknown = null + + server.use( + http.post(`${API_URL}/api/v1/admin/fiscal/iva/:id/nueva-version`, async ({ request }) => { + requestBody = await request.json() + return HttpResponse.json( + { + predecesorId: 2, + nuevaId: 10, + nuevoPorcentaje: 23.5, + vigenciaDesde: '2026-05-01', + predecesorVigenciaHasta: '2026-04-30', + }, + { status: 201 }, + ) + }), + ) + + const { onSuccess } = renderModal() + + const porcentajeInput = screen.getByLabelText(/porcentaje nuevo/i) + await userEvent.clear(porcentajeInput) + await userEvent.type(porcentajeInput, '23.5') + + const vigenciaInput = screen.getByLabelText(/vigencia desde/i) + await userEvent.type(vigenciaInput, '2026-05-01') + + const confirmBtn = await screen.findByRole('button', { name: /confirmar/i }) + await waitFor(() => expect(confirmBtn).not.toBeDisabled()) + await userEvent.click(confirmBtn) + + await waitFor(() => { + expect(requestBody).toMatchObject({ + porcentaje: 23.5, + vigenciaDesde: '2026-05-01', + }) + expect(onSuccess).toHaveBeenCalled() + }) + }) +}) + +// T600.10 — 409 inmutable_usar_nueva_version toast +describe('NuevaVigenciaModal — 409 handling', () => { + it('botón Cancelar llama onClose', async () => { + const { onClose } = renderModal() + await userEvent.click(screen.getByRole('button', { name: /cancelar/i })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +})