import type { MondayService } from './monday.service.js' import { config } from '../config/index.js' /** * Agregador "Mi día": lo que un vendedor necesita al abrir la app /hoy. * Todo se filtra por su mondayUserId (columna people de cada tablero); si el * usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales * van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando). * "Mis leads" mezcla AMBOS tableros del embudo: Leads (captura ligera, columna * Vendedor) y Oportunidades (Alta Afluencia, columna Asesor — la afluencia ES * la oportunidad en etapa 'Afluencia'). "Mis capturas" viene del registro * local (tabla LeadCaptura), no de Monday: es la lista de items que el usuario * dio de alta, con su estado actual (cruza contra ambos tableros por id). */ // Estados del board Leads en los que el lead ya no es trabajo activo del vendedor export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO'] // Etapas del board Oportunidades en las que la oportunidad ya no es trabajo activo del asesor export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO'] // Estados del board Apartado que cierran el pipeline Enkontrol export const ESTADOS_FINALES_APARTADO = [ 'ESCRITURADO', 'CANCELACION', 'CANCELACION APARTADO', 'CANCELADO', 'LIBERADO', 'FORMALIZADO', 'FIRMA CLIENTE (VENTA)', // la venta se concreta: pasa a Escrituración ] const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin'] export interface UsuarioMiDia { nombre: string rol: string mondayUserId: number | null } export interface LeadMiDia { id: string nombre: string telefono: string email: string estado: string origen: string updatedAt: string /** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */ fuente: 'lead' | 'oportunidad' /** Último evento del activity log del board; null si el item no tiene eventos */ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null } export interface ActividadMiDia { id: string nombre: string tipo: string fecha: string estado: string } export interface ApartadoPorVencer { id: string nombre: string unidadCodigo: string fechaLimite: string estado: string diasRestantes: number } export interface CapturaMiDia { leadId: string nombre: string telefono: string estado: string // estado ACTUAL en Monday; '' si el lead ya no existe en el board origen: string capturadoEn: string // ISO } export interface ClienteMiDia { id: string nombre: string proyecto: string // '' = alta aún en captura unidadCodigo: string completado: boolean // true cuando el alta ya tiene proyecto (pasó a CLIENTE) capturadoEn: string // ISO /** Estado actual del item en el tablero Apartado (bidireccional Monday→app); null si no hay apartado ligado o ya no existe */ estadoApartado: string | null } /** Oportunidad asignada al asesor (tab "Mis oportunidades" de Mi día) */ export interface OportunidadMiDia { id: string nombre: string telefono: string email: string etapa: string // label de la columna status 'Estado' desarrollo: string modeloInteres: string unidadCodigo: string updatedAt: string /** Último evento del activity log del board; null si el item no tiene eventos */ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null } /** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */ export interface ApartadoMiDia { id: string nombre: string unidadCodigo: string estado: string fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene) /** Último evento del activity log del board; null si el item no tiene eventos */ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null } export interface ResumenMiDia { generadoEn: string sinMapeoMonday: boolean misLeads: LeadMiDia[] misCapturas: CapturaMiDia[] misClientes: ClienteMiDia[] misActividadesHoy: ActividadMiDia[] apartadosPorVencer: ApartadoPorVencer[] /** MIS oportunidades: conteo por etapa + lista de trabajo (etapas no terminales) */ misOportunidades: { porEtapa: Record; items: OportunidadMiDia[] } /** MIS apartados (Vendedor = yo), en estados no finales del pipeline Enkontrol */ misApartados: ApartadoMiDia[] conteos: { leadsNuevosSemana: number // míos, últimos 7 días naturales actividadesAbiertas: number // mías, Estado Abierto } // Solo roles coordinador/corporativo/admin equipo?: { leadsNuevosSinAsignar: number porVendedor: { mondayUserId: number nombre: string | null leadsAsignados: number /** Leads del vendedor creados en los últimos 7 días naturales */ leadsNuevosSemana: number }[] } } type MondayMiDia = Pick< MondayService, 'getLeads' | 'getOportunidades' | 'getActividades' | 'getApartados' | 'getUltimosCambios' > const MS_DIA = 24 * 60 * 60 * 1000 /** Texto legible del evento del activity log (los nombres de columna no vienen legibles en el payload) */ export function detalleEventoUltimoCambio(evento: string): string { switch (evento) { case 'create_pulse': return 'Alta' case 'create_update': return 'Nota agregada' case 'update_column_value': case 'update_status': return 'Actualización' case 'move_to_group': return 'Cambio de grupo' default: return evento } } /** Fecha local 'YYYY-MM-DD' desplazada n días desde `base` */ function fechaLocal(base: Date, dias: number): string { const d = new Date(base.getTime() + dias * MS_DIA) const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, '0') const dd = String(d.getDate()).padStart(2, '0') return `${y}-${m}-${dd}` } export function esEstadoTerminalLead(estado: string): boolean { return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase()) } export function esEtapaTerminal(etapa: string): boolean { return ETAPAS_TERMINALES_OPORTUNIDAD.includes(etapa.trim().toUpperCase()) } export function esEstadoFinalApartado(estado: string): boolean { return ESTADOS_FINALES_APARTADO.includes(estado.trim().toUpperCase()) } export async function resumenMiDia( monday: MondayMiDia, usuario: UsuarioMiDia, /** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */ nombresPorMondayId: Map, /** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */ capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [], /** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */ clientes: { id: string nombreCompleto: string proyecto: string unidadCodigo: string | null mondayApartadoId: string | null createdAt: Date }[] = [], ahora: Date = new Date(), ): Promise { const miId = usuario.mondayUserId const sinMapeo = miId === null const [leads, oportunidades, actividades, apartados] = await Promise.all([ monday.getLeads(), monday.getOportunidades(), monday.getActividades(), monday.getApartados(), ]) const hoy = fechaLocal(ahora, 0) const manana = fechaLocal(ahora, 1) const en7Dias = fechaLocal(ahora, 7) const hace7DiasMs = ahora.getTime() - 7 * MS_DIA // --- Listas personales (vacías si no hay mapeo Monday) --- // "Mis leads" mezcla ambos tableros: Leads (Vendedor = yo, estado no // terminal) + Oportunidades (Asesor = yo, etapa no terminal), más reciente // primero y con su fuente para las acciones del CRM. const misLeadsLeads: LeadMiDia[] = sinMapeo ? [] : leads .filter((l) => l.vendedorIds.includes(miId) && !esEstadoTerminalLead(l.estado)) .map((l) => ({ id: l.id, nombre: l.nombre, telefono: l.telefono, email: l.email, estado: l.estado, origen: l.origen, updatedAt: l.updatedAt, fuente: 'lead', ultimoCambio: null, })) const mios = sinMapeo ? [] : oportunidades.filter((o) => o.asesorIds.includes(miId)) const misLeadsOportunidades: LeadMiDia[] = mios .filter((o) => !esEtapaTerminal(o.etapa)) .map((o) => ({ id: o.id, nombre: o.nombre, telefono: o.telefono, email: o.email, estado: o.etapa, origen: o.origen, updatedAt: o.updatedAt, fuente: 'oportunidad', ultimoCambio: null, })) const misLeads: LeadMiDia[] = [...misLeadsLeads, ...misLeadsOportunidades].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt), ) // "Último cambio" por item: UNA llamada al activity log por tablero (máx 3), // agrupando los ids por fuente. El usuario se resuelve con el mapa // mondayUserId → nombre que pasa el controller (ecosistema + workspace). const itemsOportunidades: OportunidadMiDia[] = mios .filter((o) => !esEtapaTerminal(o.etapa)) .map((o) => ({ id: o.id, nombre: o.nombre, telefono: o.telefono, email: o.email, etapa: o.etapa, desarrollo: o.desarrollo ?? '', modeloInteres: o.modeloInteres ?? '', unidadCodigo: o.unidadCodigo, updatedAt: o.updatedAt, ultimoCambio: null, })) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) const miosApartados = sinMapeo ? [] : apartados.filter((a) => a.vendedorIds.includes(miId) && !esEstadoFinalApartado(a.estado)) const [cambiosLeads, cambiosOportunidades, cambiosApartados] = await Promise.all([ monday.getUltimosCambios( config.monday.boards.leads, misLeads.filter((l) => l.fuente === 'lead').map((l) => l.id), ), monday.getUltimosCambios( config.monday.boards.oportunidades, itemsOportunidades.map((o) => o.id), ), monday.getUltimosCambios( config.monday.boards.apartado, miosApartados.map((a) => a.id), ), ]) const cambiosPorFuente = { lead: cambiosLeads, oportunidad: cambiosOportunidades } for (const lead of misLeads) { const cambio = cambiosPorFuente[lead.fuente].get(lead.id) lead.ultimoCambio = cambio ? { fechaISO: cambio.fechaISO, usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null, detalle: detalleEventoUltimoCambio(cambio.evento), } : null } for (const op of itemsOportunidades) { const cambio = cambiosOportunidades.get(op.id) op.ultimoCambio = cambio ? { fechaISO: cambio.fechaISO, usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null, detalle: detalleEventoUltimoCambio(cambio.evento), } : null } // "Mis apartados": Vendedor = yo, estado no final del pipeline Enkontrol, // por fecha límite ascendente (sin fecha al final) const misApartados: ApartadoMiDia[] = miosApartados .map((a) => { const cambio = cambiosApartados.get(a.id) return { id: a.id, nombre: a.nombre, unidadCodigo: a.unidadCodigo, estado: a.estado, fechaLimite: a.fechaLimite, ultimoCambio: cambio ? { fechaISO: cambio.fechaISO, usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null, detalle: detalleEventoUltimoCambio(cambio.evento), } : null, } }) .sort((a, b) => (a.fechaLimite || '9999-12-31').localeCompare(b.fechaLimite || '9999-12-31')) // Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen // del pipeline personal, no la lista de trabajo activo) const porEtapa: Record = {} for (const o of mios) { const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim() porEtapa[etapa] = (porEtapa[etapa] ?? 0) + 1 } // "Mis capturas" se enriquece con el estado/origen ACTUAL en Monday, // cruzando contra AMBOS tableros por id (las capturas rápidas apuntan a // Leads; las de Alta Afluencia, a Oportunidades), más reciente primero const leadsPorId = new Map(leads.map((l) => [l.id, l])) const oportunidadesPorId = new Map(oportunidades.map((o) => [o.id, o])) const misCapturas: CapturaMiDia[] = capturas .map((c) => { const lead = leadsPorId.get(c.leadId) const op = oportunidadesPorId.get(c.leadId) return { leadId: c.leadId, nombre: c.nombreLead, telefono: c.telefono ?? '', estado: lead?.estado ?? op?.etapa ?? '', origen: lead?.origen ?? op?.origen ?? '', capturadoEn: c.createdAt.toISOString(), } }) .sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn)) // "Mis clientes" es local + cruce con Monday: el alta en progreso lleva // proyecto '', el alta completada lleva el proyecto lleno. estadoApartado // cruza Cliente.mondayApartadoId contra el tablero Apartado (null si no hay // apartado ligado o ya no existe en Monday) const apartadosPorId = new Map(apartados.map((a) => [a.id, a])) const misClientes: ClienteMiDia[] = clientes .map((c) => ({ id: c.id, nombre: c.nombreCompleto, proyecto: c.proyecto, unidadCodigo: c.unidadCodigo ?? '', completado: c.proyecto.trim() !== '', capturadoEn: c.createdAt.toISOString(), estadoApartado: c.mondayApartadoId != null ? (apartadosPorId.get(c.mondayApartadoId)?.estado ?? null) : null, })) .sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn)) const misActividades = sinMapeo ? [] : actividades.filter((a) => a.responsableIds.includes(miId)) const misActividadesHoy: ActividadMiDia[] = misActividades .filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO' && a.fecha !== '' && a.fecha <= manana) .sort((a, b) => a.fecha.localeCompare(b.fecha)) .map((a) => ({ id: a.id, nombre: a.nombre, tipo: a.tipo, fecha: a.fecha, estado: a.estado })) // Apartados por vencer es del pipeline completo (Enkontrol no lleva asesor people) const apartadosPorVencer: ApartadoPorVencer[] = apartados .filter( (a) => a.fechaLimite !== '' && a.fechaLimite >= hoy && a.fechaLimite <= en7Dias && !esEstadoFinalApartado(a.estado), ) .sort((a, b) => a.fechaLimite.localeCompare(b.fechaLimite)) .map((a) => ({ id: a.id, nombre: a.nombre, unidadCodigo: a.unidadCodigo, fechaLimite: a.fechaLimite, estado: a.estado, diasRestantes: Math.round((Date.parse(`${a.fechaLimite}T00:00:00`) - Date.parse(`${hoy}T00:00:00`)) / MS_DIA), })) // Conteo de la semana: míos en AMBOS tableros (leads + oportunidades) const miosLeadRows = sinMapeo ? [] : leads.filter((l) => l.vendedorIds.includes(miId)) const conteos = { leadsNuevosSemana: miosLeadRows.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length + mios.filter((o) => Date.parse(o.createdAt) >= hace7DiasMs).length, actividadesAbiertas: misActividades.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO').length, } const resumen: ResumenMiDia = { generadoEn: ahora.toISOString(), sinMapeoMonday: sinMapeo, misLeads, misCapturas, misClientes, misActividadesHoy, apartadosPorVencer, misOportunidades: { porEtapa, items: itemsOportunidades }, misApartados, conteos, } if (ROLES_CON_EQUIPO.includes(usuario.rol)) { // Sin asignar: leads sin Vendedor (estado NUEVO) + afluencias sin Asesor // (etapa Afluencia) — lo que el round-robin aún no reparte const sinAsignar = leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado) && l.estado.trim().toUpperCase() === 'NUEVO') .length + oportunidades.filter((o) => o.asesorIds.length === 0 && o.etapa.trim().toUpperCase() === 'AFLUENCIA').length const conteoPorId = new Map() const nuevosPorId = new Map() // Leads y oportunidades comparten el conteo por vendedor (misma persona Monday) for (const l of leads) { if (esEstadoTerminalLead(l.estado)) continue const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs for (const id of l.vendedorIds) { conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1) if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1) } } for (const o of oportunidades) { if (esEtapaTerminal(o.etapa)) continue const esNuevoSemana = Date.parse(o.createdAt) >= hace7DiasMs for (const id of o.asesorIds) { conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1) if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1) } } resumen.equipo = { leadsNuevosSinAsignar: sinAsignar, porVendedor: [...conteoPorId.entries()] .map(([mondayUserId, leadsAsignados]) => ({ mondayUserId, nombre: nombresPorMondayId.get(mondayUserId) ?? null, leadsAsignados, leadsNuevosSemana: nuevosPorId.get(mondayUserId) ?? 0, })) .sort((a, b) => b.leadsAsignados - a.leadsAsignados), } } return resumen }