- Mis apartados (Mi día): botón Documentos con precarga del expediente — captura del cotizador y captura de pago apartado (obligatorios) más documentos generales; los archivos se guardan en el cliente ligado al apartado (se crea la ficha mínima si no existe). Endpoints GET/POST /apartados/:id/documentos (multipart, 10 MB, pdf/jpg/png) - Cotizador: se oculta el editor de descuento (usan los % del Excel); enganche seleccionable con mínimo 2.5% del valor de la propiedad, solo en esquemas con crédito (bancario/Infonavit); mensualidades se recalculan con el enganche elegido y nunca quedan en negativo - Cotizador: buscador de cliente en Monday (lead u oportunidad) que prellena nombre/contacto/desarrollo y vincula la cotización a la oportunidad al guardar - Alta Afluencia: buscador que jala nombre, contacto, origen, desarrollo y modelo desde leads y oportunidades existentes - buscarLeads devuelve desarrollo y modelo de interés - Landing: el módulo Clientes vuelve a llamarse Cobranza - Tests: 456 API + 94 cotizador + 14 mi-dia + 116 alta-clientes, verde
642 lines
26 KiB
TypeScript
642 lines
26 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 { UnitSelector } from '../components/UnitSelector'
|
|
import { UnitInfoCard } from '../components/UnitInfoCard'
|
|
import { ProposalCard } from '../components/ProposalCard'
|
|
import { ComparadorPropuestas } from '../components/ComparadorPropuestas'
|
|
import { ComparadorUnidades } from '../components/ComparadorUnidades'
|
|
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 { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
|
|
import {
|
|
getMisCotizaciones,
|
|
guardarCotizacion,
|
|
urlPublicaCotizacion,
|
|
TIPO_A_API,
|
|
TIPO_DE_API,
|
|
type CotizacionGuardada,
|
|
} from '../services/api'
|
|
import type { Cliente } from '../types'
|
|
|
|
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'
|
|
const campoInput = `${campoInputBase} border-[#D1D5DB]`
|
|
const campoInputError = `${campoInputBase} border-red-400`
|
|
|
|
const etiquetaCampo = 'mb-2 block text-xs font-medium text-[#4B5563]'
|
|
|
|
export default function CotizadorPage() {
|
|
const { unidades, cargando, error, usandoSeed } = useUnidades()
|
|
const cotizacion = useCotizacion()
|
|
const {
|
|
desarrollo,
|
|
setDesarrollo,
|
|
params,
|
|
unidad,
|
|
setUnidad,
|
|
acabados,
|
|
setAcabados,
|
|
apartado,
|
|
setApartado,
|
|
apartadoEfectivo,
|
|
errorApartado,
|
|
enganchePct,
|
|
setEnganchePct,
|
|
errorEnganche,
|
|
propuestaIndex,
|
|
setPropuestaIndex,
|
|
precioLista,
|
|
numMeses,
|
|
propuestas,
|
|
restaurar,
|
|
nueva,
|
|
} = cotizacion
|
|
|
|
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)
|
|
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)
|
|
|
|
useEffect(() => {
|
|
let vivo = true
|
|
getMisCotizaciones()
|
|
.then((lista) => {
|
|
if (vivo) setMisCotizaciones(lista)
|
|
})
|
|
.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 && LISTA_DESARROLLOS.includes(desarrolloParam)) setDesarrollo(desarrolloParam)
|
|
if (unidadParam) setUnidadPendiente(unidadParam)
|
|
if (opId) setOportunidadId(opId)
|
|
// 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
|
|
useEffect(() => {
|
|
if (unidadPendiente === null || cargando) return
|
|
const encontrada = unidades.find((u) => u.codigo === unidadPendiente)
|
|
if (encontrada) {
|
|
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])
|
|
|
|
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
|
|
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
|
|
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) */
|
|
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
|
|
if (!unidad || !propuestaActiva) return null
|
|
try {
|
|
const guardada = await guardarCotizacion({
|
|
unidadCodigo: unidad.codigo,
|
|
clienteNombre: cliente.nombre,
|
|
...(cliente.email ? { clienteEmail: cliente.email } : {}),
|
|
...(cliente.telefono ? { clienteTelefono: cliente.telefono } : {}),
|
|
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo],
|
|
acabados,
|
|
apartado: apartadoEfectivo,
|
|
descuento: propuestaActiva.descuento,
|
|
...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}),
|
|
...(oportunidadId !== null ? { oportunidadId } : {}),
|
|
})
|
|
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
|
|
setUltimaGuardada(conFolio)
|
|
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
|
|
return conFolio
|
|
} catch (err) {
|
|
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
|
|
}
|
|
}
|
|
|
|
const generarPdfConCliente = async (cliente: Cliente) => {
|
|
if (!unidad) return
|
|
setGuardando(true)
|
|
try {
|
|
const guardada = await guardarEnApi(cliente)
|
|
let folio: string | undefined
|
|
let qrDataUrl: string | undefined
|
|
if (guardada) {
|
|
folio = guardada.folio ?? folioCotizacion(guardada.id)
|
|
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
|
|
margin: 1,
|
|
width: 200,
|
|
})
|
|
}
|
|
generarPdf({
|
|
unidad,
|
|
acabados,
|
|
apartado: apartadoEfectivo,
|
|
precioLista,
|
|
propuestas,
|
|
cliente,
|
|
desarrollo: params,
|
|
...(folio ? { 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) {
|
|
setModalClienteAbierto(true)
|
|
return
|
|
}
|
|
void generarPdfConCliente({
|
|
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)
|
|
}
|
|
|
|
const handleCompartir = async () => {
|
|
if (!unidad || !propuestaActiva) return
|
|
const texto = textoCompartir({
|
|
unidadCodigo: unidad.codigo,
|
|
desarrolloNombre: params.nombre,
|
|
propuesta: propuestaActiva,
|
|
apartado: apartadoEfectivo,
|
|
vigencia: calcularVigencia(),
|
|
...(ultimaGuardada
|
|
? {
|
|
folio: ultimaGuardada.folio ?? folioCotizacion(ultimaGuardada.id),
|
|
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 tipo = TIPO_DE_API[c.tipoPropuesta]
|
|
const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo)
|
|
restaurar({
|
|
desarrollo: c.desarrollo,
|
|
unidad: u,
|
|
acabados: c.acabados,
|
|
apartado: c.apartado,
|
|
descuento: c.descuento,
|
|
propuestaIndex: idx >= 0 ? idx : 0,
|
|
})
|
|
setValue('nombre', c.clienteNombre, { shouldValidate: true })
|
|
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
|
|
setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
|
|
setUltimaGuardada(c)
|
|
setAviso(`Cotización ${c.folio ?? ''} retomada.`)
|
|
}
|
|
|
|
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]">
|
|
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 lg:py-10">
|
|
{/* Encabezado de página */}
|
|
<div className="mb-8">
|
|
<h1 className="text-xl font-semibold text-[#111827]">Cotizador</h1>
|
|
<p className="mt-1 text-sm text-[#64748B] capitalize">{formatoFechaLarga(new Date())}</p>
|
|
<p className="text-sm text-[#64748B]">
|
|
Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}
|
|
</p>
|
|
</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 vinculada a su oportunidad en Monday.
|
|
</p>
|
|
)}
|
|
|
|
<div className="grid items-start gap-6 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 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 ? (
|
|
<p className="text-sm text-[#64748B]">Cargando unidades…</p>
|
|
) : (
|
|
<>
|
|
<div>
|
|
<span className={etiquetaCampo}>Desarrollo</span>
|
|
<div className="flex flex-wrap overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
|
|
{LISTA_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] hover:bg-[#F9FAFB]'
|
|
}`}
|
|
>
|
|
{obtenerDesarrollo(id).nombre}
|
|
<span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B]'}`}>
|
|
{disponibles}/{total}
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<p className="mt-2 text-xs text-[#64748B]">
|
|
{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] hover:bg-[#F9FAFB]'
|
|
}`}
|
|
>
|
|
{opcion}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label htmlFor="apartado" className={etiquetaCampo}>
|
|
Monto de apartado (MXN)
|
|
</label>
|
|
<input
|
|
id="apartado"
|
|
type="number"
|
|
min={0}
|
|
step={1000}
|
|
value={apartado}
|
|
onChange={(e) => {
|
|
const v = Number(e.target.value)
|
|
setApartado(Number.isFinite(v) && v >= 0 ? v : 0)
|
|
}}
|
|
className={errorApartado ? campoInputError : campoInput}
|
|
/>
|
|
{errorApartado ? (
|
|
<p className="mt-1 text-xs text-red-700">{errorApartado}</p>
|
|
) : (
|
|
<p className="mt-1 text-xs text-[#64748B]">{formatoMoneda(apartado)}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="enganche" className={etiquetaCampo}>
|
|
Enganche (%) — opcional
|
|
</label>
|
|
<input
|
|
id="enganche"
|
|
type="number"
|
|
min={2.5}
|
|
max={100}
|
|
step={0.5}
|
|
placeholder="Según propuesta"
|
|
value={enganchePct ?? ''}
|
|
onChange={(e) => {
|
|
const crudo = e.target.value
|
|
if (crudo === '') {
|
|
setEnganchePct(null)
|
|
return
|
|
}
|
|
const v = Number(crudo)
|
|
setEnganchePct(Number.isFinite(v) ? v : null)
|
|
}}
|
|
className={errorEnganche ? campoInputError : campoInput}
|
|
/>
|
|
{errorEnganche ? (
|
|
<p className="mt-1 text-xs text-red-700">{errorEnganche}</p>
|
|
) : (
|
|
<p className="mt-1 text-xs text-[#64748B]">
|
|
Aplica a crédito bancario e Infonavit/Cofinavit; mínimo 2.5%
|
|
{unidad ? ` (${formatoMoneda((precioLista * Math.max(enganchePct ?? 2.5, 2.5)) / 100)})` : ''}
|
|
</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 && LISTA_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] pt-6">
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
|
Precio de lista
|
|
</p>
|
|
<p className="mt-2 text-3xl font-semibold text-[#111827]">
|
|
{formatoMoneda(precioLista)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{unidad && (
|
|
<button
|
|
type="button"
|
|
onClick={handleNueva}
|
|
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
>
|
|
Nueva cotización
|
|
</button>
|
|
)}
|
|
</section>
|
|
|
|
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
|
|
</aside>
|
|
|
|
{/* Panel de propuestas */}
|
|
<section className="min-w-0">
|
|
{!unidad ? (
|
|
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white">
|
|
<p className="text-sm text-[#64748B]">
|
|
Selecciona una unidad para ver las propuestas comerciales.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-8">
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
{propuestas.map((p, i) => (
|
|
<ProposalCard
|
|
key={p.tipo}
|
|
numero={i + 1}
|
|
titulo={p.titulo}
|
|
propuesta={p}
|
|
/>
|
|
))}
|
|
</div>
|
|
<ComparadorPropuestas
|
|
precioLista={precioLista}
|
|
apartado={apartadoEfectivo}
|
|
propuestas={propuestas}
|
|
titulos={propuestas.map((p) => p.titulo)}
|
|
/>
|
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
|
|
<div className="flex flex-wrap items-center justify-end gap-4">
|
|
<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 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"
|
|
>
|
|
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]">{aviso}</p>}
|
|
{!puedeGenerarPdf && !guardando && (
|
|
<p className="mt-2 text-right text-xs text-[#64748B]">
|
|
Selecciona una unidad para guardar y generar el PDF.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
{/* Secciones de ancho completo */}
|
|
<div className="mt-10 space-y-8">
|
|
<MisCotizaciones
|
|
cotizaciones={misCotizaciones}
|
|
cargando={cargandoMias}
|
|
error={errorMias}
|
|
onRetomar={handleRetomar}
|
|
/>
|
|
<ComparadorUnidades
|
|
unidades={unidades}
|
|
tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'}
|
|
apartado={apartadoEfectivo}
|
|
acabados={acabados}
|
|
/>
|
|
</div>
|
|
</main>
|
|
|
|
<ModalCliente
|
|
abierto={modalClienteAbierto}
|
|
nombreInicial={watch('nombre')}
|
|
telefonoInicial={watch('telefono')}
|
|
emailInicial={watch('email')}
|
|
onAceptar={handleModalAceptar}
|
|
onCancelar={() => setModalClienteAbierto(false)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|