fix(web/udt-011): NuevaVigenciaModal preview usa prevCivilDate+formatCivilDate sin Date() (fix BUG-FE-04)

This commit is contained in:
2026-04-18 10:24:15 -03:00
parent 20b5863908
commit 71d0928389
5 changed files with 50 additions and 14 deletions

View File

@@ -27,6 +27,7 @@ import {
import { useNuevaVersionIngresosBrutos } from '../hooks/useIngresosBrutos' import { useNuevaVersionIngresosBrutos } from '../hooks/useIngresosBrutos'
import type { IngresosBrutos } from '../types/ingresosBrutos.types' import type { IngresosBrutos } from '../types/ingresosBrutos.types'
import { toast } from 'sonner' import { toast } from 'sonner'
import { prevCivilDate, formatCivilDate } from '@/lib/dateFormat'
const formSchema = z.object({ const formSchema = z.object({
alicuota: z.coerce alicuota: z.coerce
@@ -48,11 +49,12 @@ interface NuevaVigenciaIibbModalProps {
onSuccess: () => void onSuccess: () => void
} }
function fechaCierre(vigenciaDesde: string): string { /** Formatea la fecha de cierre (vigenciaDesde - 1 día) para display "dd/MM/yyyy".
* Usa prevCivilDate (Date.UTC pura, sin TZ) + formatCivilDate (split manual).
*/
function fechaCierreDisplay(vigenciaDesde: string): string {
if (!vigenciaDesde || !/^\d{4}-\d{2}-\d{2}$/.test(vigenciaDesde)) return '—' if (!vigenciaDesde || !/^\d{4}-\d{2}-\d{2}$/.test(vigenciaDesde)) return '—'
const d = new Date(vigenciaDesde + 'T00:00:00') return formatCivilDate(prevCivilDate(vigenciaDesde))
d.setDate(d.getDate() - 1)
return d.toISOString().slice(0, 10)
} }
function resolveBackendError(err: unknown): string | null { function resolveBackendError(err: unknown): string | null {
@@ -212,7 +214,7 @@ export function NuevaVigenciaIibbModal({
</p> </p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Versión actual ({item.alicuota}%) quedará cerrada el{' '} Versión actual ({item.alicuota}%) quedará cerrada el{' '}
<strong>{fechaCierre(watchedVigencia)}</strong>. <strong>{fechaCierreDisplay(watchedVigencia)}</strong>.
</p> </p>
<p className="text-xs text-muted-foreground font-medium"> <p className="text-xs text-muted-foreground font-medium">
Esta acción no se puede deshacer. Esta acción no se puede deshacer.

View File

@@ -28,6 +28,7 @@ import {
import { useNuevaVersionTipoDeIva } from '../hooks/useTiposDeIva' import { useNuevaVersionTipoDeIva } from '../hooks/useTiposDeIva'
import type { TipoDeIva } from '../types/tipoDeIva.types' import type { TipoDeIva } from '../types/tipoDeIva.types'
import { toast } from 'sonner' import { toast } from 'sonner'
import { prevCivilDate, formatCivilDate } from '@/lib/dateFormat'
const formSchema = z.object({ const formSchema = z.object({
porcentaje: z.coerce porcentaje: z.coerce
@@ -49,12 +50,12 @@ interface NuevaVigenciaModalProps {
onSuccess: () => void onSuccess: () => void
} }
/** Devuelve la fecha anterior (vigenciaDesde - 1 día) como string "yyyy-MM-dd" */ /** Formatea la fecha de cierre (vigenciaDesde - 1 día) para display "dd/MM/yyyy".
function fechaCierre(vigenciaDesde: string): string { * Usa prevCivilDate (Date.UTC pura, sin TZ) + formatCivilDate (split manual).
*/
function fechaCierreDisplay(vigenciaDesde: string): string {
if (!vigenciaDesde || !/^\d{4}-\d{2}-\d{2}$/.test(vigenciaDesde)) return '—' if (!vigenciaDesde || !/^\d{4}-\d{2}-\d{2}$/.test(vigenciaDesde)) return '—'
const d = new Date(vigenciaDesde + 'T00:00:00') return formatCivilDate(prevCivilDate(vigenciaDesde))
d.setDate(d.getDate() - 1)
return d.toISOString().slice(0, 10)
} }
function resolveBackendError(err: unknown): string | null { function resolveBackendError(err: unknown): string | null {
@@ -218,7 +219,7 @@ export function NuevaVigenciaModal({
</p> </p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Versión actual ({item.porcentaje}%) quedará cerrada el{' '} Versión actual ({item.porcentaje}%) quedará cerrada el{' '}
<strong>{fechaCierre(watchedVigencia)}</strong>. <strong>{fechaCierreDisplay(watchedVigencia)}</strong>.
</p> </p>
<p className="text-xs text-muted-foreground font-medium"> <p className="text-xs text-muted-foreground font-medium">
Esta acción no se puede deshacer. Esta acción no se puede deshacer.

View File

@@ -87,6 +87,20 @@ export function parseCivilDate(yyyyMmDd: string): { year: number; month: number;
return { year: y, month: m, day: d }; return { year: y, month: m, day: d };
} }
/**
* Retorna el día anterior a una fecha civil Argentina en formato "yyyy-MM-dd".
* Usa Date.UTC para aritmética pura — sin conversión de timezone en ningún momento.
* Output: "yyyy-MM-dd"
*/
export function prevCivilDate(yyyyMmDd: string): string {
const [y, m, d] = yyyyMmDd.split('-').map(Number);
const prevDay = new Date(Date.UTC(y, m - 1, d - 1));
const yy = prevDay.getUTCFullYear();
const mm = String(prevDay.getUTCMonth() + 1).padStart(2, '0');
const dd = String(prevDay.getUTCDate()).padStart(2, '0');
return `${yy}-${mm}-${dd}`;
}
/** /**
* Convierte un valor de <input type="datetime-local"> (ART implícito) a ISO UTC. * Convierte un valor de <input type="datetime-local"> (ART implícito) a ISO UTC.
* Input: "2026-05-01T22:30" (sin TZ) → interpretado como ART → "2026-05-02T01:30:00.000Z". * Input: "2026-05-01T22:30" (sin TZ) → interpretado como ART → "2026-05-02T01:30:00.000Z".

View File

@@ -122,7 +122,7 @@ describe('NuevaVigenciaModal — preview con fechas correctas [REQ-UI-004]', ()
) )
}) })
it('preview muestra fecha de cierre = vigenciaDesde - 1 día', async () => { it('preview muestra fecha de cierre = vigenciaDesde - 1 día (formato dd/MM/yyyy, BUG-FE-04)', async () => {
renderModal() renderModal()
const porcentajeInput = screen.getByLabelText(/porcentaje nuevo/i) const porcentajeInput = screen.getByLabelText(/porcentaje nuevo/i)
@@ -132,9 +132,9 @@ describe('NuevaVigenciaModal — preview con fechas correctas [REQ-UI-004]', ()
const vigenciaInput = screen.getByLabelText(/vigencia desde/i) const vigenciaInput = screen.getByLabelText(/vigencia desde/i)
await userEvent.type(vigenciaInput, '2026-05-01') await userEvent.type(vigenciaInput, '2026-05-01')
// La versión anterior cierra el día anterior → 2026-04-30 // La versión anterior cierra el día anterior → 30/04/2026 (formato AR civil)
await waitFor(() => await waitFor(() =>
expect(screen.getByText(/2026-04-30/)).toBeInTheDocument(), expect(screen.getByText(/30\/04\/2026/)).toBeInTheDocument(),
) )
}) })
}) })

View File

@@ -6,6 +6,7 @@ import {
formatCivilDateRange, formatCivilDateRange,
todayArgentina, todayArgentina,
parseCivilDate, parseCivilDate,
prevCivilDate,
} from '@/lib/dateFormat'; } from '@/lib/dateFormat';
describe('dateFormat.ts', () => { describe('dateFormat.ts', () => {
@@ -108,4 +109,22 @@ describe('dateFormat.ts', () => {
expect(result.day).toBe(1); expect(result.day).toBe(1);
}); });
}); });
describe('prevCivilDate (BUG-FE-04 — fecha cierre NuevaVigencia)', () => {
it('returns day before (2026-05-01 → 2026-04-30)', () => {
expect(prevCivilDate('2026-05-01')).toBe('2026-04-30');
});
it('crosses month boundary (2026-05-31 → 2026-05-30)', () => {
expect(prevCivilDate('2026-06-01')).toBe('2026-05-31');
});
it('crosses year boundary (2026-01-01 → 2025-12-31)', () => {
expect(prevCivilDate('2026-01-01')).toBe('2025-12-31');
});
it('handles leap year (2024-03-01 → 2024-02-29)', () => {
expect(prevCivilDate('2024-03-01')).toBe('2024-02-29');
});
});
}); });