feat(mejoras): 25 mejoras UX en todos los módulos + purga Monday

Backend: mi-dia siempre local, vocabulario canónico, AplicacionPago con usuario
Mi día: modal español real, cita desde modal, score falso eliminado
Cotizador: barra sticky, persistencia local con folio
Alta Clientes: redirect eliminado, foto conectada, asesor desde sesión, refs dinámicas
Cobranza: contrato Mora arreglado, link a Plan, abono próximo pago, WhatsApp
Landing: contactos locales, ModalContacto eliminado, ModoTrabajo eliminado
Dashboards: MiTablero unificado, funnel con descartados, drill-down, rango fechas
Chat FRASA: corpus 5 desarrollos, 6 herramientas, historial conversaciones

69 archivos, 7 apps construidas
This commit is contained in:
2026-09-11 06:52:37 +00:00
parent 075d47c304
commit 64b01246d6
69 changed files with 1600 additions and 1133 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -21,6 +21,7 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
import { calcularVigencia } from '../lib/vigencia'
import { folioCotizacion } from '../lib/folio'
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
import {
getMisCotizaciones,
@@ -30,6 +31,7 @@ import {
TIPO_DE_API,
type CotizacionGuardada,
} from '../services/api'
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
import type { Cliente } from '../types'
const esquemaCliente = z.object({
@@ -87,6 +89,8 @@ export default function CotizadorPage() {
const [errorMias, setErrorMias] = useState<string | null>(null)
const [ultimaGuardada, setUltimaGuardada] = useState<CotizacionGuardada | null>(null)
const [modalClienteAbierto, setModalClienteAbierto] = useState(false)
// true cuando el modal de cliente se abrió desde "Guardar" (sin generar PDF)
const [guardarSinPdf, setGuardarSinPdf] = useState(false)
const [guardando, setGuardando] = useState(false)
const [aviso, setAviso] = useState<string | null>(null)
// Prefill desde Mi día (botón "Cotizar" de una oportunidad): el id queda
@@ -96,9 +100,22 @@ export default function CotizadorPage() {
useEffect(() => {
let vivo = true
/** Combina servidor + locales, sin duplicados, más recientes primero */
const mezclar = (principal: CotizacionGuardada[], extra: CotizacionGuardada[]) => {
const ids = new Set(principal.map((c) => c.id))
const combinadas = [...principal, ...extra.filter((c) => !ids.has(c.id))]
return combinadas
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
.slice(0, 50)
}
// Cotizaciones guardadas localmente cuando el API no respondió
void cargarCotizacionesLocales().then((locales) => {
if (!vivo || locales.length === 0) return
setMisCotizaciones((prev) => mezclar(prev, locales as CotizacionGuardada[]))
})
getMisCotizaciones()
.then((lista) => {
if (vivo) setMisCotizaciones(lista)
if (vivo) setMisCotizaciones((prev) => mezclar(lista, prev))
})
.catch((err) => {
console.warn('[api] No se pudo cargar el historial de cotizaciones.', err)
@@ -162,9 +179,63 @@ export default function CotizadorPage() {
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !errorEnganche && !guardando
/** Persiste la cotización en el API; null si no hay conexión (el PDF sale igual, sin folio/QR) */
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
// más barata; la elección manual del vendedor se respeta hasta el siguiente
// cambio de unidad. handleRetomar marca saltarAuto: manda SU propuesta guardada.
const unidadPrevia = useRef<string | null | undefined>(undefined)
const saltarAuto = useRef(false)
useEffect(() => {
const codigo = unidad?.codigo ?? null
const cambioUnidad = unidadPrevia.current !== undefined && unidadPrevia.current !== codigo
unidadPrevia.current = codigo
if (!cambioUnidad || saltarAuto.current) {
saltarAuto.current = false
return
}
const totales = propuestas.map((p) => totalPagado(p, apartadoEfectivo))
const indiceMenor = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
if (indiceMenor >= 0) setPropuestaIndex(indiceMenor)
}, [unidad?.codigo, propuestas, apartadoEfectivo])
/** true cuando la cotización vive solo en este dispositivo (el API no respondió) */
const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-')
/** Instantánea local de la cotización con folio CTE-LOCAL-<timestamp> */
const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => {
const timestamp = Date.now()
return {
id: `local-${timestamp}`,
folio: `CTE-LOCAL-${timestamp}`,
unidadCodigo: unidad!.codigo,
desarrollo,
clienteNombre: cliente.nombre,
clienteTelefono: cliente.telefono ?? null,
clienteEmail: cliente.email ?? null,
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo],
acabados,
apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento,
precioLista,
precioFinal: propuestaActiva.precioFinal,
enganche: propuestaActiva.enganche,
numMensualidades: propuestaActiva.numMensualidades ?? 0,
mensualidad: propuestaActiva.mensualidad,
pagoFinal: propuestaActiva.pagoFinal,
createdAt: new Date(timestamp).toISOString(),
}
}
/**
* Persiste la cotización en el API. Si no responde, la guarda localmente
* (IndexedDB/localStorage) con folio CTE-LOCAL-* y la devuelve igual: el PDF
* siempre sale con folio (sin QR, que solo existe para cotizaciones del servidor).
*/
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
if (!unidad || !propuestaActiva) return null
try {
@@ -186,9 +257,14 @@ export default function CotizadorPage() {
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
return conFolio
} catch (err) {
toast.error('No se pudo guardar la cotización'); console.warn('[api] No se pudo guardar la cotización; el PDF sale sin folio/QR.', err)
setAviso('Sin conexión al servidor: la cotización no se guardó (PDF sin folio/QR).')
return null
console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err)
const local = cotizacionLocal(cliente)
void guardarCotizacionLocal(local)
setUltimaGuardada(local)
setMisCotizaciones((prev) => [local, ...prev].slice(0, 50))
toast('Cotización guardada en este dispositivo (sin conexión al servidor)')
setAviso(`Sin conexión al servidor: cotización guardada localmente con folio ${local.folio}.`)
return local
}
}
@@ -201,10 +277,13 @@ export default function CotizadorPage() {
let qrDataUrl: string | undefined
if (guardada) {
folio = guardada.folio ?? folioCotizacion(guardada.id)
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
margin: 1,
width: 200,
})
// El QR apunta al resumen público en el servidor; una cotización local no lo tiene
if (!esCotizacionLocal(guardada)) {
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
margin: 1,
width: 200,
})
}
}
generarPdf({
unidad,
@@ -227,6 +306,7 @@ export default function CotizadorPage() {
if (!unidad) return
// Sin nombre capturado, pide los datos del cliente en el modal
if (nombreCliente.trim().length < 3) {
setGuardarSinPdf(false)
setModalClienteAbierto(true)
return
}
@@ -237,12 +317,42 @@ export default function CotizadorPage() {
})
}
/** Guarda solo (sin PDF); con el flujo de modal si falta el nombre del cliente */
const guardarSolo = async (cliente: Cliente) => {
if (!unidad) return
setGuardando(true)
try {
await guardarEnApi(cliente)
} finally {
setGuardando(false)
}
}
const handleGuardar = () => {
if (!unidad) return
if (nombreCliente.trim().length < 3) {
setGuardarSinPdf(true)
setModalClienteAbierto(true)
return
}
void guardarSolo({
nombre: nombreCliente.trim(),
email: watch('email') || undefined,
telefono: watch('telefono') || undefined,
})
}
const handleModalAceptar = (cliente: Cliente) => {
setModalClienteAbierto(false)
setValue('nombre', cliente.nombre, { shouldValidate: true })
if (cliente.telefono) setValue('telefono', cliente.telefono, { shouldValidate: true })
if (cliente.email) setValue('email', cliente.email, { shouldValidate: true })
void generarPdfConCliente(cliente)
if (guardarSinPdf) {
setGuardarSinPdf(false)
void guardarSolo(cliente)
} else {
void generarPdfConCliente(cliente)
}
}
const handleCompartir = async () => {
@@ -256,7 +366,10 @@ export default function CotizadorPage() {
...(ultimaGuardada
? {
folio: ultimaGuardada.folio ?? folioCotizacion(ultimaGuardada.id),
urlPublica: urlPublicaCotizacion(ultimaGuardada.id),
// La URL pública solo existe si la cotización vive en el servidor
...(esCotizacionLocal(ultimaGuardada)
? {}
: { urlPublica: urlPublicaCotizacion(ultimaGuardada.id) }),
}
: {}),
})
@@ -281,6 +394,7 @@ export default function CotizadorPage() {
}
const tipo = TIPO_DE_API[c.tipoPropuesta]
const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo)
saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada
restaurar({
desarrollo: c.desarrollo,
unidad: u,
@@ -306,7 +420,7 @@ export default function CotizadorPage() {
return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
<main className="px-4 py-8 sm:px-6 lg:px-8 lg:py-10">
<main className="px-4 py-8 pb-28 sm:px-6 lg:px-8 lg:py-10 lg:pb-28">
{/* Encabezado de página */}
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
@@ -599,49 +713,6 @@ export default function CotizadorPage() {
propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)}
/>
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-end sm:justify-end">
<div>
<label htmlFor="propuesta-elegida" className={etiquetaCampo}>
Propuesta elegida
</label>
<select
id="propuesta-elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</div>
<button
type="button"
onClick={handleCompartir}
disabled={!puedeGenerarPdf}
className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
>
Compartir
</button>
<button
type="button"
onClick={handleGenerarPdf}
disabled={!puedeGenerarPdf}
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
</button>
</div>
{aviso && <p className="mt-2 text-right text-xs text-[#64748B] dark:text-[#94A3B8]">{aviso}</p>}
{!puedeGenerarPdf && !guardando && (
<p className="mt-2 text-right text-xs text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para guardar y generar el PDF.
</p>
)}
</div>
</div>
)}
</section>
@@ -664,6 +735,71 @@ export default function CotizadorPage() {
</div>
</main>
{/* Barra fija de cierre: propuesta elegida + acciones, siempre visibles */}
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[#E5E7EB] bg-white/95 shadow-[0_-4px_16px_rgba(15,23,42,0.08)] backdrop-blur dark:border-[#1E293B] dark:bg-[#0F172A]/95">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{propuestaActiva ? (
<>
<span className="inline-flex max-w-full items-center rounded-full bg-accent/10 px-3 py-1.5 text-xs font-semibold text-accent">
<span className="truncate">
Propuesta elegida: #{propuestas.indexOf(propuestaActiva) + 1} {propuestaActiva.titulo}
{totalActivo !== null ? ` · Total ${formatoMoneda(totalActivo)}` : ''}
</span>
</span>
<select
aria-label="Cambiar propuesta elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</>
) : (
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para elegir propuesta, guardar y generar el PDF.
</p>
)}
{aviso && (
<p role="status" className="w-full text-xs text-[#64748B] dark:text-[#94A3B8] sm:w-auto sm:max-w-xs sm:truncate">
{aviso}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleCompartir}
disabled={!puedeGenerarPdf}
className="rounded-lg border border-accent bg-white px-4 py-2 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
>
Compartir
</button>
<button
type="button"
onClick={handleGuardar}
disabled={!puedeGenerarPdf}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:cursor-not-allowed disabled:opacity-40 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] dark:hover:bg-[#1E293B]"
>
Guardar
</button>
<button
type="button"
onClick={handleGenerarPdf}
disabled={!puedeGenerarPdf}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{guardando ? 'Guardando…' : 'Generar PDF'}
</button>
</div>
</div>
</div>
<ModalCliente
abierto={modalClienteAbierto}
nombreInicial={watch('nombre')}