feat: docs en apartados, enganche seleccionable y prefill de clientes
- 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
This commit is contained in:
105
apps/cotizador/src/components/BuscadorCliente.tsx
Normal file
105
apps/cotizador/src/components/BuscadorCliente.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { buscarLeads, type LeadResumen } from '../services/api'
|
||||
|
||||
const DEBOUNCE_MS = 300
|
||||
|
||||
interface Props {
|
||||
onSeleccion: (lead: LeadResumen) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscador con debounce de leads/oportunidades de Monday (GET /leads/buscar):
|
||||
* jala nombre, teléfono y email del cliente al cotizador; si el resultado es
|
||||
* una oportunidad, la cotización queda vinculada a ella al guardar.
|
||||
*/
|
||||
export function BuscadorCliente({ onSeleccion }: Props) {
|
||||
const [q, setQ] = useState('')
|
||||
const [resultados, setResultados] = useState<LeadResumen[] | null>(null)
|
||||
const [buscando, setBuscando] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const texto = q.trim()
|
||||
if (texto.length < 2) {
|
||||
setResultados(null)
|
||||
setError('')
|
||||
setBuscando(false)
|
||||
return
|
||||
}
|
||||
setBuscando(true)
|
||||
const timer = setTimeout(() => {
|
||||
buscarLeads(texto)
|
||||
.then((lista) => {
|
||||
setResultados(lista)
|
||||
setError('')
|
||||
})
|
||||
.catch(() => {
|
||||
setResultados(null)
|
||||
setError('No se pudo buscar en Monday. Intenta de nuevo.')
|
||||
})
|
||||
.finally(() => setBuscando(false))
|
||||
}, DEBOUNCE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [q])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="busqueda-cliente" className="mb-2 block text-xs font-medium text-[#4B5563]">
|
||||
Buscar cliente en Monday (lead u oportunidad)
|
||||
</label>
|
||||
<input
|
||||
id="busqueda-cliente"
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Nombre, teléfono o email…"
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
|
||||
{buscando && <p className="mt-3 text-xs text-[#64748B]">Buscando…</p>}
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-xs text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!buscando && !error && resultados !== null && resultados.length === 0 && (
|
||||
<p className="mt-3 text-xs text-[#64748B]">Sin coincidencias.</p>
|
||||
)}
|
||||
|
||||
{resultados !== null && resultados.length > 0 && (
|
||||
<ul className="mt-3 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
{resultados.map((lead) => (
|
||||
<li key={`${lead.fuente}-${lead.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSeleccion(lead)
|
||||
setQ('')
|
||||
setResultados(null)
|
||||
}}
|
||||
className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-100 hover:bg-[#F8FAFC]"
|
||||
>
|
||||
<span className="flex items-baseline gap-2 text-sm text-[#111827]">
|
||||
{lead.nombre}
|
||||
<span
|
||||
className={
|
||||
lead.fuente === 'oportunidad'
|
||||
? 'rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-800'
|
||||
: 'rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600'
|
||||
}
|
||||
>
|
||||
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-[#64748B]">
|
||||
{lead.telefono || 'sin teléfono'}
|
||||
{lead.estado ? ` · ${lead.estado}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ interface Props {
|
||||
numero: number
|
||||
titulo: string
|
||||
propuesta: PropuestaResultado
|
||||
onDescuentoChange: (fraccion: number) => void
|
||||
}
|
||||
|
||||
function Renglon({ etiqueta, valor, nota }: { etiqueta: string; valor: string; nota?: string }) {
|
||||
@@ -24,10 +23,8 @@ function Renglon({ etiqueta, valor, nota }: { etiqueta: string; valor: string; n
|
||||
)
|
||||
}
|
||||
|
||||
/** Tarjeta de una propuesta comercial con descuento editable */
|
||||
export function ProposalCard({ numero, titulo, propuesta, onDescuentoChange }: Props) {
|
||||
const porcentaje = propuesta.descuento * 100
|
||||
|
||||
/** Tarjeta de una propuesta comercial (el descuento ya no es editable: usa el del Excel) */
|
||||
export function ProposalCard({ numero, titulo, propuesta }: Props) {
|
||||
return (
|
||||
<article className="flex flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-all duration-200 hover:shadow-md">
|
||||
<header className="mb-6 border-b border-[#E5E7EB] pb-4">
|
||||
@@ -37,30 +34,10 @@ export function ProposalCard({ numero, titulo, propuesta, onDescuentoChange }: P
|
||||
<h2 className="mt-1 text-xl font-semibold text-[#111827]">{titulo}</h2>
|
||||
</header>
|
||||
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor={`descuento-${propuesta.tipo}`}
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563]"
|
||||
>
|
||||
Descuento (%)
|
||||
</label>
|
||||
<input
|
||||
id={`descuento-${propuesta.tipo}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={porcentaje}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value)
|
||||
onDescuentoChange(Number.isFinite(v) ? Math.min(100, Math.max(0, v)) / 100 : 0)
|
||||
}}
|
||||
className="w-28 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#E5E7EB]">
|
||||
<Renglon etiqueta="Monto de descuento" valor={formatoMoneda(propuesta.montoDescuento)} />
|
||||
{propuesta.descuento > 0 && (
|
||||
<Renglon etiqueta="Monto de descuento" valor={formatoMoneda(propuesta.montoDescuento)} />
|
||||
)}
|
||||
<Renglon etiqueta="Precio final c/desc." valor={formatoMoneda(propuesta.precioFinal)} />
|
||||
<Renglon
|
||||
etiqueta="Enganche"
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
obtenerPrecioLista,
|
||||
resolverNumMensualidades,
|
||||
validarApartado,
|
||||
validarEnganchePct,
|
||||
type PropuestaResultado,
|
||||
} from '../lib/calculations'
|
||||
import { DESARROLLO_DEFAULT, descuentosDefault, obtenerDesarrollo } from '../data/desarrollos'
|
||||
@@ -26,6 +27,11 @@ interface UseCotizacionResult {
|
||||
apartadoEfectivo: number
|
||||
/** Mensaje de error de la captura del apartado; null si es válida */
|
||||
errorApartado: string | null
|
||||
/** Enganche capturado en PORCENTAJE (10 = 10%); null = % del Excel de cada propuesta */
|
||||
enganchePct: number | null
|
||||
setEnganchePct: (v: number | null) => void
|
||||
/** Mensaje de error del enganche capturado; null si es válido */
|
||||
errorEnganche: string | null
|
||||
descuentos: DescuentosPropuestas
|
||||
setDescuento: (clave: keyof DescuentosPropuestas, valor: number) => void
|
||||
/** Índice de la propuesta elegida (para guardar/compartir) */
|
||||
@@ -57,6 +63,7 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
const [unidad, setUnidad] = useState<Unidad | null>(null)
|
||||
const [acabados, setAcabados] = useState(false)
|
||||
const [apartado, setApartado] = useState<number>(APARTADO_DEFAULT)
|
||||
const [enganchePct, setEnganchePct] = useState<number | null>(null)
|
||||
const [descuentos, setDescuentos] = useState<DescuentosPropuestas>(descuentosDefault(DESARROLLO_DEFAULT))
|
||||
const [propuestaIndex, setPropuestaIndex] = useState(0)
|
||||
|
||||
@@ -93,6 +100,7 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
setUnidad(null)
|
||||
setAcabados(false)
|
||||
setApartado(APARTADO_DEFAULT)
|
||||
setEnganchePct(null)
|
||||
setDescuentos(descuentosDefault(desarrollo))
|
||||
setPropuestaIndex(0)
|
||||
}
|
||||
@@ -111,10 +119,14 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
const errorApartado = validarApartado(apartado)
|
||||
const apartadoEfectivo = Math.max(apartado, APARTADO_MINIMO)
|
||||
|
||||
// Enganche personalizado: null = default del Excel; inválido no entra al cálculo
|
||||
const errorEnganche = validarEnganchePct(enganchePct)
|
||||
const engancheEfectivo = errorEnganche === null && enganchePct !== null ? enganchePct / 100 : null
|
||||
|
||||
const propuestas = useMemo(() => {
|
||||
if (!unidad) return []
|
||||
return calcularPropuestas({ unidad, acabados, apartado: apartadoEfectivo, descuentos, desarrollo })
|
||||
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo])
|
||||
return calcularPropuestas({ unidad, acabados, apartado: apartadoEfectivo, descuentos, desarrollo, enganchePct: engancheEfectivo })
|
||||
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, engancheEfectivo])
|
||||
|
||||
return {
|
||||
desarrollo,
|
||||
@@ -128,6 +140,9 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
setApartado,
|
||||
apartadoEfectivo,
|
||||
errorApartado,
|
||||
enganchePct,
|
||||
setEnganchePct,
|
||||
errorEnganche,
|
||||
descuentos,
|
||||
setDescuento,
|
||||
propuestaIndex,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
totalPagado,
|
||||
indiceMenorTotal,
|
||||
validarApartado,
|
||||
validarEnganchePct,
|
||||
APARTADO_DEFAULT,
|
||||
APARTADO_MINIMO,
|
||||
} from './calculations'
|
||||
@@ -192,3 +193,88 @@ describe('indiceMenorTotal', () => {
|
||||
expect(indiceMenorTotal([])).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enganche personalizado (mínimo 2.5% del valor de la propiedad)', () => {
|
||||
const PRECIO_LISTA = 4579000 // U-101 con acabados
|
||||
|
||||
it('override 10%: solo bancario/Infonavit cambian; Contado y Semi Contado quedan fijos', () => {
|
||||
const [bancario, infonavit, contado, semi] = calcularPropuestas({
|
||||
unidad: U101,
|
||||
acabados: true,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 },
|
||||
numMeses: N,
|
||||
enganchePct: 0.1,
|
||||
})
|
||||
// Bancario DIAGONAL: 10% sobre precio de LISTA, neto del apartado
|
||||
expect(bancario.enganche).toBeCloseTo(0.1 * PRECIO_LISTA - APARTADO_DEFAULT, 2)
|
||||
// Infonavit igual (misma base 'lista'); sus mensualidades se reducen (acotadas a 0:
|
||||
// 10% de enganche + 95% de pago final ya cubren más que el precio)
|
||||
expect(infonavit.enganche).toBeCloseTo(0.1 * PRECIO_LISTA - APARTADO_DEFAULT, 2)
|
||||
expect(infonavit.mensualidadesTotal).toBe(0)
|
||||
// Contado (90%) y Semi Contado (50%): estructuras fijas del Excel, intactas
|
||||
expect(contado.enganche).toBeCloseTo(4018678, 2)
|
||||
expect(semi.enganche).toBeCloseTo(0.5 * semi.precioFinal - APARTADO_DEFAULT, 2)
|
||||
})
|
||||
|
||||
it('override 4%: las mensualidades de Infonavit se recalculan con el enganche elegido', () => {
|
||||
const [, infonavit] = calcularPropuestas({
|
||||
unidad: U101,
|
||||
acabados: true,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 },
|
||||
numMeses: N,
|
||||
enganchePct: 0.04,
|
||||
})
|
||||
const engancheEsperado = 0.04 * PRECIO_LISTA - APARTADO_DEFAULT
|
||||
expect(infonavit.enganche).toBeCloseTo(engancheEsperado, 2)
|
||||
expect(infonavit.mensualidadesTotal).toBeCloseTo(PRECIO_LISTA - infonavit.pagoFinal - APARTADO_DEFAULT - engancheEsperado, 2)
|
||||
})
|
||||
|
||||
it('se acota al mínimo 2.5% aunque se pida menos', () => {
|
||||
const [bancario] = calcularPropuestas({
|
||||
unidad: U101,
|
||||
acabados: true,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 },
|
||||
numMeses: N,
|
||||
enganchePct: 0.01, // por debajo del mínimo
|
||||
})
|
||||
expect(bancario.enganche).toBeCloseTo(0.025 * PRECIO_LISTA - APARTADO_DEFAULT, 2)
|
||||
})
|
||||
|
||||
it('las mensualidades nunca quedan en negativo (enganche muy alto)', () => {
|
||||
const [, infonavit] = calcularPropuestas({
|
||||
unidad: U101,
|
||||
acabados: true,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 },
|
||||
numMeses: N,
|
||||
enganchePct: 0.5, // 50% + pago final 95% > 100%: remanente negativo → 0
|
||||
})
|
||||
expect(infonavit.mensualidadesTotal).toBe(0)
|
||||
expect(infonavit.mensualidad).toBe(0)
|
||||
})
|
||||
|
||||
it('null = comportamiento del Excel (sin override)', () => {
|
||||
const [bancario] = calcularPropuestas({
|
||||
unidad: U101,
|
||||
acabados: true,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 },
|
||||
numMeses: N,
|
||||
enganchePct: null,
|
||||
})
|
||||
expect(bancario.enganche).toBeCloseTo(94475, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validarEnganchePct', () => {
|
||||
it('null es válido (sin override); mínimo 2.5%; máximo 100%', () => {
|
||||
expect(validarEnganchePct(null)).toBeNull()
|
||||
expect(validarEnganchePct(2.5)).toBeNull()
|
||||
expect(validarEnganchePct(50)).toBeNull()
|
||||
expect(validarEnganchePct(2.4)).toBe('El enganche mínimo es 2.5% del valor de la propiedad')
|
||||
expect(validarEnganchePct(101)).toBe('El enganche no puede ser mayor al 100%')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,31 @@ export const APARTADO_DEFAULT = 20000
|
||||
/** Monto mínimo de apartado aceptado por el cotizador */
|
||||
export const APARTADO_MINIMO = 20000
|
||||
|
||||
/** Enganche mínimo seleccionable: 2.5% del valor de la propiedad (fracción) */
|
||||
export const ENGANCHE_MINIMO_PCT = 0.025
|
||||
|
||||
/**
|
||||
* Mensaje de error si el enganche capturado (en PORCENTAJE, 2.5 = 2.5%) es
|
||||
* inválido; null si es válido. null de entrada = sin override (default Excel).
|
||||
*/
|
||||
export function validarEnganchePct(pct: number | null): string | null {
|
||||
if (pct === null) return null
|
||||
if (!Number.isFinite(pct) || pct < 2.5) {
|
||||
return 'El enganche mínimo es 2.5% del valor de la propiedad'
|
||||
}
|
||||
if (pct > 100) return 'El enganche no puede ser mayor al 100%'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* ¿La propuesta acepta enganche personalizado? Solo los esquemas con crédito
|
||||
* (bancario/Infonavit-Cofinavit: enganche financiado de 2.5–5%). Contado (90%)
|
||||
* y Semi Contado (50%) son estructuras fijas del Excel: no se tocan.
|
||||
*/
|
||||
export function aplicaEnganchePersonalizado(cfg: PropuestaConfig): boolean {
|
||||
return cfg.modoMensualidades !== 'ninguna' && cfg.pctEnganche > 0 && cfg.pctEnganche <= 0.05
|
||||
}
|
||||
|
||||
/**
|
||||
* Mensaje de error si el apartado capturado es inválido; null si es válido.
|
||||
* La UI acota el valor al mínimo para los cálculos y muestra este mensaje.
|
||||
@@ -68,6 +93,12 @@ export interface CotizacionInput {
|
||||
desarrollo?: string
|
||||
/** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */
|
||||
numMeses?: number
|
||||
/**
|
||||
* Enganche elegido por el asesor (fracción, mínimo ENGANCHE_MINIMO_PCT);
|
||||
* null = el % del Excel de cada propuesta. Solo aplica a esquemas con
|
||||
* crédito (ver aplicaEnganchePersonalizado).
|
||||
*/
|
||||
enganchePct?: number | null
|
||||
}
|
||||
|
||||
const MS_POR_MES = 30.4 * 86400000
|
||||
@@ -112,15 +143,23 @@ function calcularPropuestaConfig(
|
||||
apartado: number,
|
||||
descuento: number,
|
||||
n: number | null,
|
||||
enganchePct: number | null = null,
|
||||
): PropuestaResultado {
|
||||
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
|
||||
|
||||
// % de enganche efectivo: el override del asesor (mínimo 2.5%) solo aplica a
|
||||
// esquemas con crédito; el resto usa el % fijo del Excel
|
||||
const pctEnganche =
|
||||
enganchePct !== null && aplicaEnganchePersonalizado(cfg)
|
||||
? Math.max(ENGANCHE_MINIMO_PCT, enganchePct)
|
||||
: cfg.pctEnganche
|
||||
|
||||
// Enganche: pct sobre su base, neto del apartado (salvo la guarda de SOLANA)
|
||||
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
|
||||
const enganche =
|
||||
cfg.engancheCeroSinApartado && cfg.pctEnganche === 0
|
||||
cfg.engancheCeroSinApartado && pctEnganche === 0
|
||||
? 0
|
||||
: cfg.pctEnganche * baseEnganche - apartado
|
||||
: pctEnganche * baseEnganche - apartado
|
||||
|
||||
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
|
||||
const pagoFinal = cfg.pctPagoFinal * basePagoFinal
|
||||
@@ -142,9 +181,12 @@ function calcularPropuestaConfig(
|
||||
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
|
||||
break
|
||||
case 'porcentaje':
|
||||
mensualidadesTotal = precioFinal * (1 - cfg.pctEnganche - cfg.pctPagoFinal)
|
||||
mensualidadesTotal = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
|
||||
break
|
||||
}
|
||||
// Con enganche alto el remanente puede quedar en negativo: se acota a 0
|
||||
// (ya no hay nada que diferir; el cliente paga de más al inicio)
|
||||
mensualidadesTotal = Math.max(0, mensualidadesTotal)
|
||||
mensualidad = mensualidadesTotal / n
|
||||
}
|
||||
}
|
||||
@@ -201,6 +243,6 @@ export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[]
|
||||
const precioLista = obtenerPrecioLista(unidad, acabados)
|
||||
const n = input.numMeses ?? resolverNumMensualidades(unidad, params)
|
||||
return params.propuestas.map((cfg) =>
|
||||
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n),
|
||||
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n, input.enganchePct ?? null),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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'
|
||||
@@ -60,7 +61,9 @@ export default function CotizadorPage() {
|
||||
setApartado,
|
||||
apartadoEfectivo,
|
||||
errorApartado,
|
||||
setDescuento,
|
||||
enganchePct,
|
||||
setEnganchePct,
|
||||
errorEnganche,
|
||||
propuestaIndex,
|
||||
setPropuestaIndex,
|
||||
precioLista,
|
||||
@@ -154,7 +157,7 @@ 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))]
|
||||
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !guardando
|
||||
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> => {
|
||||
@@ -169,6 +172,7 @@ export default function CotizadorPage() {
|
||||
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) }
|
||||
@@ -419,6 +423,54 @@ export default function CotizadorPage() {
|
||||
)}
|
||||
</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 *
|
||||
@@ -502,7 +554,6 @@ export default function CotizadorPage() {
|
||||
numero={i + 1}
|
||||
titulo={p.titulo}
|
||||
propuesta={p}
|
||||
onDescuentoChange={(v) => setDescuento(params.propuestas[i].claveDescuento, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -108,3 +108,28 @@ export async function getMisCotizaciones(): Promise<CotizacionGuardada[]> {
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as CotizacionGuardada[]
|
||||
}
|
||||
|
||||
// ---------- Búsqueda de clientes en Monday (prellenado del cotizador) ----------
|
||||
|
||||
export interface LeadResumen {
|
||||
id: string
|
||||
nombre: string
|
||||
telefono: string
|
||||
email: string
|
||||
estado: string
|
||||
origen: string
|
||||
/** 'lead' = tablero Leads; 'oportunidad' = tablero Oportunidades (se vincula al guardar) */
|
||||
fuente: 'lead' | 'oportunidad'
|
||||
/** Desarrollo de interés ('' si no tiene) */
|
||||
desarrollo?: string
|
||||
/** Modelo de interés ('' si no tiene) */
|
||||
modeloInteres?: string
|
||||
}
|
||||
|
||||
/** GET /leads/buscar?q= — busca en AMBOS tableros (Leads + Oportunidades) */
|
||||
export async function buscarLeads(q: string): Promise<LeadResumen[]> {
|
||||
const res = await fetch(`${API_URL}/leads/buscar?q=${encodeURIComponent(q)}`, conAuth())
|
||||
manejar401(res)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as LeadResumen[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user