fix(auditoria completa): bloqueos de Mi Dia, cobranza (saldos y fechas), captura (departamento y rol), contactos (privacidad), cotizador (QR) y validaciones de API

This commit is contained in:
2026-09-21 07:18:39 +00:00
parent 75956a709f
commit 5e9b2ac456
16 changed files with 259 additions and 71 deletions

View File

@@ -18,9 +18,17 @@ export function formatoFechaLarga(fecha: Date): string {
return fecha.toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' })
}
/** ISO 'YYYY-MM-DD' a fecha legible corta: "08/10/2027" */
export function formatoFechaCorta(iso: string): string {
return new Date(`${iso}T00:00:00`).toLocaleDateString('es-MX', {
/**
* Fecha ISO ('YYYY-MM-DD' o ISO completo con hora, como lo manda el API) a
* fecha legible corta: "08/10/2027". Antes se concatenaba 'T00:00:00' a la
* cadena y con las fechas con hora salía "Invalid Date" en los PDFs.
*/
export function formatoFechaCorta(iso: string | null | undefined): string {
if (!iso) return '—'
const soloFecha = String(iso).slice(0, 10)
const fecha = new Date(`${soloFecha}T00:00:00`)
if (Number.isNaN(fecha.getTime())) return '—'
return fecha.toLocaleDateString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',

View File

@@ -35,7 +35,7 @@ describe('getUnidades', () => {
const resultado = await getUnidades()
expect(fetchMock).toHaveBeenCalledWith('http://192.168.10.123:3000/api/unidades', {})
expect(fetchMock).toHaveBeenCalledWith('/api/unidades', {})
expect(resultado.usandoSeed).toBe(false)
expect(resultado.unidades).toEqual([unidadApi])
})

View File

@@ -91,9 +91,16 @@ export interface GuardarCotizacionPayload {
oportunidadId?: string
}
/** URL pública de una cotización (destino del QR del PDF) */
/**
* URL pública de una cotización (destino del QR del PDF). Debe ser ABSOLUTA:
* un QR con ruta relativa ("/api/...") no es navegable al escanearlo desde el
* teléfono del cliente. Se arma con el origen actual cuando API_URL es relativa.
*/
export function urlPublicaCotizacion(id: string): string {
return `${API_URL}/cotizaciones/${id}/publico`
const base = API_URL.startsWith('http')
? API_URL
: `${typeof window !== 'undefined' ? window.location.origin : ''}${API_URL}`
return `${base}/cotizaciones/${id}/publico`
}
/** POST /cotizaciones — persiste y liga la oportunidad del cliente */

View File

@@ -1,4 +1,4 @@
import { Fragment, useMemo, useState, useCallback } from 'react'
import { Fragment, useEffect, useMemo, useState, useCallback } from 'react'
import type { CampoDef, DatosCaptura } from '../services/captura'
// ─── Tipos ──────────────────────────────────────────────────────────
@@ -185,6 +185,14 @@ export function TablaCaptura({
const [dirty, setDirty] = useState<DirtyMap>({})
const [pagina, setPagina] = useState(0)
// Al cambiar de departamento o de desarrollo los campos son otros: los cambios
// pendientes de la vista anterior se descartan (antes se guardaban en el
// departamento equivocado) y la paginación vuelve al inicio.
useEffect(() => {
setDirty({})
setPagina(0)
}, [campos])
/** Obtener estatus de una unidad (campo estatus en dept VENTAS, siempre disponible) */
const estatusUnidad = useCallback(
(unidad: string): string => {
@@ -252,12 +260,20 @@ export function TablaCaptura({
[datos],
)
/** Enviar cambios al padre */
/**
* Enviar cambios al padre. NO se limpian aquí: el padre recarga los datos al
* guardar con éxito y el efecto de abajo limpia los pendientes. Si el guardado
* falla, lo capturado sigue en pantalla para reintentar.
*/
const handleSubmit = useCallback(() => {
onGuardar(dirty)
setDirty({})
}, [dirty, onGuardar])
// Los datos guardados llegaron (recarga tras guardar): ya no hay pendientes
useEffect(() => {
setDirty({})
}, [datos])
if (unidades.length === 0) {
return (
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">

View File

@@ -79,7 +79,6 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
</div>
{item.telefono && <p className="mt-1 text-xs text-[#94A3B8]">{item.telefono}</p>}
{item.unidadCodigo && <p className="mt-0.5 text-xs text-[#94A3B8]">{item.unidadCodigo}</p>}
{item.pendienteSync && <span className="mt-1 inline-block rounded-full bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">⏳ sync</span>}
</>
)}
</div>

View File

@@ -1038,8 +1038,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
setAviso(null)
try {
await cambiarEtapaOportunidad(op.id, 'Cotizador')
await cargar() // refleja la etapa nueva sin recargar la página
} catch {
toast.error('No se pudo actualizar la etapa; el cotizador se abrirá de todos modos.')
} finally {
setEnviando(false) // sin esto la página quedaba bloqueada hasta recargar
}
window.open(urlCotizadorParaOportunidad(op), '_blank')
}
@@ -1128,7 +1131,20 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
const enEscrituracion = resumen
? (resumen.todosApartados || resumen.misApartados).filter((a) => {
const e = (a.estado ?? '').toUpperCase()
return e.includes('FIRMA') || e.includes('VENTA') || e.includes('SEGUIMIENTO') || e.includes('INTEGRACION') || e.includes('X AUTORIZAR') || e.includes('AUTORIZADO') || e.includes('X ESCRITURAR')
return (
e.includes('FIRMA') ||
e.includes('VENTA') ||
e.includes('SEGUIMIENTO') ||
e.includes('INTEGRACION') ||
e.includes('X AUTORIZAR') ||
e.includes('AUTORIZADO') ||
e.includes('X ESCRITURAR') ||
// Estados finales: sin ellos el registro desaparecía de Mi Día al
// marcarlo como Escriturado o Titulado
e.includes('ESCRITURADO') ||
e.includes('TITULADO') ||
e.includes('CANCO')
)
})
: []
const escrituracionFiltrados = aplicarFiltros(enEscrituracion, filtrosPorTab.escrituracion, {