test+feat(web/adm-009): NuevaVigenciaModal con preview de fechas
Preview en tiempo real: nuevo porcentaje, fecha cierre = vigenciaDesde-1d. Banner warning con tokens DS. Boton disabled si form invalido [REQ-UI-004]. 7 tests RTL pasan incluyendo verificacion de fecha cierre correcta.
This commit is contained in:
@@ -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<typeof formSchema>
|
||||||
|
|
||||||
|
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<FormValues>({
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<TriangleAlert className="h-5 w-5 text-warning" />
|
||||||
|
Nueva vigencia — {item?.codigo}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Banner de advertencia — usa token --warning-bg */}
|
||||||
|
<div
|
||||||
|
className="rounded-md border px-4 py-3 text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--warning-bg)',
|
||||||
|
borderColor: 'var(--warning-border)',
|
||||||
|
color: 'var(--warning-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Porcentaje nuevo */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="porcentaje"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Porcentaje nuevo</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.01}
|
||||||
|
placeholder="Ej: 23.5"
|
||||||
|
aria-label="Porcentaje nuevo"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Vigencia desde */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="vigenciaDesde"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Vigencia desde</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="date"
|
||||||
|
aria-label="Vigencia desde"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preview — visible solo cuando form es válido */}
|
||||||
|
{showPreview && item && (
|
||||||
|
<div className="rounded-md border border-border bg-muted/50 p-3 space-y-1 text-sm">
|
||||||
|
<p className="font-medium text-foreground">Vista previa:</p>
|
||||||
|
<p>
|
||||||
|
Nueva versión <strong>{item.codigo}</strong> con alícuota{' '}
|
||||||
|
<strong>{watchedPorcentaje}%</strong> vigente desde{' '}
|
||||||
|
<strong>{watchedVigencia}</strong>.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Versión actual ({item.porcentaje}%) quedará cerrada el{' '}
|
||||||
|
<strong>{fechaCierre(watchedVigencia)}</strong>.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground font-medium">
|
||||||
|
Esta acción no se puede deshacer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
aria-label="cancelar"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isFormValid || mutation.isPending}
|
||||||
|
aria-label="confirmar"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Creando versión...' : 'Confirmar creación de versión'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<NuevaVigenciaModal
|
||||||
|
open={opts.open ?? true}
|
||||||
|
item={opts.item ?? sampleTipoDeIva}
|
||||||
|
onClose={onClose}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user