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

@@ -80,6 +80,10 @@ export interface ApartadoMonday {
fechaLimite: string // 'YYYY-MM-DD' de 'Fecha límite' ('' si no tiene)
estado: string // label de la columna status 'Estado'
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
telefono?: string // columna phone 'Teléfono' ('' si no tiene)
email?: string // columna email 'Correo electrónico' ('' si no tiene)
precioFinal?: number // columna numbers 'Precio Final' (0 si no tiene)
fechaCompra?: string // 'YYYY-MM-DD' de 'Fecha Compra' ('' si no tiene)
}
/** Contacto del tablero Contactos (módulo Contactos de la landing) */
@@ -136,6 +140,14 @@ interface ActivityLogMonday {
data: string // JSON string; pulse_id identifica el item
}
/** Evento del activity log de un item (historial completo, no solo el último) */
export interface EventoHistorialMonday {
itemId: string
fechaISO: string
usuarioId: number | null // user_id de Monday; null si no es numérico (automatizaciones)
evento: string // event crudo (create_pulse, update_column_value, ...)
}
/** Último evento del activity log de un item (para "Último cambio" de Mi día) */
export interface UltimoCambioMonday {
fechaISO: string
@@ -1046,6 +1058,59 @@ export class MondayService {
return resultado
}
/**
* TODOS los eventos del activity log de un conjunto de items (historial del
* módulo Contactos), más reciente primero. Misma normalización de created_at
* que getUltimosCambios.
*/
async getHistorialItems(boardId: string, itemIds: string[]): Promise<EventoHistorialMonday[]> {
if (itemIds.length === 0) return []
const data = await this.graphql<{ boards: { activity_logs: ActivityLogMonday[] }[] }>(
`query ($ids: [ID!], $itemIds: [ID!], $limit: Int!) {
boards(ids: $ids) {
activity_logs(item_ids: $itemIds, limit: $limit) { id event created_at user_id data }
}
}`,
{ ids: [boardId], itemIds, limit: Math.min(itemIds.length * 10, 200) },
)
const eventos: EventoHistorialMonday[] = []
for (const log of data.boards[0]?.activity_logs ?? []) {
let itemId: string | undefined
try {
const datos = JSON.parse(log.data) as { pulse_id?: number | string }
itemId = datos.pulse_id === undefined ? undefined : String(datos.pulse_id)
} catch {
continue
}
if (itemId === undefined) continue
const crudo = Number(log.created_at)
if (!Number.isFinite(crudo)) continue
const ms = crudo > 1e16 ? crudo / 1e4 : crudo > 1e14 ? crudo / 1e3 : crudo
const userId = Number(log.user_id)
eventos.push({
itemId,
fechaISO: new Date(ms).toISOString(),
usuarioId: Number.isFinite(userId) && userId > 0 ? userId : null,
evento: log.event,
})
}
return eventos.sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
}
/** Item del tablero Contactos con TODAS sus columnas por título ('' si vacía) */
async getContactoDetalle(itemId: string): Promise<Record<string, string> | null> {
const boardId = config.monday.boards.contactos
const [cols, item] = await Promise.all([this.columnasDe(boardId), this.getItemConGrupo(itemId)])
if (!item) return null
const porTitulo: Record<string, string> = { nombre: item.name }
for (const [titulo, col] of cols) {
const cv = item.column_values.find((c) => c.id === col.id)
// status nunca escrito: value null pero text muestra el default del tablero
porTitulo[titulo] = cv?.value ? (cv.text ?? '') : ''
}
return porTitulo
}
/** Parsea el value JSON de una columna people a los user IDs (solo personas) */
private peopleIds(raw: string | null | undefined): number[] {
if (!raw) return []
@@ -1121,6 +1186,10 @@ export class MondayService {
const colFechaLimite = cols.get('Fecha límite')
const colEstado = cols.get('Estado')
const colVendedor = cols.get('Vendedor')
const colTelefono = cols.get('Teléfono')
const colEmail = cols.get('Correo electrónico')
const colPrecioFinal = cols.get('Precio Final')
const colFechaCompra = cols.get('Fecha Compra')
const items: ItemMonday[] = []
let cursor: string | null = null
@@ -1168,6 +1237,10 @@ export class MondayService {
fechaLimite: colFechaLimite ? (porId.get(colFechaLimite.id)?.text ?? '') : '',
estado,
vendedorIds: colVendedor ? this.peopleIds(porId.get(colVendedor.id)?.value) : [],
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
precioFinal: colPrecioFinal ? Number(porId.get(colPrecioFinal.id)?.text ?? 0) || 0 : 0,
fechaCompra: colFechaCompra ? (porId.get(colFechaCompra.id)?.text ?? '') : '',
}
})
}