From a4575bb94842ada4c2174e68b1753aab9afa6314 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 4 Sep 2026 23:55:36 +0000 Subject: [PATCH] feat(contactos): detalle completo del cliente con propiedad e historial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/controllers/contactos.controller.ts | 84 ++++++++ api-gateway/src/routes/index.ts | 3 +- .../contactos-detalle.service.test.ts | 121 +++++++++++ .../src/services/contactos-detalle.service.ts | 197 ++++++++++++++++++ api-gateway/src/services/monday.service.ts | 73 +++++++ apps/landing/src/components/ModalContacto.tsx | 192 +++++++++++++++++ .../landing/src/components/VistaContactos.tsx | 38 +++- apps/landing/src/services/api.ts | 47 +++++ 8 files changed, 744 insertions(+), 11 deletions(-) create mode 100644 api-gateway/src/services/contactos-detalle.service.test.ts create mode 100644 api-gateway/src/services/contactos-detalle.service.ts create mode 100644 apps/landing/src/components/ModalContacto.tsx diff --git a/api-gateway/src/controllers/contactos.controller.ts b/api-gateway/src/controllers/contactos.controller.ts index ea9589a..7b8faa8 100644 --- a/api-gateway/src/controllers/contactos.controller.ts +++ b/api-gateway/src/controllers/contactos.controller.ts @@ -1,5 +1,8 @@ import type { Request, Response } from 'express' import { mondayService } from '../services/monday.service.js' +import { detalleContacto, claveTelefono } from '../services/contactos-detalle.service.js' +import { prisma } from '../services/prisma.js' +import { NotFoundError } from '../utils/errors.js' /** * GET /api/contactos (Bearer) → contactos del tablero Contactos de Monday @@ -8,3 +11,84 @@ import { mondayService } from '../services/monday.service.js' export async function listarContactosEndpoint(_req: Request, res: Response): Promise { res.json(await mondayService.getContactos()) } + +/** + * GET /api/contactos/:id/detalle (Bearer) → ficha completa del contacto: + * todas sus columnas, la propiedad comprada/en proceso (apartado relacionado), + * quién atiende cada item relacionado (leads, oportunidades, apartados) y el + * historial completo de atención (activity log de Monday + capturas locales). + */ +export async function detalleContactoEndpoint(req: Request, res: Response): Promise { + const contactoId = String(req.params['id']) + + const contacto = await mondayService.getContactoDetalle(contactoId) + if (contacto === null) throw new NotFoundError('Contacto no encontrado') + + // Nombres para "atendidoPor"/historial: usuarios del ecosistema + workspace + const mapeados = await prisma.usuario.findMany({ + where: { mondayUserId: { not: null }, activo: true }, + select: { nombre: true, mondayUserId: true }, + }) + const nombresPorMondayId = new Map( + mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]), + ) + try { + for (const u of await mondayService.getUsuariosWorkspace()) { + if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre) + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('No se pudieron listar los usuarios del workspace de Monday:', err) + } + + // Registro local: capturas con el mismo teléfono (Monday no guarda quién + // capturó) y la ficha de cliente ligada a este contacto (o por celular). + // LeadCaptura/Cliente no tienen relación Prisma a Usuario: se resuelve a mano. + const clave = claveTelefono(contacto['Teléfono'] ?? '') + const capturasTodas = await prisma.leadCaptura.findMany({ + where: { telefono: { not: null } }, + orderBy: { createdAt: 'desc' }, + }) + const capturasCrudas = capturasTodas.filter((c) => c.telefono !== null && clave !== '' && claveTelefono(c.telefono) === clave) + + const clienteCrudo = await prisma.cliente.findFirst({ + where: { + OR: [ + { mondayContactoId: contactoId }, + ...(clave !== '' ? [{ celular: { contains: clave } }] : []), + ], + }, + include: { _count: { select: { documentos: true } } }, + orderBy: { createdAt: 'desc' }, + }) + + // Nombres de capturistas (ids locales → nombre para mostrar) + const idsUsuarios = [ + ...new Set([ + ...capturasCrudas.map((c) => c.usuarioId), + ...(clienteCrudo?.capturadoPorId ? [clienteCrudo.capturadoPorId] : []), + ]), + ] + const usuariosLocal = idsUsuarios.length + ? await prisma.usuario.findMany({ where: { id: { in: idsUsuarios } }, select: { id: true, nombre: true } }) + : [] + const nombreLocal = new Map(usuariosLocal.map((u) => [u.id, u.nombre])) + + const capturas = capturasCrudas.map((c) => ({ + leadId: c.leadId, + nombreLead: c.nombreLead, + createdAt: c.createdAt, + usuario: nombreLocal.has(c.usuarioId) ? { nombre: nombreLocal.get(c.usuarioId)! } : null, + })) + const clienteLocal = clienteCrudo + ? { + ...clienteCrudo, + capturadoPor: + clienteCrudo.capturadoPorId && nombreLocal.has(clienteCrudo.capturadoPorId) + ? { nombre: nombreLocal.get(clienteCrudo.capturadoPorId)! } + : null, + } + : null + + res.json(await detalleContacto(mondayService, contactoId, nombresPorMondayId, capturas, clienteLocal)) +} diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index f124776..f17a343 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -62,7 +62,7 @@ import { listarUsuariosMondayEndpoint, } from '../controllers/usuarios.controller.js' import { resumenMiDiaEndpoint } from '../controllers/mi-dia.controller.js' -import { listarContactosEndpoint } from '../controllers/contactos.controller.js' +import { listarContactosEndpoint, detalleContactoEndpoint } from '../controllers/contactos.controller.js' import { webhookMonday } from '../controllers/webhooks.controller.js' import { correrRoundRobin, @@ -150,6 +150,7 @@ apiRouter.post('/apartados/:itemId/documentos', uploadDocumentosApartado.array(' apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint) apiRouter.get('/contactos', listarContactosEndpoint) +apiRouter.get('/contactos/:id/detalle', detalleContactoEndpoint) // GET estático: va ANTES de /leads/:itemId para no colisionar con el parámetro apiRouter.get('/leads/buscar', buscarLeadsEndpoint) diff --git a/api-gateway/src/services/contactos-detalle.service.test.ts b/api-gateway/src/services/contactos-detalle.service.test.ts new file mode 100644 index 0000000..6d915cd --- /dev/null +++ b/api-gateway/src/services/contactos-detalle.service.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { claveTelefono, detalleContacto } from './contactos-detalle.service.js' +import type { LeadMonday, OportunidadMonday, ApartadoMonday } from './monday.service.js' + +const MI_ID = 106736727 + +const leadBase: LeadMonday = { + id: 'l1', nombre: 'Ana Demo', createdAt: '', updatedAt: '', estado: 'Nuevo', origen: 'PASE', + telefono: '+52 6641234567', email: 'ana@correo.com', vendedorIds: [MI_ID], +} + +const opBase: OportunidadMonday = { + id: 'o1', nombre: 'Ana Demo', createdAt: '', updatedAt: '', etapa: 'Cotizador', unidadCodigo: '', + precioFinal: 1500000, asesorIds: [MI_ID], telefono: '6641234567', email: '', origen: 'PASE', + desarrollo: 'SENDA', modeloInteres: 'SOHO', +} + +const apBase: ApartadoMonday = { + id: 'a1', nombre: 'Ana Demo', unidadCodigo: 'SENDA-409-1-2', fechaLimite: '2026-09-10', + estado: 'Pago Apartado', vendedorIds: [MI_ID], telefono: '6641234567', email: '', + precioFinal: 2646000, fechaCompra: '2026-09-01', +} + +function mondayMock(overrides: Record = {}) { + return { + async getContactoDetalle() { + return { nombre: 'Ana Demo', Teléfono: '+52 6641234567', Email: 'ana@correo.com', Estado: 'CLIENTE', CURP: 'XAXX010101XXXXXXX0' } + }, + async getLeads() { return [leadBase] }, + async getOportunidades() { return [opBase] }, + async getApartados() { return [apBase] }, + async getHistorialItems(_b: string, ids: string[]) { + return ids.map((id, i) => ({ + itemId: id, + fechaISO: `2026-09-0${(i % 9) + 1}T10:00:00.000Z`, + usuarioId: MI_ID, + evento: 'update_column_value', + })) + }, + async getUnidad() { return { desarrollo: 'SENDA' } }, + ...overrides, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any +} + +const nombres = new Map([[MI_ID, 'Martin Zavala']]) + +describe('claveTelefono', () => { + it('últimos 10 dígitos, tolerante a lada, espacios y símbolos', () => { + expect(claveTelefono('+52 664 123 4567')).toBe('6641234567') + expect(claveTelefono('+16195550134')).toBe('6195550134') + expect(claveTelefono('6641234567')).toBe('6641234567') + expect(claveTelefono('')).toBe('') + expect(claveTelefono('123')).toBe('') + }) +}) + +describe('detalleContacto (GET /api/contactos/:id/detalle)', () => { + it('arma contacto + propiedad + relaciones + historial con nombres resueltos', async () => { + const d = await detalleContacto( + mondayMock(), + 'c1', + nombres, + [{ leadId: 'l1', nombreLead: 'Ana Demo', createdAt: new Date('2026-09-02T08:00:00Z'), usuario: { nombre: 'Martin Zavala' } }], + { + id: 'cl1', nombreCompleto: 'Ana Demo', proyecto: 'SENDA', unidadCodigo: 'SENDA-409-1-2', + createdAt: new Date('2026-09-03T08:00:00Z'), capturadoPor: { nombre: 'Martin Zavala' }, + }, + ) + expect(d).not.toBeNull() + expect(d!.contacto['CURP']).toBe('XAXX010101XXXXXXX0') + // Propiedad comprada (apartado relacionado, enriquecido con el desarrollo) + expect(d!.propiedad).toMatchObject({ + unidadCodigo: 'SENDA-409-1-2', + desarrollo: 'SENDA', + estado: 'Pago Apartado', + precioFinal: 2646000, + atendidoPor: 'Martin Zavala', + }) + // Relaciones por teléfono/email con quién atiende + expect(d!.relaciones.leads[0]).toMatchObject({ id: 'l1', atendidoPor: 'Martin Zavala' }) + expect(d!.relaciones.oportunidades[0]).toMatchObject({ id: 'o1', atendidoPor: 'Martin Zavala' }) + expect(d!.relaciones.apartados[0]).toMatchObject({ id: 'a1', atendidoPor: 'Martin Zavala' }) + // Historial: activity log + captura + alta local, ordenado desc + expect(d!.historial.some((h) => h.fuente === 'App' && h.detalle.includes('Captura'))).toBe(true) + expect(d!.historial.some((h) => h.detalle.includes('Alta de cliente'))).toBe(true) + expect(d!.historial.every((h) => h.usuario === 'Martin Zavala')).toBe(true) + const fechas = d!.historial.map((h) => h.fechaISO) + expect(fechas).toEqual([...fechas].sort((a, b) => b.localeCompare(a))) + }) + + it('sin relaciones ni registros locales: listas vacías y propiedad null', async () => { + const d = await detalleContacto( + mondayMock({ + async getLeads() { return [] }, + async getOportunidades() { return [] }, + async getApartados() { return [] }, + }), + 'c1', + new Map(), + [], + null, + ) + expect(d!.propiedad).toBeNull() + expect(d!.relaciones).toEqual({ leads: [], oportunidades: [], apartados: [] }) + // El historial al menos tiene los eventos del propio contacto + expect(d!.historial).toHaveLength(1) + expect(d!.historial[0]?.fuente).toBe('Contactos') + }) + + it('contacto inexistente devuelve null', async () => { + const d = await detalleContacto( + mondayMock({ async getContactoDetalle() { return null } }), + 'no-existe', + new Map(), + [], + null, + ) + expect(d).toBeNull() + }) +}) diff --git a/api-gateway/src/services/contactos-detalle.service.ts b/api-gateway/src/services/contactos-detalle.service.ts new file mode 100644 index 0000000..d0c2e3c --- /dev/null +++ b/api-gateway/src/services/contactos-detalle.service.ts @@ -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 // 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, + capturas: CapturaLocal[], + clienteLocal: ClienteLocal | null, +): Promise { + 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, + } +} diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index 7634b5f..f5a5727 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -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 { + 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 | 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 = { 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 ?? '') : '', } }) } diff --git a/apps/landing/src/components/ModalContacto.tsx b/apps/landing/src/components/ModalContacto.tsx new file mode 100644 index 0000000..cc07f7b --- /dev/null +++ b/apps/landing/src/components/ModalContacto.tsx @@ -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 ( +
+

{etiqueta}

+

{valor}

+
+ ) +} + +function ListaRelaciones({ titulo, items }: { titulo: string; items: RelacionCrm[] }) { + if (items.length === 0) return null + return ( +
+

{titulo}

+
    + {items.map((r) => ( +
  • + + {r.estado || 'Sin estado'} + {r.detalle ? {r.detalle} : null} + + + {r.atendidoPor ?? 'Sin asignar'} + +
  • + ))} +
+
+ ) +} + +/** + * 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(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 ( +
{ + if (e.target === e.currentTarget) onCerrar() + }} + > +
+
+
+

{c['nombre'] ?? nombre}

+ {c['Estado'] && ( + {c['Estado']} + )} +
+ +
+ +
+ {error && ( +

+ {error} +

+ )} + {!detalle && !error &&

Cargando detalle…

} + + {detalle && ( + <> +
+

Datos del contacto

+
+ + + + + + + + + + + +
+
+ + {detalle.propiedad && ( +
+

Propiedad

+
+
+ + + + + + +
+
+
+ )} + +
+ + + +
+ +
+

+ Historial de atención +

+ {detalle.historial.length === 0 ? ( +

Sin movimientos registrados.

+ ) : ( +
    + {detalle.historial.map((h, i) => ( +
  • +
    +

    + {h.detalle} + + {h.fuente} + +

    +

    {h.usuario ?? 'Automatización'}

    +
    + {formatoFecha(h.fechaISO)} +
  • + ))} +
+ )} +
+ + )} +
+
+
+ ) +} diff --git a/apps/landing/src/components/VistaContactos.tsx b/apps/landing/src/components/VistaContactos.tsx index 04614c2..e15c033 100644 --- a/apps/landing/src/components/VistaContactos.tsx +++ b/apps/landing/src/components/VistaContactos.tsx @@ -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(null) const [cargando, setCargando] = useState(true) const [error, setError] = useState('') + const [seleccionado, setSeleccionado] = useState(null) const cargar = useCallback(() => { setCargando(true) @@ -60,20 +63,35 @@ export function VistaContactos({ onVolver }: Props) { ) : (
    {contactos.map((contacto) => ( -
  • - {contacto.nombre} - {contacto.estado && ( - - {contacto.estado} - - )} - {contacto.telefono || 'Sin teléfono'} - {contacto.email || 'Sin email'} +
  • +
  • ))}
)} + + {seleccionado && ( + setSeleccionado(null)} + /> + )} ) } diff --git a/apps/landing/src/services/api.ts b/apps/landing/src/services/api.ts index e3ca3b4..08698d4 100644 --- a/apps/landing/src/services/api.ts +++ b/apps/landing/src/services/api.ts @@ -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 + 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 { + 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 +}