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:
@@ -1,5 +1,8 @@
|
|||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
import { mondayService } from '../services/monday.service.js'
|
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
|
* 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<void> {
|
export async function listarContactosEndpoint(_req: Request, res: Response): Promise<void> {
|
||||||
res.json(await mondayService.getContactos())
|
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<void> {
|
||||||
|
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<number, string>(
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ import {
|
|||||||
listarUsuariosMondayEndpoint,
|
listarUsuariosMondayEndpoint,
|
||||||
} from '../controllers/usuarios.controller.js'
|
} from '../controllers/usuarios.controller.js'
|
||||||
import { resumenMiDiaEndpoint } from '../controllers/mi-dia.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 { webhookMonday } from '../controllers/webhooks.controller.js'
|
||||||
import {
|
import {
|
||||||
correrRoundRobin,
|
correrRoundRobin,
|
||||||
@@ -150,6 +150,7 @@ apiRouter.post('/apartados/:itemId/documentos', uploadDocumentosApartado.array('
|
|||||||
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
|
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
|
||||||
|
|
||||||
apiRouter.get('/contactos', listarContactosEndpoint)
|
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
|
// GET estático: va ANTES de /leads/:itemId para no colisionar con el parámetro
|
||||||
apiRouter.get('/leads/buscar', buscarLeadsEndpoint)
|
apiRouter.get('/leads/buscar', buscarLeadsEndpoint)
|
||||||
|
|||||||
121
api-gateway/src/services/contactos-detalle.service.test.ts
Normal file
121
api-gateway/src/services/contactos-detalle.service.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
197
api-gateway/src/services/contactos-detalle.service.ts
Normal file
197
api-gateway/src/services/contactos-detalle.service.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,10 @@ export interface ApartadoMonday {
|
|||||||
fechaLimite: string // 'YYYY-MM-DD' de 'Fecha límite' ('' si no tiene)
|
fechaLimite: string // 'YYYY-MM-DD' de 'Fecha límite' ('' si no tiene)
|
||||||
estado: string // label de la columna status 'Estado'
|
estado: string // label de la columna status 'Estado'
|
||||||
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
|
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) */
|
/** 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
|
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) */
|
/** Último evento del activity log de un item (para "Último cambio" de Mi día) */
|
||||||
export interface UltimoCambioMonday {
|
export interface UltimoCambioMonday {
|
||||||
fechaISO: string
|
fechaISO: string
|
||||||
@@ -1046,6 +1058,59 @@ export class MondayService {
|
|||||||
return resultado
|
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) */
|
/** Parsea el value JSON de una columna people a los user IDs (solo personas) */
|
||||||
private peopleIds(raw: string | null | undefined): number[] {
|
private peopleIds(raw: string | null | undefined): number[] {
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
@@ -1121,6 +1186,10 @@ export class MondayService {
|
|||||||
const colFechaLimite = cols.get('Fecha límite')
|
const colFechaLimite = cols.get('Fecha límite')
|
||||||
const colEstado = cols.get('Estado')
|
const colEstado = cols.get('Estado')
|
||||||
const colVendedor = cols.get('Vendedor')
|
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[] = []
|
const items: ItemMonday[] = []
|
||||||
let cursor: string | null = null
|
let cursor: string | null = null
|
||||||
@@ -1168,6 +1237,10 @@ export class MondayService {
|
|||||||
fechaLimite: colFechaLimite ? (porId.get(colFechaLimite.id)?.text ?? '') : '',
|
fechaLimite: colFechaLimite ? (porId.get(colFechaLimite.id)?.text ?? '') : '',
|
||||||
estado,
|
estado,
|
||||||
vendedorIds: colVendedor ? this.peopleIds(porId.get(colVendedor.id)?.value) : [],
|
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 ?? '') : '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
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 { useCallback, useEffect, useState } from 'react'
|
||||||
import { getContactos, type Contacto } from '../services/api'
|
import { getContactos, type Contacto } from '../services/api'
|
||||||
|
import { ModalContacto } from './ModalContacto'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onVolver: () => void
|
onVolver: () => void
|
||||||
@@ -8,12 +9,14 @@ interface Props {
|
|||||||
/**
|
/**
|
||||||
* Vista interna "Contactos": lista del tablero Contactos de Monday (lo más
|
* 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
|
* 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) {
|
export function VistaContactos({ onVolver }: Props) {
|
||||||
const [contactos, setContactos] = useState<Contacto[] | null>(null)
|
const [contactos, setContactos] = useState<Contacto[] | null>(null)
|
||||||
const [cargando, setCargando] = useState(true)
|
const [cargando, setCargando] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [seleccionado, setSeleccionado] = useState<Contacto | null>(null)
|
||||||
|
|
||||||
const cargar = useCallback(() => {
|
const cargar = useCallback(() => {
|
||||||
setCargando(true)
|
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">
|
<ul className="divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||||
{contactos.map((contacto) => (
|
{contactos.map((contacto) => (
|
||||||
<li key={contacto.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-6 py-4">
|
<li key={contacto.id}>
|
||||||
<span className="text-sm font-semibold text-[#111827]">{contacto.nombre}</span>
|
<button
|
||||||
{contacto.estado && (
|
type="button"
|
||||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">
|
onClick={() => setSeleccionado(contacto)}
|
||||||
{contacto.estado}
|
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>
|
>
|
||||||
)}
|
<span className="text-sm font-semibold text-[#111827]">{contacto.nombre}</span>
|
||||||
<span className="text-sm text-[#64748B]">{contacto.telefono || 'Sin teléfono'}</span>
|
{contacto.estado && (
|
||||||
<span className="text-sm text-[#64748B]">{contacto.email || 'Sin email'}</span>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{seleccionado && (
|
||||||
|
<ModalContacto
|
||||||
|
contactoId={seleccionado.id}
|
||||||
|
nombre={seleccionado.nombre}
|
||||||
|
onCerrar={() => setSeleccionado(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,3 +53,50 @@ export async function guardarVinculoMonday(mondayUserId: number | null): Promise
|
|||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
return (await resp.json()) as UsuarioSesion
|
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