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:
2026-09-04 22:32:40 +00:00
parent 1963c06d93
commit cbd50e2066
19 changed files with 700 additions and 44 deletions

View 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>
)
}

View File

@@ -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"