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,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<string, unknown> = {}) {
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()
})
})