feat(contactos): detalle completo del cliente con propiedad e historial

- GET /api/contactos/:id/detalle: todas las columnas del contacto, la
  propiedad comprada/en proceso (apartado relacionado por teléfono/email,
  enriquecido con el desarrollo de la unidad), quién atiende cada item
  relacionado (leads/oportunidades/apartados, columna people) y el
  historial completo de atención (activity log de los 4 tableros +
  capturas y alta local con nombre del capturista)
- monday.service: getContactoDetalle (columnas por título),
  getHistorialItems (todos los eventos del activity log por items) y
  ApartadoMonday enriquecido (teléfono, email, precio final, fecha compra)
- Landing: la lista de Contactos abre un modal por cliente con datos,
  propiedad, relaciones CRM (con quién atiende) y timeline de historial
- Tests: 465 API + 12 landing, verde
This commit is contained in:
2026-09-04 23:55:36 +00:00
parent 27ba9ea8fa
commit a4575bb948
8 changed files with 744 additions and 11 deletions

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { getContactos, type Contacto } from '../services/api'
import { ModalContacto } from './ModalContacto'
interface Props {
onVolver: () => void
@@ -8,12 +9,14 @@ interface Props {
/**
* Vista interna "Contactos": lista del tablero Contactos de Monday (lo más
* reciente primero). Vive dentro de la landing (no hay router); se abre desde
* la tarjeta Contactos del grid.
* la tarjeta Contactos del grid. Click en un contacto → modal con toda su
* información, propiedad, quién lo atiende e historial de atención.
*/
export function VistaContactos({ onVolver }: Props) {
const [contactos, setContactos] = useState<Contacto[] | null>(null)
const [cargando, setCargando] = useState(true)
const [error, setError] = useState('')
const [seleccionado, setSeleccionado] = useState<Contacto | null>(null)
const cargar = useCallback(() => {
setCargando(true)
@@ -60,20 +63,35 @@ export function VistaContactos({ onVolver }: Props) {
) : (
<ul className="divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
{contactos.map((contacto) => (
<li key={contacto.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-6 py-4">
<span className="text-sm font-semibold text-[#111827]">{contacto.nombre}</span>
{contacto.estado && (
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">
{contacto.estado}
</span>
)}
<span className="text-sm text-[#64748B]">{contacto.telefono || 'Sin teléfono'}</span>
<span className="text-sm text-[#64748B]">{contacto.email || 'Sin email'}</span>
<li key={contacto.id}>
<button
type="button"
onClick={() => setSeleccionado(contacto)}
className="flex w-full flex-wrap items-center gap-x-4 gap-y-1 px-6 py-4 text-left transition-colors duration-100 hover:bg-[#F8FAFC]"
>
<span className="text-sm font-semibold text-[#111827]">{contacto.nombre}</span>
{contacto.estado && (
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">
{contacto.estado}
</span>
)}
<span className="text-sm text-[#64748B]">{contacto.telefono || 'Sin teléfono'}</span>
<span className="text-sm text-[#64748B]">{contacto.email || 'Sin email'}</span>
<span className="ml-auto text-xs font-medium text-accent">Ver detalle →</span>
</button>
</li>
))}
</ul>
)}
</section>
{seleccionado && (
<ModalContacto
contactoId={seleccionado.id}
nombre={seleccionado.nombre}
onCerrar={() => setSeleccionado(null)}
/>
)}
</main>
)
}