Files
FRASA-CAS/apps/cotizador/src/pages/CotizadorPage.tsx
Consult AS Technology 61d1b84fc3 feat: esquema unico de cotizacion FRASA 2026 (reemplaza las 4 propuestas)
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
2026-09-28 13:00:22 +00:00

789 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}