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"
|
||||
|
||||
Reference in New Issue
Block a user