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

@@ -0,0 +1,197 @@
import { config } from '../config/index.js'
import { detalleEventoUltimoCambio, esEstadoFinalApartado } from './mi-dia.service.js'
import type { MondayService } from './monday.service.js'
/**
* Detalle del módulo Contactos: toda la información del contacto, la propiedad
* comprada (apartado relacionado), quién lo atiende (asesor/vendedor actual de
* cada item relacionado) y el historial completo de atención.
*
* Las relaciones se arman por teléfono (últimos 10 dígitos) o email a través de
* los tableros Leads, Oportunidades y Apartado; el historial mezcla el activity
* log de Monday de esos items + el registro local (quién capturó qué en las apps).
*/
export interface RelacionCrm {
id: string
nombre: string
estado: string // estado/etapa actual
/** Quién lo atiende ahora (columna people del tablero); null si sin asignar */
atendidoPor: string | null
detalle: string // origen/desarrollo/unidad según el tablero ('' si no aplica)
}
export interface EventoHistorialContacto {
fechaISO: string
usuario: string | null // nombre resuelto; null si fue automatización o desconocido
detalle: string
fuente: string // 'Contactos' | 'Leads' | 'Oportunidades' | 'Apartado' | 'App'
}
export interface DetalleContacto {
contacto: Record<string, string> // columnas del board por título ('nombre' incluido)
/** Apartado relacionado más reciente (la propiedad comprada o en proceso) */
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[]
}
/** Últimos 10 dígitos del teléfono (dedup/relación tolerante a lada y formato) */
export function claveTelefono(telefono: string): string {
const digitos = telefono.replace(/\D/g, '')
return digitos.length >= 10 ? digitos.slice(-10) : ''
}
type MondayDetalle = Pick<
MondayService,
| 'getContactoDetalle'
| 'getLeads'
| 'getOportunidades'
| 'getApartados'
| 'getHistorialItems'
| 'getUnidad'
>
export interface CapturaLocal {
leadId: string
nombreLead: string
createdAt: Date
usuario: { nombre: string } | null
}
export interface ClienteLocal {
id: string
nombreCompleto: string
proyecto: string
unidadCodigo: string | null
createdAt: Date
capturadoPor: { nombre: string } | null
_count?: { documentos: number }
}
export async function detalleContacto(
monday: MondayDetalle,
contactoId: string,
nombresPorMondayId: Map<number, string>,
capturas: CapturaLocal[],
clienteLocal: ClienteLocal | null,
): Promise<DetalleContacto | null> {
const contacto = await monday.getContactoDetalle(contactoId)
if (contacto === null) return null
const nombre = (id: number | null): string | null =>
id !== null ? (nombresPorMondayId.get(id) ?? null) : null
const tel = claveTelefono(contacto['Teléfono'] ?? '')
const email = (contacto['Email'] ?? '').trim().toLowerCase()
const coincide = (t: string, e: string): boolean =>
(tel !== '' && claveTelefono(t) === tel) || (email !== '' && e.trim().toLowerCase() === email)
const [leads, oportunidades, apartados] = await Promise.all([
monday.getLeads(),
monday.getOportunidades(),
monday.getApartados(),
])
const relLeads = leads.filter((l) => coincide(l.telefono, l.email))
const relOportunidades = oportunidades.filter((o) => coincide(o.telefono, o.email))
const relApartados = apartados.filter((a) => coincide(a.telefono ?? '', a.email ?? ''))
// Propiedad: el apartado relacionado "vivo" (no final) o el último del board;
// se enriquece con el desarrollo de la unidad (inventario)
let propiedad: DetalleContacto['propiedad'] = null
const aptoReciente = relApartados.find((a) => !esEstadoFinalApartado(a.estado)) ?? relApartados[relApartados.length - 1]
if (aptoReciente) {
let desarrollo = ''
if (aptoReciente.unidadCodigo !== '') {
try {
desarrollo = (await monday.getUnidad(aptoReciente.unidadCodigo))?.desarrollo ?? ''
} catch {
desarrollo = ''
}
}
propiedad = {
id: aptoReciente.id,
unidadCodigo: aptoReciente.unidadCodigo,
desarrollo,
estado: aptoReciente.estado,
precioFinal: aptoReciente.precioFinal ?? 0,
fechaCompra: aptoReciente.fechaCompra ?? '',
fechaLimite: aptoReciente.fechaLimite,
atendidoPor: nombre(aptoReciente.vendedorIds[0] ?? null),
}
}
// Historial: activity log de los items relacionados + del propio contacto,
// más los registros locales de captura/alta (Monday no guarda quién capturó)
const [evContactos, evLeads, evOportunidades, evApartado] = await Promise.all([
monday.getHistorialItems(config.monday.boards.contactos, [contactoId]),
monday.getHistorialItems(config.monday.boards.leads, relLeads.map((l) => l.id)),
monday.getHistorialItems(config.monday.boards.oportunidades, relOportunidades.map((o) => o.id)),
monday.getHistorialItems(config.monday.boards.apartado, relApartados.map((a) => a.id)),
])
const historial: EventoHistorialContacto[] = [
...evContactos.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Contactos' })),
...evLeads.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Leads' })),
...evOportunidades.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Oportunidades' })),
...evApartado.map((e) => ({ fechaISO: e.fechaISO, usuario: nombre(e.usuarioId), detalle: detalleEventoUltimoCambio(e.evento), fuente: 'Apartado' })),
...capturas.map((c) => ({
fechaISO: c.createdAt.toISOString(),
usuario: c.usuario?.nombre ?? null,
detalle: `Captura en la app (${c.nombreLead})`,
fuente: 'App',
})),
...(clienteLocal
? [{
fechaISO: clienteLocal.createdAt.toISOString(),
usuario: clienteLocal.capturadoPor?.nombre ?? null,
detalle: `Alta de cliente registrada${clienteLocal.unidadCodigo ? ` (${clienteLocal.unidadCodigo})` : ''}`,
fuente: 'App',
}]
: []),
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
return {
contacto,
propiedad,
relaciones: {
leads: relLeads.map((l) => ({
id: l.id,
nombre: l.nombre,
estado: l.estado,
atendidoPor: nombre(l.vendedorIds[0] ?? null),
detalle: l.origen,
})),
oportunidades: relOportunidades.map((o) => ({
id: o.id,
nombre: o.nombre,
estado: o.etapa,
atendidoPor: nombre(o.asesorIds[0] ?? null),
detalle: [o.desarrollo, o.modeloInteres].filter((p) => p).join(' · '),
})),
apartados: relApartados.map((a) => ({
id: a.id,
nombre: a.nombre,
estado: a.estado,
atendidoPor: nombre(a.vendedorIds[0] ?? null),
detalle: a.unidadCodigo,
})),
},
historial,
}
}