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

@@ -156,6 +156,16 @@ export const ETIQUETA_FASE_OPORTUNIDAD: Record<FaseOportunidad, string> = {
Abandono: 'Abandono',
}
// Slots de la precarga de documentos en "Mis apartados"; los obligatorios son
// los que pide el proceso de venta (captura del cotizador y de pago apartado)
export const SLOTS_DOCS_APARTADO = [
{ tipo: 'CAPTURA_COTIZADOR', etiqueta: 'Captura del cotizador', obligatorio: true },
{ tipo: 'CAPTURA_PAGO_APARTADO', etiqueta: 'Captura de pago apartado', obligatorio: true },
{ tipo: 'OTRO', etiqueta: 'Documentos generales (INE, CURP, comprobantes…)', obligatorio: false },
] as const
export type SlotDocApartado = (typeof SLOTS_DOCS_APARTADO)[number]['tipo']
/**
* URL del cotizador (/quote/) con los datos del cliente prellenados y el id de
* la oportunidad: al guardar, la cotización queda vinculada a ese cliente en

View File

@@ -12,6 +12,7 @@ import {
FASES_APARTADO,
MOTIVOS_NO_COMPRA,
ORIGENES_LEAD,
SLOTS_DOCS_APARTADO,
textoDiasRestantes,
urlCotizadorParaOportunidad,
type ApartadoMiDia,
@@ -20,6 +21,7 @@ import {
type MotivoNoCompra,
type OportunidadMiDia,
type ResumenMiDia,
type SlotDocApartado,
} from '../lib/mi-dia'
import {
cambiarEstadoApartado,
@@ -28,6 +30,9 @@ import {
crearLead,
descartarLead,
fetchResumenMiDia,
listarDocumentosApartado,
subirDocumentoApartado,
type DocumentoApartado,
} from '../services/mi-dia'
import { getSesion } from '../services/auth'
@@ -234,6 +239,7 @@ function FilaApartado({
onFase: (fase: FaseApartado) => void
enviando: boolean
}) {
const [docsAbiertos, setDocsAbiertos] = useState(false)
return (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -276,11 +282,119 @@ function FilaApartado({
{ETIQUETA_FASE_APARTADO[fase]}
</button>
))}
<button
type="button"
onClick={() => setDocsAbiertos((v) => !v)}
aria-expanded={docsAbiertos}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
{docsAbiertos ? 'Cerrar documentos' : 'Documentos'}
</button>
</div>
{docsAbiertos && <PanelDocumentosApartado itemId={apartado.id} />}
</li>
)
}
/**
* Precarga de documentos del expediente desde "Mis apartados": captura del
* cotizador, captura de pago apartado (ambos obligatorios) y documentos
* generales. Los archivos se guardan en el cliente ligado al apartado.
*/
function PanelDocumentosApartado({ itemId }: { itemId: string }) {
const [documentos, setDocumentos] = useState<DocumentoApartado[] | null>(null)
const [error, setError] = useState('')
const [subiendo, setSubiendo] = useState<SlotDocApartado | null>(null)
const cargar = useCallback(async () => {
try {
setDocumentos(await listarDocumentosApartado(itemId))
setError('')
} catch {
setError('No se pudieron cargar los documentos.')
}
}, [itemId])
useEffect(() => {
void cargar()
}, [cargar])
const subir = async (tipo: SlotDocApartado, archivo: File | undefined) => {
if (!archivo) return
setSubiendo(tipo)
setError('')
try {
await subirDocumentoApartado(itemId, tipo, archivo)
await cargar()
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo subir el archivo.')
} finally {
setSubiendo(null)
}
}
return (
<div className="mt-3 rounded-lg border border-dashed border-[#D1D5DB] bg-[#F8FAFC] p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Expediente del cliente
</p>
{error && (
<p role="alert" className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{error}
</p>
)}
<ul className="mt-3 space-y-3">
{SLOTS_DOCS_APARTADO.map((slot) => {
const delSlot = (documentos ?? []).filter((d) => d.tipo === slot.tipo)
return (
<li key={slot.tipo}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-medium text-[#374151]">
{slot.etiqueta}
{slot.obligatorio && <span className="ml-1 text-red-600">*</span>}
</p>
<label
className={`cursor-pointer rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white ${
subiendo !== null ? 'pointer-events-none opacity-50' : ''
}`}
>
{subiendo === slot.tipo ? 'Subiendo…' : 'Subir archivo'}
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png"
className="sr-only"
disabled={subiendo !== null}
onChange={(e) => {
void subir(slot.tipo, e.target.files?.[0])
e.target.value = ''
}}
/>
</label>
</div>
{documentos === null ? (
<p className="mt-1 text-[11px] text-[#94A3B8]">Cargando…</p>
) : delSlot.length === 0 ? (
<p className="mt-1 text-[11px] text-[#94A3B8]">Sin archivos</p>
) : (
<ul className="mt-1 space-y-0.5">
{delSlot.map((d) => (
<li key={d.id} className="text-[11px] text-[#64748B]">
{d.nombreArchivo} · {formatoFechaCorta(d.createdAt.slice(0, 10))}
</li>
))}
</ul>
)}
</li>
)
})}
</ul>
<p className="mt-3 text-[11px] text-[#94A3B8]">
* Obligatorios para el proceso de venta. PDF, JPG o PNG, máximo 10 MB.
</p>
</div>
)
}
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados */
type TabCrm = 'leads' | 'oportunidades' | 'apartados'

View File

@@ -85,3 +85,30 @@ export function cambiarEstadoApartado(itemId: string, estado: string): Promise<u
body: JSON.stringify({ estado }),
})
}
// ---------- Precarga de documentos del expediente (Mis apartados) ----------
export interface DocumentoApartado {
id: string
tipo: string // CAPTURA_COTIZADOR | CAPTURA_PAGO_APARTADO | OTRO | INE | ...
nombreArchivo: string
createdAt: string // ISO
}
/** GET /apartados/:itemId/documentos — documentos ya cargados del cliente ligado */
export function listarDocumentosApartado(itemId: string): Promise<DocumentoApartado[]> {
return fetchJson<DocumentoApartado[]>(`/apartados/${itemId}/documentos`)
}
/** POST /apartados/:itemId/documentos — multipart; el archivo se guarda en el cliente */
export async function subirDocumentoApartado(itemId: string, tipo: string, archivo: File): Promise<void> {
const form = new FormData()
form.append('tipo', tipo)
form.append('archivos', archivo)
const res = await fetch(`${API_URL}/apartados/${itemId}/documentos`, conAuth({ method: 'POST', body: form }))
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
}