Flujo aprobado por el cliente (28 sep 2026), porcentajes globales para todos los desarrollos: - Apartado: cantidad libre (sin minimo), se acredita dentro del enganche de firma; el excedente se descuenta del enganche - Enganche de firma: 2.5% del precio (saldo a firma = max(0, 2.5% - apartado)) - Enganche: 7.5%, diferible en N mensualidades (N a eleccion del vendedor, 1-36) - Gastos de escrituracion: 5.5%, pago unico a la firma o diferido con el enganche (toggle) - lib/esquema-frasa.ts (frontend) + cotizacion-esquema.service.ts (backend): modulos puros espejo, caso dorado M/apartado 5k/N=14 en tests de ambos - Migracion 20260928120000: 7 columnas nuevas en Cotizacion (esquema, saldoFirma, engancheDiferible, escrituracion, etc.); legacy se rellena para compatibilidad de reportes (tipoPropuesta=FRASA_2026) - UI: panel de linea de tiempo (Hoy -> Firma -> Mensualidades) sustituye a propuestas/comparadores; PDF y WhatsApp con el esquema unico - API recalcula server-side; smoke end-to-end verificado contra inventario - 743 tests API + 128 cotizador en verde
789 lines
32 KiB
TypeScript
789 lines
32 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import { useForm } from 'react-hook-form'
|
||
import { zodResolver } from '@hookform/resolvers/zod'
|
||
import { z } from 'zod'
|
||
import QRCode from 'qrcode'
|
||
import { useUnidades } from '../hooks/useUnidades'
|
||
import { useCotizacion } from '../hooks/useCotizacion'
|
||
import DarkModeToggle from '../components/DarkModeToggle'
|
||
import { SkeletonList } from "../components/Skeleton"
|
||
import toast from "react-hot-toast"
|
||
import { UnitSelector } from '../components/UnitSelector'
|
||
import { UnitInfoCard } from '../components/UnitInfoCard'
|
||
import { EsquemaPanel } from '../components/EsquemaPanel'
|
||
import { MisCotizaciones } from '../components/MisCotizaciones'
|
||
import { ModalCliente } from '../components/ModalCliente'
|
||
import { BuscadorCliente } from '../components/BuscadorCliente'
|
||
import { generarPdf } from '../lib/pdf'
|
||
import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
|
||
import { calcularVigencia } from '../lib/vigencia'
|
||
import { folioCotizacion } from '../lib/folio'
|
||
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
|
||
import { desarrolloEfectivo, idsCotizables } from '../lib/reglas'
|
||
import {
|
||
getMisCotizaciones,
|
||
getCotizacion,
|
||
guardarCotizacion,
|
||
urlPublicaCotizacion,
|
||
ErrorApiCotizacion,
|
||
type CotizacionGuardada,
|
||
} from '../services/api'
|
||
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
|
||
import type { Cliente, Unidad } from '../types'
|
||
|
||
/** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */
|
||
const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'
|
||
|
||
/** Una cotización guardada ya vive en el esquema único (filas nuevas) */
|
||
const esEsquemaNuevo = (c: CotizacionGuardada): boolean =>
|
||
c.esquema === 'FRASA_2026' && c.saldoFirma != null
|
||
|
||
const esquemaCliente = z.object({
|
||
nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'),
|
||
email: z.union([z.literal(''), z.string().email('Correo electrónico inválido')]).optional(),
|
||
telefono: z
|
||
.union([z.literal(''), z.string().regex(/^[0-9+\-\s()]{7,15}$/, 'Teléfono inválido')])
|
||
.optional(),
|
||
})
|
||
|
||
type FormularioCliente = z.infer<typeof esquemaCliente>
|
||
|
||
const campoInputBase =
|
||
'w-full rounded-lg border bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]'
|
||
const campoInput = `${campoInputBase} border-[#D1D5DB]`
|
||
const campoInputError = `${campoInputBase} border-red-400`
|
||
|
||
const etiquetaCampo = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||
|
||
export default function CotizadorPage() {
|
||
const { unidades, cargando, error, usandoSeed, reglas } = useUnidades()
|
||
const cotizacion = useCotizacion(reglas)
|
||
const {
|
||
desarrollo,
|
||
setDesarrollo,
|
||
params,
|
||
unidad,
|
||
setUnidad,
|
||
acabados,
|
||
setAcabados,
|
||
apartado,
|
||
setApartado,
|
||
apartadoEfectivo,
|
||
numMensualidades,
|
||
setNumMensualidades,
|
||
escrituracionDiferida,
|
||
setEscrituracionDiferida,
|
||
precioLista,
|
||
resultado,
|
||
numMeses,
|
||
restaurar,
|
||
nueva,
|
||
} = cotizacion
|
||
|
||
/** Desarrollos que se ofrecen: los activos en las reglas del admin */
|
||
const desarrollos = idsCotizables(reglas)
|
||
const unidadesDesarrollo = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === desarrollo)
|
||
const disponiblesDesarrollo = unidadesDesarrollo.filter((u) => u.status === 'DISPONIBLE').length
|
||
|
||
// Historial del vendedor + última cotización guardada (para folio/QR/compartir)
|
||
const [misCotizaciones, setMisCotizaciones] = useState<CotizacionGuardada[]>([])
|
||
const [cargandoMias, setCargandoMias] = useState(true)
|
||
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
|
||
// ligado a la cotización al guardar; la unidad se preselecciona al cargar
|
||
const [oportunidadId, setOportunidadId] = useState<string | null>(null)
|
||
const [unidadPendiente, setUnidadPendiente] = useState<string | null>(null)
|
||
// Retomar una cotización llegada por URL (/quote/?cotizacion=<id>, desde la
|
||
// búsqueda global): se aplica cuando termina de cargar inventario+reglas
|
||
const [cotizacionPendiente, setCotizacionPendiente] = useState<string | null>(null)
|
||
|
||
// Si el desarrollo activo deja de ofrecerse (lo desactivó el admin), se cae al
|
||
// primero disponible para no dejar el cotizador en un desarrollo muerto.
|
||
useEffect(() => {
|
||
if (desarrollos.includes(desarrollo)) return
|
||
const alterno = desarrollos[0]
|
||
if (alterno === undefined) return
|
||
setDesarrollo(alterno)
|
||
setAviso(
|
||
`El desarrollo ${desarrollo} ya no está disponible en el cotizador; se cambió a ${alterno}.`,
|
||
)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [desarrollos, desarrollo])
|
||
|
||
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((prev) => mezclar(lista, prev))
|
||
})
|
||
.catch((err) => {
|
||
console.warn('[api] No se pudo cargar el historial de cotizaciones.', err)
|
||
if (vivo) setErrorMias('No se pudo cargar el historial (sin conexión al servidor).')
|
||
})
|
||
.finally(() => {
|
||
if (vivo) setCargandoMias(false)
|
||
})
|
||
return () => {
|
||
vivo = false
|
||
}
|
||
}, [])
|
||
|
||
const {
|
||
register,
|
||
watch,
|
||
setValue,
|
||
reset,
|
||
formState: { errors },
|
||
} = useForm<FormularioCliente>({
|
||
resolver: zodResolver(esquemaCliente),
|
||
mode: 'onChange',
|
||
defaultValues: { nombre: '', email: '', telefono: '' },
|
||
})
|
||
|
||
const nombreCliente = watch('nombre') ?? ''
|
||
|
||
// Prefill desde la URL (botón "Cotizar" de Mi día): cliente, contacto,
|
||
// desarrollo, unidad y el id de la oportunidad a vincular al guardar
|
||
useEffect(() => {
|
||
const qs = new URLSearchParams(window.location.search)
|
||
const cliente = qs.get('cliente')
|
||
const telefono = qs.get('telefono')
|
||
const email = qs.get('email')
|
||
const desarrolloParam = qs.get('desarrollo')
|
||
const unidadParam = qs.get('unidad')
|
||
const opId = qs.get('oportunidadId')
|
||
if (cliente) setValue('nombre', cliente, { shouldValidate: true })
|
||
if (telefono) setValue('telefono', telefono, { shouldValidate: true })
|
||
if (email) setValue('email', email, { shouldValidate: true })
|
||
if (desarrolloParam) {
|
||
if (desarrollos.includes(desarrolloParam)) setDesarrollo(desarrolloParam)
|
||
else
|
||
setAviso(
|
||
`El desarrollo ${desarrolloParam} no está disponible en el cotizador. Elige un desarrollo de la lista.`,
|
||
)
|
||
}
|
||
if (unidadParam) setUnidadPendiente(unidadParam)
|
||
if (opId) setOportunidadId(opId)
|
||
const cotizacionParam = qs.get('cotizacion')
|
||
if (cotizacionParam) setCotizacionPendiente(cotizacionParam)
|
||
// Solo al montar: es el punto de entrada desde Mi día
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
// Preselecciona la unidad cuando el inventario terminó de cargar. Solo
|
||
// unidades DISPONIBLES: si la pedida ya se apartó/vendió (o no está en el
|
||
// inventario) se avisa y no se preselecciona nada.
|
||
useEffect(() => {
|
||
if (unidadPendiente === null || cargando) return
|
||
const encontrada = unidades.find((u) => u.codigo === unidadPendiente)
|
||
if (!encontrada) {
|
||
setAviso(
|
||
`La unidad ${unidadPendiente} no está en el inventario actual: elige otra unidad disponible.`,
|
||
)
|
||
} else if (!esDisponible(encontrada)) {
|
||
setAviso(
|
||
`La unidad ${encontrada.codigo} ya no está disponible (estatus: ${encontrada.status}). Elige otra unidad disponible.`,
|
||
)
|
||
} else if (!desarrollos.includes(encontrada.desarrollo ?? 'DIAGONAL')) {
|
||
setAviso(
|
||
`El desarrollo de la unidad ${encontrada.codigo} no está disponible en el cotizador: elige otra unidad disponible.`,
|
||
)
|
||
} else {
|
||
const desarrolloUnidad = encontrada.desarrollo ?? 'DIAGONAL'
|
||
if (desarrolloUnidad !== desarrollo) setDesarrollo(desarrolloUnidad)
|
||
setUnidad(encontrada)
|
||
setAviso(`Unidad ${encontrada.codigo} preseleccionada desde Mi día.`)
|
||
}
|
||
setUnidadPendiente(null)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [unidades, cargando, unidadPendiente])
|
||
|
||
// Retoma la cotización pedida por URL cuando el inventario ya cargó
|
||
useEffect(() => {
|
||
if (cotizacionPendiente === null || cargando) return
|
||
const id = cotizacionPendiente
|
||
setCotizacionPendiente(null)
|
||
void getCotizacion(id)
|
||
.then((c) => {
|
||
if (c) handleRetomar(c)
|
||
else setAviso(`No se encontró la cotización entre las tuyas (${id}).`)
|
||
})
|
||
.catch(() => setAviso('No se pudo cargar la cotización de la liga.'))
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [cotizacionPendiente, cargando, unidades])
|
||
|
||
// Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones
|
||
const unidadNoDisponible = unidad !== null && !esDisponible(unidad)
|
||
const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !guardando
|
||
|
||
/** 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()
|
||
if (!unidad || !resultado) throw new Error('No hay esquema para guardar localmente')
|
||
return {
|
||
id: `local-${timestamp}`,
|
||
folio: `CTE-LOCAL-${timestamp}`,
|
||
unidadCodigo: unidad.codigo,
|
||
desarrollo,
|
||
clienteNombre: cliente.nombre,
|
||
clienteTelefono: cliente.telefono ?? null,
|
||
clienteEmail: cliente.email ?? null,
|
||
esquema: 'FRASA_2026',
|
||
tipoPropuesta: 'FRASA_2026',
|
||
acabados,
|
||
apartado: apartadoEfectivo,
|
||
descuento: 0,
|
||
precioLista,
|
||
precioFinal: precioLista,
|
||
saldoFirma: resultado.saldoFirma,
|
||
engancheDiferible: resultado.enganche,
|
||
escrituracion: resultado.escrituracion,
|
||
escrituracionDiferida,
|
||
enganche: resultado.enganche,
|
||
numMensualidades,
|
||
mensualidad: resultado.mensualidad,
|
||
pagoFinal: resultado.pagoUnicoFirma,
|
||
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).
|
||
* Si el servidor RECHAZA la cotización (unidad no disponible, datos inválidos)
|
||
* devuelve null: se avisa y no se guarda nada.
|
||
*/
|
||
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
|
||
if (!unidad || !resultado) return null
|
||
try {
|
||
const guardada = await guardarCotizacion({
|
||
unidadCodigo: unidad.codigo,
|
||
clienteNombre: cliente.nombre,
|
||
...(cliente.email ? { clienteEmail: cliente.email } : {}),
|
||
...(cliente.telefono ? { clienteTelefono: cliente.telefono } : {}),
|
||
acabados,
|
||
apartado: apartadoEfectivo,
|
||
numMensualidades,
|
||
escrituracionDiferida,
|
||
...(oportunidadId !== null ? { oportunidadId } : {}),
|
||
})
|
||
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
|
||
setUltimaGuardada(conFolio)
|
||
toast.success('Cotización guardada correctamente')
|
||
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
|
||
return conFolio
|
||
} catch (err) {
|
||
// El servidor contestó y rechazó la cotización: no se guarda ni local
|
||
if (err instanceof ErrorApiCotizacion) {
|
||
toast.error(err.message)
|
||
setAviso(err.message)
|
||
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
|
||
}
|
||
}
|
||
|
||
const generarPdfConCliente = async (cliente: Cliente) => {
|
||
if (!unidad || !resultado) return
|
||
setGuardando(true)
|
||
try {
|
||
const guardada = await guardarEnApi(cliente)
|
||
// Rechazada por el servidor (p. ej. la unidad dejó de estar disponible):
|
||
// no se genera el PDF
|
||
if (!guardada) return
|
||
const folio = guardada.folio ?? folioCotizacion(guardada.id)
|
||
let qrDataUrl: string | undefined
|
||
// 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,
|
||
acabados,
|
||
apartado: apartadoEfectivo,
|
||
precioLista,
|
||
// El PDF imprime SOLO el esquema único FRASA 2026
|
||
esquema: resultado,
|
||
cliente,
|
||
desarrollo: params,
|
||
folio,
|
||
...(qrDataUrl ? { qrDataUrl } : {}),
|
||
vigencia: calcularVigencia(),
|
||
})
|
||
} finally {
|
||
setGuardando(false)
|
||
}
|
||
}
|
||
|
||
const handleGenerarPdf = () => {
|
||
if (!unidad) return
|
||
// Sin nombre capturado, pide los datos del cliente en el modal
|
||
if (nombreCliente.trim().length < 3) {
|
||
setGuardarSinPdf(false)
|
||
setModalClienteAbierto(true)
|
||
return
|
||
}
|
||
void generarPdfConCliente({
|
||
nombre: nombreCliente.trim(),
|
||
email: watch('email') || undefined,
|
||
telefono: watch('telefono') || undefined,
|
||
})
|
||
}
|
||
|
||
/** 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 })
|
||
if (guardarSinPdf) {
|
||
setGuardarSinPdf(false)
|
||
void guardarSolo(cliente)
|
||
} else {
|
||
void generarPdfConCliente(cliente)
|
||
}
|
||
}
|
||
|
||
const handleCompartir = async () => {
|
||
if (!unidad || !resultado) return
|
||
const texto = textoCompartir({
|
||
unidadCodigo: unidad.codigo,
|
||
desarrolloNombre: params.nombre,
|
||
esquema: resultado,
|
||
apartado: apartadoEfectivo,
|
||
vigencia: calcularVigencia(),
|
||
...(ultimaGuardada
|
||
? {
|
||
folio: ultimaGuardada.folio ?? folioCotizacion(ultimaGuardada.id),
|
||
// La URL pública solo existe si la cotización vive en el servidor
|
||
...(esCotizacionLocal(ultimaGuardada)
|
||
? {}
|
||
: { urlPublica: urlPublicaCotizacion(ultimaGuardada.id) }),
|
||
}
|
||
: {}),
|
||
})
|
||
try {
|
||
await navigator.clipboard.writeText(texto)
|
||
setAviso('Resumen copiado al portapapeles.')
|
||
} catch {
|
||
setAviso('No se pudo copiar al portapapeles.')
|
||
}
|
||
const telefono = watch('telefono') || ultimaGuardada?.clienteTelefono || undefined
|
||
const url = urlWhatsApp(telefono, texto)
|
||
if (url) window.open(url, '_blank', 'noopener')
|
||
}
|
||
|
||
const handleRetomar = (c: CotizacionGuardada) => {
|
||
const u = unidades.find(
|
||
(x) => x.codigo === c.unidadCodigo && (x.desarrollo ?? 'DIAGONAL') === c.desarrollo,
|
||
)
|
||
if (!u) {
|
||
setAviso(`La unidad ${c.unidadCodigo} ya no está en el inventario actual.`)
|
||
return
|
||
}
|
||
const nueva = esEsquemaNuevo(c)
|
||
restaurar({
|
||
desarrollo: c.desarrollo,
|
||
unidad: u,
|
||
acabados: c.acabados,
|
||
apartado: c.apartado,
|
||
// Las cotizaciones del esquema anterior retoman con los defaults del
|
||
// nuevo esquema (14 mensualidades, escrituración a la firma)
|
||
...(nueva
|
||
? {
|
||
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : undefined,
|
||
escrituracionDiferida: c.escrituracionDiferida,
|
||
}
|
||
: {}),
|
||
})
|
||
setValue('nombre', c.clienteNombre, { shouldValidate: true })
|
||
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
|
||
setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
|
||
setUltimaGuardada(c)
|
||
const avisoEsquema = nueva
|
||
? ''
|
||
: ` Usa el esquema anterior (${c.tipoPropuesta}); se cargó con el esquema FRASA 2026 y sus valores por defecto.`
|
||
setAviso(
|
||
esDisponible(u)
|
||
? `Cotización ${c.folio ?? ''} retomada.${avisoEsquema}`
|
||
: `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.${avisoEsquema}`,
|
||
)
|
||
}
|
||
|
||
const handleNueva = () => {
|
||
nueva()
|
||
reset({ nombre: '', email: '', telefono: '' })
|
||
setUltimaGuardada(null)
|
||
setOportunidadId(null) // la nueva cotización ya no va ligada a la oportunidad anterior
|
||
setAviso(null)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||
<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>
|
||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Cotizador</h1>
|
||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8] capitalize">{formatoFechaLarga(new Date())}</p>
|
||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||
Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}
|
||
</p>
|
||
</div>
|
||
<DarkModeToggle />
|
||
</div>
|
||
|
||
{oportunidadId !== null && nombreCliente.trim() !== '' && (
|
||
<p
|
||
role="status"
|
||
className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm font-medium text-blue-800"
|
||
>
|
||
Cotizando para {nombreCliente} — al guardar, la cotización queda ligada a su oportunidad.
|
||
</p>
|
||
)}
|
||
|
||
{unidadNoDisponible && unidad && (
|
||
<p
|
||
role="alert"
|
||
className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-800"
|
||
>
|
||
La unidad {unidad.codigo} no está disponible (estatus: {unidad.status}): no se puede
|
||
cotizar ni apartar. Elige otra unidad disponible; Guardar, Compartir y Generar PDF
|
||
quedan bloqueados.
|
||
</p>
|
||
)}
|
||
|
||
<div className="grid items-start gap-6 md:grid-cols-1 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
|
||
{/* Panel de configuración */}
|
||
<aside className="space-y-6 lg:sticky lg:top-24">
|
||
<section className="space-y-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||
{error && <p className="text-xs text-red-700">{error}</p>}
|
||
{usandoSeed && !cargando && (
|
||
<p className="text-xs text-amber-700">Datos de demostración (sin conexión al servidor)</p>
|
||
)}
|
||
{cargando ? (
|
||
<SkeletonList count={3} />
|
||
) : (
|
||
<>
|
||
<div>
|
||
<span className={etiquetaCampo}>Desarrollo</span>
|
||
<div className="flex flex-wrap overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
|
||
{desarrollos.map((id) => {
|
||
const activo = id === desarrollo
|
||
const total = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === id).length
|
||
const disponibles = unidades.filter(
|
||
(u) => (u.desarrollo ?? 'DIAGONAL') === id && u.status === 'DISPONIBLE',
|
||
).length
|
||
return (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
aria-pressed={activo}
|
||
onClick={() => setDesarrollo(id)}
|
||
className={`px-4 py-2.5 text-xs font-medium transition-colors duration-100 ${
|
||
activo
|
||
? 'bg-accent text-white'
|
||
: 'bg-white text-[#4B5563] dark:text-[#CBD5E1] hover:bg-[#F9FAFB] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]'
|
||
}`}
|
||
>
|
||
{desarrolloEfectivo(reglas, id).nombre}
|
||
<span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B] dark:text-[#94A3B8]'}`}>
|
||
{disponibles}/{total}
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||
{unidadesDesarrollo.length} unidades · {disponiblesDesarrollo} disponibles
|
||
</p>
|
||
</div>
|
||
<UnitSelector
|
||
unidades={unidadesDesarrollo}
|
||
valor={unidad?.codigo ?? ''}
|
||
onChange={(codigo) =>
|
||
setUnidad(unidadesDesarrollo.find((u) => u.codigo === codigo) ?? null)
|
||
}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{!params.precioUnico && (
|
||
<div>
|
||
<span className={etiquetaCampo}>Cocina y closet</span>
|
||
<div className="inline-flex overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
|
||
{(['SI', 'NO'] as const).map((opcion) => {
|
||
const activo = (opcion === 'SI') === acabados
|
||
return (
|
||
<button
|
||
key={opcion}
|
||
type="button"
|
||
aria-pressed={activo}
|
||
onClick={() => setAcabados(opcion === 'SI')}
|
||
className={`px-5 py-2.5 text-xs font-medium transition-colors duration-100 ${
|
||
activo
|
||
? 'bg-accent text-white'
|
||
: 'bg-white text-[#4B5563] dark:text-[#CBD5E1] hover:bg-[#F9FAFB] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]'
|
||
}`}
|
||
>
|
||
{opcion}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label htmlFor="apartado" className={etiquetaCampo}>
|
||
Monto de apartado (MXN)
|
||
</label>
|
||
<input
|
||
id="apartado"
|
||
type="number"
|
||
step={1000}
|
||
value={apartado}
|
||
onChange={(e) => {
|
||
const v = Number(e.target.value)
|
||
setApartado(Number.isFinite(v) && v >= 0 ? v : 0)
|
||
}}
|
||
className={campoInput}
|
||
/>
|
||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoMoneda(apartado)}</p>
|
||
</div>
|
||
|
||
<BuscadorCliente
|
||
onSeleccion={(lead) => {
|
||
setValue('nombre', lead.nombre, { shouldValidate: true })
|
||
if (lead.telefono) setValue('telefono', lead.telefono, { shouldValidate: true })
|
||
if (lead.email) setValue('email', lead.email, { shouldValidate: true })
|
||
if (lead.fuente === 'oportunidad') setOportunidadId(lead.id)
|
||
if (lead.desarrollo && desarrollos.includes(lead.desarrollo)) {
|
||
setDesarrollo(lead.desarrollo)
|
||
}
|
||
setAviso(
|
||
`Datos de ${lead.nombre} cargados${
|
||
lead.fuente === 'oportunidad' ? ' — la cotización quedará vinculada a su oportunidad' : ''
|
||
}.`,
|
||
)
|
||
}}
|
||
/>
|
||
|
||
<div>
|
||
<label htmlFor="nombre" className={etiquetaCampo}>
|
||
Nombre del cliente *
|
||
</label>
|
||
<input
|
||
id="nombre"
|
||
type="text"
|
||
className={errors.nombre ? campoInputError : campoInput}
|
||
{...register('nombre')}
|
||
/>
|
||
{errors.nombre && <p className="mt-1 text-xs text-red-700">{errors.nombre.message}</p>}
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="email" className={etiquetaCampo}>
|
||
Correo electrónico (opcional)
|
||
</label>
|
||
<input
|
||
id="email"
|
||
type="email"
|
||
className={errors.email ? campoInputError : campoInput}
|
||
{...register('email')}
|
||
/>
|
||
{errors.email && <p className="mt-1 text-xs text-red-700">{errors.email.message}</p>}
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="telefono" className={etiquetaCampo}>
|
||
Teléfono (opcional)
|
||
</label>
|
||
<input
|
||
id="telefono"
|
||
type="tel"
|
||
className={errors.telefono ? campoInputError : campoInput}
|
||
{...register('telefono')}
|
||
/>
|
||
{errors.telefono && (
|
||
<p className="mt-1 text-xs text-red-700">{errors.telefono.message}</p>
|
||
)}
|
||
</div>
|
||
|
||
{unidad && (
|
||
<div className="border-t border-[#E5E7EB] dark:border-[#1E293B] pt-6">
|
||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||
Precio de lista
|
||
</p>
|
||
<p className="mt-2 text-3xl font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||
{formatoMoneda(precioLista)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{unidad && (
|
||
<button
|
||
type="button"
|
||
onClick={handleNueva}
|
||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||
>
|
||
Nueva cotización
|
||
</button>
|
||
)}
|
||
</section>
|
||
|
||
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
|
||
</aside>
|
||
|
||
{/* Panel del esquema único FRASA 2026 */}
|
||
<section className="min-w-0">
|
||
{!unidad || !resultado ? (
|
||
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]">
|
||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||
Selecciona una unidad para ver el esquema de cotización.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<EsquemaPanel
|
||
esquema={resultado}
|
||
numMensualidades={numMensualidades}
|
||
onNumMensualidades={setNumMensualidades}
|
||
escrituracionDiferida={escrituracionDiferida}
|
||
onEscrituracionDiferida={setEscrituracionDiferida}
|
||
/>
|
||
)}
|
||
</section>
|
||
</div>
|
||
|
||
{/* Secciones de ancho completo */}
|
||
<div className="mt-10 space-y-8">
|
||
<MisCotizaciones
|
||
cotizaciones={misCotizaciones}
|
||
cargando={cargandoMias}
|
||
error={errorMias}
|
||
onRetomar={handleRetomar}
|
||
/>
|
||
</div>
|
||
</main>
|
||
|
||
{/* Barra fija de cierre: resumen del esquema + 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">
|
||
{resultado ? (
|
||
<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">
|
||
Esquema FRASA 2026 · Hoy {formatoMoneda(resultado.apartado)} · A la firma{' '}
|
||
{formatoMoneda(resultado.pagoUnicoFirma)} · {resultado.numMensualidades} ×{' '}
|
||
{formatoMoneda(resultado.mensualidad)}
|
||
</span>
|
||
</span>
|
||
) : (
|
||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||
Selecciona una unidad para ver el esquema, 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')}
|
||
telefonoInicial={watch('telefono')}
|
||
emailInicial={watch('email')}
|
||
onAceptar={handleModalAceptar}
|
||
onCancelar={() => setModalClienteAbierto(false)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|