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:
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user