diff --git a/src/web/src/features/fiscal/iibb/components/IngresosBrutosFormModal.tsx b/src/web/src/features/fiscal/iibb/components/IngresosBrutosFormModal.tsx index 7acd739..b1d0fac 100644 --- a/src/web/src/features/fiscal/iibb/components/IngresosBrutosFormModal.tsx +++ b/src/web/src/features/fiscal/iibb/components/IngresosBrutosFormModal.tsx @@ -2,7 +2,7 @@ // Modal de edición / creación de IngresosBrutos // CRÍTICO: NO incluye campo Alícuota en modo edit (inmutable, cambiar via NuevaVersion) import { useEffect } from 'react' -import { todayArgentina } from '@/lib/dateFormat' +import { todayArgentina } from '@/lib/formatters' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' diff --git a/src/web/src/features/fiscal/iibb/components/NuevaVigenciaIibbModal.tsx b/src/web/src/features/fiscal/iibb/components/NuevaVigenciaIibbModal.tsx index 1aa31aa..6085d24 100644 --- a/src/web/src/features/fiscal/iibb/components/NuevaVigenciaIibbModal.tsx +++ b/src/web/src/features/fiscal/iibb/components/NuevaVigenciaIibbModal.tsx @@ -28,7 +28,7 @@ import { import { useNuevaVersionIngresosBrutos } from '../hooks/useIngresosBrutos' import type { IngresosBrutos } from '../types/ingresosBrutos.types' import { toast } from 'sonner' -import { prevCivilDate, formatCivilDate } from '@/lib/dateFormat' +import { prevCivilDate, formatCivilDate } from '@/lib/formatters' const formSchema = z.object({ alicuota: z.coerce diff --git a/src/web/src/features/fiscal/iva/components/NuevaVigenciaModal.tsx b/src/web/src/features/fiscal/iva/components/NuevaVigenciaModal.tsx index d098575..5f69438 100644 --- a/src/web/src/features/fiscal/iva/components/NuevaVigenciaModal.tsx +++ b/src/web/src/features/fiscal/iva/components/NuevaVigenciaModal.tsx @@ -29,7 +29,7 @@ import { import { useNuevaVersionTipoDeIva } from '../hooks/useTiposDeIva' import type { TipoDeIva } from '../types/tipoDeIva.types' import { toast } from 'sonner' -import { prevCivilDate, formatCivilDate } from '@/lib/dateFormat' +import { prevCivilDate, formatCivilDate } from '@/lib/formatters' const formSchema = z.object({ porcentaje: z.coerce diff --git a/src/web/src/features/fiscal/iva/components/TipoDeIvaFormModal.tsx b/src/web/src/features/fiscal/iva/components/TipoDeIvaFormModal.tsx index 3efe20d..9412303 100644 --- a/src/web/src/features/fiscal/iva/components/TipoDeIvaFormModal.tsx +++ b/src/web/src/features/fiscal/iva/components/TipoDeIvaFormModal.tsx @@ -2,7 +2,7 @@ // Modal de edición / creación de TipoDeIva // CRÍTICO: NO incluye campo Porcentaje (inmutable, cambiar via NuevaVersion) import { useEffect } from 'react' -import { todayArgentina } from '@/lib/dateFormat' +import { todayArgentina } from '@/lib/formatters' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' diff --git a/src/web/src/features/medios/pages/MedioDetailPage.tsx b/src/web/src/features/medios/pages/MedioDetailPage.tsx index 468f201..f733fc4 100644 --- a/src/web/src/features/medios/pages/MedioDetailPage.tsx +++ b/src/web/src/features/medios/pages/MedioDetailPage.tsx @@ -5,7 +5,7 @@ import { CanPerform } from '@/components/auth/CanPerform' import { useMedio } from '../hooks/useMedio' import { DeactivateMedioModal } from '../components/DeactivateMedioModal' import { tipoMedioLabel } from '../tipoMedio' -import { formatInstantOrDash } from '@/lib/dateFormat' +import { formatInstantOrDash } from '@/lib/formatters' export function MedioDetailPage() { const { id } = useParams<{ id: string }>() diff --git a/src/web/src/features/products/components/AddProductPriceDialog.tsx b/src/web/src/features/products/components/AddProductPriceDialog.tsx index 36ced8c..9782f02 100644 --- a/src/web/src/features/products/components/AddProductPriceDialog.tsx +++ b/src/web/src/features/products/components/AddProductPriceDialog.tsx @@ -23,7 +23,7 @@ import { FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' -import { todayArgentina } from '@/lib/dateFormat' +import { todayArgentina } from '@/lib/formatters' import { useAddProductPrice } from '../hooks/useAddProductPrice' // ─── Schema (Zod, espejo del backend) ──────────────────────────────────────── diff --git a/src/web/src/features/products/components/ProductPriceHistory.tsx b/src/web/src/features/products/components/ProductPriceHistory.tsx index 54eaaac..306f602 100644 --- a/src/web/src/features/products/components/ProductPriceHistory.tsx +++ b/src/web/src/features/products/components/ProductPriceHistory.tsx @@ -13,8 +13,7 @@ import { TableRow, } from '@/components/ui/table' import { CanPerform } from '@/components/auth/CanPerform' -import { formatCivilDate } from '@/lib/dateFormat' -import { formatCurrency } from '@/lib/numberFormat' +import { formatCivilDate, formatCurrency } from '@/lib/formatters' import { useProductPrices } from '../hooks/useProductPrices' import { AddProductPriceDialog } from './AddProductPriceDialog' diff --git a/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx b/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx index ec805f4..84f1b8d 100644 --- a/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx +++ b/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx @@ -7,7 +7,7 @@ import { useMedio } from '../../medios/hooks/useMedio' import { DeactivatePuntoDeVentaModal } from '../components/DeactivatePuntoDeVentaModal' import { MedioInactivoBanner } from '../components/MedioInactivoBanner' import { PdvInactivoBanner } from '../components/PdvInactivoBanner' -import { formatInstantOrDash } from '@/lib/dateFormat' +import { formatInstantOrDash } from '@/lib/formatters' export function PuntoDeVentaDetailPage() { const { id } = useParams<{ id: string }>() diff --git a/src/web/src/features/secciones/pages/SeccionDetailPage.tsx b/src/web/src/features/secciones/pages/SeccionDetailPage.tsx index 7c3f846..61ee072 100644 --- a/src/web/src/features/secciones/pages/SeccionDetailPage.tsx +++ b/src/web/src/features/secciones/pages/SeccionDetailPage.tsx @@ -7,7 +7,7 @@ import { DeactivateSeccionModal } from '../components/DeactivateSeccionModal' import { MedioInactivoBanner } from '../components/MedioInactivoBanner' import { tipoSeccionLabel } from '../tipoSeccion' import { useMedio } from '../../medios/hooks/useMedio' -import { formatInstantOrDash } from '@/lib/dateFormat' +import { formatInstantOrDash } from '@/lib/formatters' export function SeccionDetailPage() { const { id } = useParams<{ id: string }>() diff --git a/src/web/src/features/users/components/UsersTable.tsx b/src/web/src/features/users/components/UsersTable.tsx index a9c0378..b0d31c0 100644 --- a/src/web/src/features/users/components/UsersTable.tsx +++ b/src/web/src/features/users/components/UsersTable.tsx @@ -3,7 +3,7 @@ import type { ColumnDef } from '@tanstack/react-table' import { Badge } from '@/components/ui/badge' import { DataTable } from '@/components/ui/data-table' import type { UserListItem } from '../types' -import { formatInstantOrDash } from '@/lib/dateFormat' +import { formatInstantOrDash } from '@/lib/formatters' interface UsersTableProps { rows: UserListItem[] diff --git a/src/web/src/lib/dateFormat.ts b/src/web/src/lib/formatters.ts similarity index 87% rename from src/web/src/lib/dateFormat.ts rename to src/web/src/lib/formatters.ts index 9523e3e..9508713 100644 --- a/src/web/src/lib/dateFormat.ts +++ b/src/web/src/lib/formatters.ts @@ -1,4 +1,7 @@ /** + * Punto unificado de formateo — fecha y número. + * Anteriormente separado en dateFormat.ts y numberFormat.ts (unificado en issue #46). + * * Localización temporal Argentina — utility centralizada. * Ver: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.17 ⏰ Localización Temporal Argentina.md * Engram topic_key: sig-cm2/conventions/fechas-timezones @@ -121,3 +124,20 @@ export function parseArgentinaDateTimeToUtc(localDateTime: string): string { // Parse "yyyy-MM-ddTHH:mm" como ART (offset -03:00) y convertir a ISO UTC return new Date(`${localDateTime}:00-03:00`).toISOString(); } + +// --------------------------------------------------------------------------- +// Números +// --------------------------------------------------------------------------- + +/** + * Formatea un número como moneda ARS (pesos argentinos). + * Output: "$ 1.500,50" o similar según locale. + */ +export function formatCurrency(amount: number): string { + return new Intl.NumberFormat('es-AR', { + style: 'currency', + currency: 'ARS', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); +} diff --git a/src/web/src/lib/numberFormat.ts b/src/web/src/lib/numberFormat.ts deleted file mode 100644 index 3e30a6b..0000000 --- a/src/web/src/lib/numberFormat.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Formateo de números — utility centralizada. - * Usar SIEMPRE estas funciones en lugar de Intl.NumberFormat inline. - */ - -/** - * Formatea un número como moneda ARS (pesos argentinos). - * Output: "$ 1.500,50" o similar según locale. - */ -export function formatCurrency(amount: number): string { - return new Intl.NumberFormat('es-AR', { - style: 'currency', - currency: 'ARS', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(amount) -} diff --git a/src/web/src/pages/admin/audit/AuditFilters.tsx b/src/web/src/pages/admin/audit/AuditFilters.tsx index 016f89a..6024732 100644 --- a/src/web/src/pages/admin/audit/AuditFilters.tsx +++ b/src/web/src/pages/admin/audit/AuditFilters.tsx @@ -2,7 +2,7 @@ import { useState, type FormEvent } from 'react' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' -import { parseArgentinaDateTimeToUtc } from '@/lib/dateFormat' +import { parseArgentinaDateTimeToUtc } from '@/lib/formatters' /** Filtros crudos del form (todos strings para binding directo del input). */ export interface AuditFiltersValue { diff --git a/src/web/src/pages/admin/audit/AuditPage.tsx b/src/web/src/pages/admin/audit/AuditPage.tsx index a5d0ecf..1245b01 100644 --- a/src/web/src/pages/admin/audit/AuditPage.tsx +++ b/src/web/src/pages/admin/audit/AuditPage.tsx @@ -18,7 +18,7 @@ import { toApiFilter, type AuditFiltersValue, } from './AuditFilters' -import { formatInstant } from '@/lib/dateFormat' +import { formatInstant } from '@/lib/formatters' /** Copia texto al clipboard con fallback + toast. */ async function copyToClipboard(text: string, label: string): Promise { diff --git a/src/web/src/tests/lib/dateFormat.test.ts b/src/web/src/tests/lib/dateFormat.test.ts deleted file mode 100644 index f428760..0000000 --- a/src/web/src/tests/lib/dateFormat.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { - AR_TZ, - formatInstant, - formatInstantOrDash, - formatCivilDate, - formatCivilDateRange, - todayArgentina, - parseCivilDate, - prevCivilDate, -} from '@/lib/dateFormat'; - -describe('dateFormat.ts', () => { - describe('AR_TZ constant', () => { - it('is America/Argentina/Buenos_Aires', () => { - expect(AR_TZ).toBe('America/Argentina/Buenos_Aires'); - }); - }); - - describe('formatInstant (Cat1 — UTC → AR display)', () => { - it('converts 01:30 UTC to 22:30 ART previous day', () => { - const iso = '2026-05-01T01:30:00.000Z'; - const result = formatInstant(iso); - // format es-AR short + medium: "30/4/2026, 22:30:00" o similar - expect(result).toMatch(/30\/0?4\/2026/); - expect(result).toMatch(/22:30/); - }); - - it('uses AR timezone regardless of browser TZ', () => { - const iso = '2026-05-01T12:00:00.000Z'; // 09:00 ART - const result = formatInstant(iso); - expect(result).toMatch(/01\/0?5\/2026/); - expect(result).toMatch(/09:00/); - }); - }); - - describe('formatInstantOrDash (Cat1 nullable)', () => { - it('returns "—" for null', () => { - expect(formatInstantOrDash(null)).toBe('—'); - }); - - it('returns "—" for undefined', () => { - expect(formatInstantOrDash(undefined)).toBe('—'); - }); - - it('delegates to formatInstant for valid ISO', () => { - const iso = '2026-05-01T01:30:00.000Z'; - expect(formatInstantOrDash(iso)).toBe(formatInstant(iso)); - }); - }); - - describe('formatCivilDate (Cat2 — yyyy-MM-dd → dd/MM/yyyy)', () => { - it('splits manually without new Date()', () => { - expect(formatCivilDate('2026-05-01')).toBe('01/05/2026'); - }); - - it('preserves day for early-year dates', () => { - expect(formatCivilDate('2026-01-01')).toBe('01/01/2026'); - }); - - it('preserves day for end-of-year dates', () => { - expect(formatCivilDate('2026-12-31')).toBe('31/12/2026'); - }); - }); - - describe('formatCivilDateRange', () => { - it('renders full range with arrow', () => { - expect(formatCivilDateRange('2026-05-01', '2026-12-31')) - .toBe('01/05/2026 → 31/12/2026'); - }); - - it('renders open range when hasta is null', () => { - expect(formatCivilDateRange('2026-05-01', null)) - .toBe('desde 01/05/2026'); - }); - }); - - describe('todayArgentina (fix BUG-FE-03)', () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it('returns 2026-04-30 at 22:30 ART of 30/04 (BUG-FE-03 regression)', () => { - // 22:30 ART = 01:30 UTC del día siguiente - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-05-01T01:30:00.000Z')); - - expect(todayArgentina()).toBe('2026-04-30'); - }); - - it('returns 2026-05-01 at 00:30 ART of 01/05', () => { - // 00:30 ART = 03:30 UTC - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-05-01T03:30:00.000Z')); - - expect(todayArgentina()).toBe('2026-05-01'); - }); - - it('returns 2024-02-28 at 22:30 ART of 28/02/2024 (bisiesto)', () => { - // 22:30 ART del 28/02/2024 = 01:30 UTC del 29/02/2024 - vi.useFakeTimers(); - vi.setSystemTime(new Date('2024-02-29T01:30:00.000Z')); - - expect(todayArgentina()).toBe('2024-02-28'); - }); - - it('returns 2026-12-31 at 22:30 ART of 31/12 (end of year)', () => { - // 22:30 ART del 31/12/2026 = 01:30 UTC del 01/01/2027 - vi.useFakeTimers(); - vi.setSystemTime(new Date('2027-01-01T01:30:00.000Z')); - - expect(todayArgentina()).toBe('2026-12-31'); - }); - }); - - describe('parseCivilDate', () => { - it('splits "2026-05-01" into {year, month, day}', () => { - expect(parseCivilDate('2026-05-01')).toEqual({ year: 2026, month: 5, day: 1 }); - }); - - it('does not use new Date() (no UTC creep)', () => { - const result = parseCivilDate('2026-01-01'); - expect(result.year).toBe(2026); - expect(result.month).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'); - }); - }); -}); diff --git a/src/web/src/tests/lib/formatters.test.ts b/src/web/src/tests/lib/formatters.test.ts new file mode 100644 index 0000000..e619390 --- /dev/null +++ b/src/web/src/tests/lib/formatters.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + AR_TZ, + formatInstant, + formatInstantOrDash, + formatCivilDate, + formatCivilDateRange, + todayArgentina, + parseCivilDate, + prevCivilDate, + parseArgentinaDateTimeToUtc, + formatCurrency, +} from '@/lib/formatters'; + +// ─── Smoke test — API unificada accesible desde @/lib/formatters ─────────────── + +describe('formatters.ts — smoke (unified module exports)', () => { + it('exports AR_TZ', () => { + expect(AR_TZ).toBe('America/Argentina/Buenos_Aires'); + }); + + it('exports formatInstant (function)', () => { + expect(typeof formatInstant).toBe('function'); + }); + + it('exports formatInstantOrDash (function)', () => { + expect(typeof formatInstantOrDash).toBe('function'); + }); + + it('exports formatCivilDate (function)', () => { + expect(typeof formatCivilDate).toBe('function'); + }); + + it('exports formatCivilDateRange (function)', () => { + expect(typeof formatCivilDateRange).toBe('function'); + }); + + it('exports todayArgentina (function)', () => { + expect(typeof todayArgentina).toBe('function'); + }); + + it('exports parseCivilDate (function)', () => { + expect(typeof parseCivilDate).toBe('function'); + }); + + it('exports prevCivilDate (function)', () => { + expect(typeof prevCivilDate).toBe('function'); + }); + + it('exports parseArgentinaDateTimeToUtc (function)', () => { + expect(typeof parseArgentinaDateTimeToUtc).toBe('function'); + }); + + it('exports formatCurrency (function)', () => { + expect(typeof formatCurrency).toBe('function'); + }); +}); + +// ─── Suite completa — mismos tests que dateFormat.test.ts ───────────────────── + +describe('formatInstant (Cat1 — UTC → AR display)', () => { + it('converts 01:30 UTC to 22:30 ART previous day', () => { + const iso = '2026-05-01T01:30:00.000Z'; + const result = formatInstant(iso); + expect(result).toMatch(/30\/0?4\/2026/); + expect(result).toMatch(/22:30/); + }); + + it('uses AR timezone regardless of browser TZ', () => { + const iso = '2026-05-01T12:00:00.000Z'; // 09:00 ART + const result = formatInstant(iso); + expect(result).toMatch(/01\/0?5\/2026/); + expect(result).toMatch(/09:00/); + }); +}); + +describe('formatInstantOrDash (Cat1 nullable)', () => { + it('returns "—" for null', () => { + expect(formatInstantOrDash(null)).toBe('—'); + }); + + it('returns "—" for undefined', () => { + expect(formatInstantOrDash(undefined)).toBe('—'); + }); + + it('delegates to formatInstant for valid ISO', () => { + const iso = '2026-05-01T01:30:00.000Z'; + expect(formatInstantOrDash(iso)).toBe(formatInstant(iso)); + }); +}); + +describe('formatCivilDate (Cat2 — yyyy-MM-dd → dd/MM/yyyy)', () => { + it('splits manually without new Date()', () => { + expect(formatCivilDate('2026-05-01')).toBe('01/05/2026'); + }); + + it('preserves day for early-year dates', () => { + expect(formatCivilDate('2026-01-01')).toBe('01/01/2026'); + }); + + it('preserves day for end-of-year dates', () => { + expect(formatCivilDate('2026-12-31')).toBe('31/12/2026'); + }); +}); + +describe('formatCivilDateRange', () => { + it('renders full range with arrow', () => { + expect(formatCivilDateRange('2026-05-01', '2026-12-31')) + .toBe('01/05/2026 → 31/12/2026'); + }); + + it('renders open range when hasta is null', () => { + expect(formatCivilDateRange('2026-05-01', null)) + .toBe('desde 01/05/2026'); + }); +}); + +describe('todayArgentina (fix BUG-FE-03)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns 2026-04-30 at 22:30 ART of 30/04 (BUG-FE-03 regression)', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-01T01:30:00.000Z')); + expect(todayArgentina()).toBe('2026-04-30'); + }); + + it('returns 2026-05-01 at 00:30 ART of 01/05', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-01T03:30:00.000Z')); + expect(todayArgentina()).toBe('2026-05-01'); + }); + + it('returns 2024-02-28 at 22:30 ART of 28/02/2024 (bisiesto)', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-02-29T01:30:00.000Z')); + expect(todayArgentina()).toBe('2024-02-28'); + }); + + it('returns 2026-12-31 at 22:30 ART of 31/12 (end of year)', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2027-01-01T01:30:00.000Z')); + expect(todayArgentina()).toBe('2026-12-31'); + }); +}); + +describe('parseCivilDate', () => { + it('splits "2026-05-01" into {year, month, day}', () => { + expect(parseCivilDate('2026-05-01')).toEqual({ year: 2026, month: 5, day: 1 }); + }); + + it('does not use new Date() (no UTC creep)', () => { + const result = parseCivilDate('2026-01-01'); + expect(result.year).toBe(2026); + expect(result.month).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'); + }); +}); + +describe('formatCurrency (ARS)', () => { + it('formats a whole number with 2 decimal places', () => { + const result = formatCurrency(1500); + expect(result).toMatch(/1[\.,]500/); + expect(result).toMatch(/00/); + }); + + it('formats a decimal amount', () => { + const result = formatCurrency(1234.56); + expect(result).toMatch(/56/); + }); +});