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:
192
apps/landing/src/components/ModalContacto.tsx
Normal file
192
apps/landing/src/components/ModalContacto.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getContactoDetalle, type DetalleContacto, type RelacionCrm } from '../services/api'
|
||||
|
||||
interface Props {
|
||||
contactoId: string
|
||||
nombre: string
|
||||
onCerrar: () => void
|
||||
}
|
||||
|
||||
const formatoMoneda = (n: number): string =>
|
||||
n > 0 ? n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }) : '—'
|
||||
|
||||
const formatoFecha = (iso: string): string =>
|
||||
iso
|
||||
? new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: ''
|
||||
|
||||
function Dato({ etiqueta, valor }: { etiqueta: string; valor: string }) {
|
||||
if (valor === '') return null
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#64748B]">{etiqueta}</p>
|
||||
<p className="mt-0.5 text-sm text-[#111827]">{valor}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListaRelaciones({ titulo, items }: { titulo: string; items: RelacionCrm[] }) {
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{items.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-[#E5E7EB] px-3 py-2">
|
||||
<span className="text-sm text-[#111827]">
|
||||
{r.estado || 'Sin estado'}
|
||||
{r.detalle ? <span className="ml-2 text-xs text-[#64748B]">{r.detalle}</span> : null}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-[#64748B]">
|
||||
{r.atendidoPor ?? 'Sin asignar'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal de contacto: toda su información (columnas del board), la propiedad
|
||||
* comprada/en proceso, quién atiende cada item relacionado y el historial
|
||||
* completo de atención (Monday + capturas de las apps).
|
||||
*/
|
||||
export function ModalContacto({ contactoId, nombre, onCerrar }: Props) {
|
||||
const [detalle, setDetalle] = useState<DetalleContacto | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let vivo = true
|
||||
getContactoDetalle(contactoId)
|
||||
.then((d) => {
|
||||
if (vivo) setDetalle(d)
|
||||
})
|
||||
.catch(() => {
|
||||
if (vivo) setError('No se pudo cargar el detalle del contacto.')
|
||||
})
|
||||
return () => {
|
||||
vivo = false
|
||||
}
|
||||
}, [contactoId])
|
||||
|
||||
// Cerrar con Escape
|
||||
useEffect(() => {
|
||||
const alTeclear = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCerrar()
|
||||
}
|
||||
window.addEventListener('keydown', alTeclear)
|
||||
return () => window.removeEventListener('keydown', alTeclear)
|
||||
}, [onCerrar])
|
||||
|
||||
const c = detalle?.contacto ?? {}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-20 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Detalle de ${nombre}`}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onCerrar()
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl rounded-2xl border border-[#E5E7EB] bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-[#E5E7EB] px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-[#111827]">{c['nombre'] ?? nombre}</h2>
|
||||
{c['Estado'] && (
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">{c['Estado']}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCerrar}
|
||||
aria-label="Cerrar"
|
||||
className="rounded-lg px-2 py-1.5 text-sm text-[#64748B] transition-colors hover:bg-[#F9FAFB] hover:text-[#111827]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 px-6 py-5">
|
||||
{error && (
|
||||
<p role="alert" className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!detalle && !error && <p className="text-sm text-[#64748B]">Cargando detalle…</p>}
|
||||
|
||||
{detalle && (
|
||||
<>
|
||||
<section aria-label="Datos del contacto">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B]">Datos del contacto</p>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">
|
||||
<Dato etiqueta="Teléfono" valor={c['Teléfono'] ?? ''} />
|
||||
<Dato etiqueta="Email" valor={c['Email'] ?? ''} />
|
||||
<Dato etiqueta="CURP" valor={c['CURP'] ?? ''} />
|
||||
<Dato etiqueta="RFC" valor={c['RFC'] ?? ''} />
|
||||
<Dato etiqueta="Estado civil" valor={c['Estado Civil'] ?? ''} />
|
||||
<Dato etiqueta="Fecha de nacimiento" valor={c['Fecha Nacimiento'] ?? ''} />
|
||||
<Dato etiqueta="Rango de edad" valor={c['Rango de Edad'] ?? ''} />
|
||||
<Dato etiqueta="Actividad económica" valor={c['Actividad Economica'] ?? ''} />
|
||||
<Dato etiqueta="Vivienda actual" valor={c['Vivienda Actual'] ?? ''} />
|
||||
<Dato etiqueta="Propósito de compra" valor={c['Proposito de Compra'] ?? ''} />
|
||||
<Dato etiqueta="Dirección" valor={c['Dirección'] ?? ''} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{detalle.propiedad && (
|
||||
<section aria-label="Propiedad">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B]">Propiedad</p>
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] p-4">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">
|
||||
<Dato etiqueta="Desarrollo" valor={detalle.propiedad.desarrollo} />
|
||||
<Dato etiqueta="Unidad" valor={detalle.propiedad.unidadCodigo} />
|
||||
<Dato etiqueta="Precio final" valor={formatoMoneda(detalle.propiedad.precioFinal)} />
|
||||
<Dato etiqueta="Estatus" valor={detalle.propiedad.estado} />
|
||||
<Dato etiqueta="Fecha de compra" valor={detalle.propiedad.fechaCompra} />
|
||||
<Dato etiqueta="Atendido por" valor={detalle.propiedad.atendidoPor ?? ''} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section aria-label="Relaciones CRM" className="space-y-4">
|
||||
<ListaRelaciones titulo="Leads" items={detalle.relaciones.leads} />
|
||||
<ListaRelaciones titulo="Oportunidades" items={detalle.relaciones.oportunidades} />
|
||||
<ListaRelaciones titulo="Apartados" items={detalle.relaciones.apartados} />
|
||||
</section>
|
||||
|
||||
<section aria-label="Historial de atención">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
Historial de atención
|
||||
</p>
|
||||
{detalle.historial.length === 0 ? (
|
||||
<p className="text-sm text-[#64748B]">Sin movimientos registrados.</p>
|
||||
) : (
|
||||
<ul className="max-h-64 space-y-2 overflow-y-auto">
|
||||
{detalle.historial.map((h, i) => (
|
||||
<li key={i} className="flex items-baseline justify-between gap-3 rounded-lg border border-[#E5E7EB] px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-[#111827]">
|
||||
{h.detalle}
|
||||
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600">
|
||||
{h.fuente}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-[#64748B]">{h.usuario ?? 'Automatización'}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-[#94A3B8]">{formatoFecha(h.fechaISO)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,3 +53,50 @@ export async function guardarVinculoMonday(mondayUserId: number | null): Promise
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as UsuarioSesion
|
||||
}
|
||||
|
||||
// ---------- Detalle de contacto (modal del módulo Contactos) ----------
|
||||
|
||||
export interface RelacionCrm {
|
||||
id: string
|
||||
nombre: string
|
||||
estado: string
|
||||
/** Quién lo atiende ahora (columna people del tablero); null si sin asignar */
|
||||
atendidoPor: string | null
|
||||
detalle: string
|
||||
}
|
||||
|
||||
export interface EventoHistorialContacto {
|
||||
fechaISO: string
|
||||
usuario: string | null
|
||||
detalle: string
|
||||
fuente: string // 'Contactos' | 'Leads' | 'Oportunidades' | 'Apartado' | 'App'
|
||||
}
|
||||
|
||||
export interface DetalleContacto {
|
||||
/** Columnas del board por título ('nombre' incluido); '' si vacía */
|
||||
contacto: Record<string, string>
|
||||
propiedad: {
|
||||
id: string
|
||||
unidadCodigo: string
|
||||
desarrollo: string
|
||||
estado: string
|
||||
precioFinal: number
|
||||
fechaCompra: string
|
||||
fechaLimite: string
|
||||
atendidoPor: string | null
|
||||
} | null
|
||||
relaciones: {
|
||||
leads: RelacionCrm[]
|
||||
oportunidades: RelacionCrm[]
|
||||
apartados: RelacionCrm[]
|
||||
}
|
||||
historial: EventoHistorialContacto[]
|
||||
}
|
||||
|
||||
/** GET /contactos/:id/detalle — ficha completa: info, propiedad, quién atiende e historial */
|
||||
export async function getContactoDetalle(id: string): Promise<DetalleContacto> {
|
||||
const resp = await fetch(`${API_URL}/contactos/${id}/detalle`, conAuth())
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as DetalleContacto
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user