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 en Mis oportunidades. 'APARTO' incluido: al apartar, el // seguimiento continúa en Mis apartados (pipeline Enkontrol). export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO', 'APARTO'] // Estados del board Apartado que cierran el pipeline Enkontrol export const ESTADOS_FINALES_APARTADO = [ 'ESCRITURADO', 'CANCELACION', 'CANCELACION APARTADO', 'CANCELADO', 'LIBERADO', 'FORMALIZADO', ] 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 /** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */ pendienteSync?: boolean } 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 /** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */ pendienteSync?: boolean } 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 /** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */ pendienteSync?: boolean } /** 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 /** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */ pendienteSync?: boolean } 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(), /** User ID de la cuenta Monday COMPARTIDA de vendedores (config); con ella la * columna people ya no distingue personas y "lo mío" viene de la captura local */ compartidoId: number | null = null, /** ids de items Monday capturados por el usuario (leads + oportunidades * mapeadas); default: los leadId de `capturas` */ capturadosIds?: Set, ): Promise { const miId = usuario.mondayUserId const sinMapeo = miId === null // "Mío" = lo que capturé en la app (registro local) o lo que Monday me tiene // asignado (columna people). Con la cuenta compartida, people ya no distingue // personas: solo cuenta la captura local. Funciona SIN vínculo Monday. const idsCapturados = capturadosIds ?? new Set(capturas.map((c) => c.leadId)) const esCompartido = miId !== null && compartidoId !== null && compartidoId > 0 && miId === compartidoId const esMio = (peopleIds: number[], itemId: string): boolean => { if (idsCapturados.has(itemId)) return true if (esCompartido || miId === null) return false return peopleIds.includes(miId) } 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 (por captura local + asignación Monday; ver esMio) --- // "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). const misLeadsLeads: LeadMiDia[] = leads .filter((l) => esMio(l.vendedorIds, l.id) && !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 = oportunidades.filter((o) => esMio(o.asesorIds, o.id)) 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, })) // "Mis leads" solo muestra items del tablero Leads (captura ligera). // Las oportunidades van exclusivamente en "Mis oportunidades". const misLeads: LeadMiDia[] = misLeadsLeads.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)) // Apartados ligados a MIS altas de cliente (registro local): con la cuenta // compartida o sin vínculo Monday, esta liga local es la que personaliza const apartadosDeMisClientes = new Set( clientes.map((c) => c.mondayApartadoId).filter((id): id is string => id !== null), ) // Set de oportunidades mías (por LeadCaptura o por vendedorIds) const miosOportunidadesIds = new Set( capturas.filter((c) => c.oportunidadId).map((c) => c.oportunidadId), ) const miosApartados = apartados.filter( (a) => (esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id)) && !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)) // Excluir de "Mis capturas" los que ya aparecen en "Mis leads" o "Mis // oportunidades" para no duplicar visualmente cuando el lead existe en // Monday Y tambien en LeadCaptura (caso creacion con Monday activo). const idsEnOtrosTabs = new Set([ ...misLeads.map((l) => l.id), ...mios.map((o) => o.id), ]) const misCapturasSinDuplicados = misCapturas.filter((c) => !idsEnOtrosTabs.has(c.leadId)) // "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) => esMio(a.vendedorIds, a.id) && 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 = leads.filter((l) => esMio(l.vendedorIds, l.id)) 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: misCapturasSinDuplicados, 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 } // ---------- Modo local (Monday caído) y unión durante la transición ---------- /** * Fila local del embudo (tabla LeadCaptura) tal como la necesita Mi día. El * controller pasa las filas de Prisma ya filtradas por el usuario; la forma es * estructural para que los tests usen fixtures llanas. */ export interface FilaLocalMiDia { leadId: string nombreLead: string telefono: string | null email: string | null origen: string | null desarrollo: string | null modelo: string | null board: string // LEADS | OPORTUNIDADES | APARTADO etapa: string /** Fecha límite del apartado (para "Apartados por vencer") */ fechaLimite: Date | null /** Perfilamiento completo de afluencia (JSON serializado) */ datosJson: string | null /** Usuario del ecosistema que capturó el lead (LeadCaptura.usuarioId) */ usuarioId: string /** Item de Monday una vez sincronizado (null en capturas puramente locales) */ mondayItemId: string | null pendienteSync: boolean createdAt: Date updatedAt: Date } /** Cliente local tal como lo necesita el modo local (misma forma que en resumenMiDia) */ export interface ClienteLocalMiDia { id: string nombreCompleto: string proyecto: string unidadCodigo: string | null mondayApartadoId: string | null createdAt: Date } /** "Último cambio" derivado del updatedAt local; el quién es el capturista ('app' si no hay nombre) */ function ultimoCambioLocal(fila: FilaLocalMiDia, nombreUsuario: string) { return { fechaISO: fila.updatedAt.toISOString(), usuario: nombreUsuario.trim() !== '' ? nombreUsuario : 'app', detalle: 'Actualización', } } const porUpdatedAtDesc = (a: { updatedAt: string }, b: { updatedAt: string }): number => b.updatedAt.localeCompare(a.updatedAt) /** * Actividad local tal como la necesita Mi día (tabla ActividadLocal) */ export interface ActividadLocalMiDia { id: string titulo: string descripcion: string | null usuarioId: string leadCapturaId: string | null fechaVencimiento: Date | null fechaCierre: Date | null estado: string tipo: string } /** Usuario local tal como lo necesita la sección equipo (tabla Usuario) */ export interface UsuarioLocal { id: string nombre: string rol: string activo: boolean } /** * ResumenMiDia COMPLETO construido solo con datos locales (Monday caído). * Misma forma que resumenMiDia: Mis leads (board LEADS, etapa no terminal), * Mis oportunidades (board OPORTUNIDADES), Mis apartados (board APARTADO + * unidad ligada vía Cliente), Mis capturas, Mis clientes. * * Parámetros opcionales para el modo local 100%: * - `actividades`: actividades locales del usuario (tabla ActividadLocal) * - `usuarios`: todos los usuarios activos (para la sección equipo) */ export function resumenMiDiaLocal( usuario: UsuarioMiDia, capturas: FilaLocalMiDia[], clientes: ClienteLocalMiDia[], ahora: Date = new Date(), actividades: ActividadLocalMiDia[] = [], usuarios: UsuarioLocal[] = [], ): ResumenMiDia { const hace7DiasMs = ahora.getTime() - 7 * MS_DIA const leadsLocales = capturas.filter((c) => c.board === 'LEADS') const oportunidadesLocales = capturas.filter((c) => c.board === 'OPORTUNIDADES') const apartadosLocales = capturas.filter((c) => c.board === 'APARTADO') // "Mis leads" solo muestra leads (board LEADS), no oportunidades const misLeads: LeadMiDia[] = leadsLocales .filter((c) => !esEstadoTerminalLead(c.etapa)) .map((c) => ({ id: c.leadId, nombre: c.nombreLead, telefono: c.telefono ?? '', email: c.email ?? '', estado: c.etapa, origen: c.origen ?? '', updatedAt: c.updatedAt.toISOString(), fuente: 'lead' as const, ultimoCambio: ultimoCambioLocal(c, usuario.nombre), pendienteSync: c.pendienteSync, })) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) // (oportunidades se muestran exclusivamente en misOportunidades) // Conteo por etapa de TODAS las oportunidades (incluye terminales, como Monday) const porEtapa: Record = {} for (const o of oportunidadesLocales) { const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim() porEtapa[etapa] = (porEtapa[etapa] ?? 0) + 1 } const itemsOportunidades: OportunidadMiDia[] = oportunidadesLocales .filter((c) => !esEtapaTerminal(c.etapa)) .map((c) => { let unidadCodigo = '' if (c.datosJson) { try { const datos = JSON.parse(c.datosJson) as Record if (typeof datos.unidadCodigo === 'string' && datos.unidadCodigo !== '') unidadCodigo = datos.unidadCodigo else if (typeof datos.desarrollo === 'string' && datos.desarrollo !== '') unidadCodigo = datos.desarrollo } catch { /* datosJson corrupto: ignorar */ } } return { id: c.leadId, nombre: c.nombreLead, telefono: c.telefono ?? '', email: c.email ?? '', etapa: c.etapa, desarrollo: c.desarrollo ?? '', modeloInteres: c.modelo ?? '', unidadCodigo, updatedAt: c.updatedAt.toISOString(), ultimoCambio: ultimoCambioLocal(c, usuario.nombre), pendienteSync: c.pendienteSync, } }) .sort(porUpdatedAtDesc) // La unidad del apartado local se conoce vía el Cliente ligado (alta iniciada) const unidadPorApartadoId = new Map( clientes .filter((c) => c.mondayApartadoId !== null) .map((c) => [c.mondayApartadoId!, c.unidadCodigo ?? '']), ) const misApartados: ApartadoMiDia[] = apartadosLocales .filter((c) => !esEstadoFinalApartado(c.etapa)) .map((c) => ({ id: c.leadId, nombre: c.nombreLead, unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '', estado: c.etapa, fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '', ultimoCambio: ultimoCambioLocal(c, usuario.nombre), pendienteSync: c.pendienteSync, })) const misCapturas: CapturaMiDia[] = capturas .map((c) => ({ leadId: c.leadId, nombre: c.nombreLead, telefono: c.telefono ?? '', estado: c.etapa, origen: c.origen ?? '', capturadoEn: c.createdAt.toISOString(), pendienteSync: c.pendienteSync, })) .sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn)) // Excluir de "Mis capturas" los que ya aparecen en "Mis leads" u "Mis // oportunidades" para no duplicar visualmente. const idsEnOtrosTabsLocal = new Set([ ...misLeads.map((l) => l.id), ...itemsOportunidades.map((o) => o.id), ]) const misCapturasSinDuplicadosLocal = misCapturas.filter((c) => !idsEnOtrosTabsLocal.has(c.leadId)) const etapaApartadoPorId = new Map(apartadosLocales.map((a) => [a.leadId, a.etapa])) 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 ? (etapaApartadoPorId.get(c.mondayApartadoId) ?? null) : null, })) .sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn)) // --- Mis actividades hoy (el controller ya filtra por usuarioId) --- const hoy = fechaLocal(ahora, 0) const manana = fechaLocal(ahora, 1) const en7Dias = fechaLocal(ahora, 7) const misActividadesHoy: ActividadMiDia[] = actividades .filter((a) => a.estado.toUpperCase() === 'ABIERTA') .filter((a) => { if (!a.fechaVencimiento) return true const fv = a.fechaVencimiento.toISOString().slice(0, 10) return fv <= manana }) .sort((a, b) => { const fa = a.fechaVencimiento?.toISOString() ?? '9999' const fb = b.fechaVencimiento?.toISOString() ?? '9999' return fa.localeCompare(fb) }) .map((a) => ({ id: a.id, nombre: a.titulo, tipo: a.tipo, fecha: a.fechaVencimiento ? a.fechaVencimiento.toISOString().slice(0, 10) : '', estado: a.estado, })) // --- Apartados por vencer --- const apartadosPorVencer: ApartadoPorVencer[] = apartadosLocales .filter((c) => { if (esEstadoFinalApartado(c.etapa)) return false if (!c.fechaLimite) return false const fv = c.fechaLimite.toISOString().slice(0, 10) return fv >= hoy && fv <= en7Dias }) .sort((a, b) => { const fa = a.fechaLimite?.toISOString().slice(0, 10) ?? '9999-12-31' const fb = b.fechaLimite?.toISOString().slice(0, 10) ?? '9999-12-31' return fa.localeCompare(fb) }) .map((c) => { const fv = c.fechaLimite!.toISOString().slice(0, 10) return { id: c.leadId, nombre: c.nombreLead, unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '', fechaLimite: fv, estado: c.etapa, diasRestantes: Math.round( (Date.parse(`${fv}T00:00:00`) - Date.parse(`${hoy}T00:00:00`)) / MS_DIA, ), } }) // --- Conteo de actividades abiertas --- const actividadesAbiertas = actividades.filter((a) => a.estado.toUpperCase() === 'ABIERTA').length // --- Equipo (solo coordinador/admin/corporativo) --- const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin'] let equipo: NonNullable | undefined if (ROLES_CON_EQUIPO.includes(usuario.rol) && usuarios.length > 0) { const Leads = capturas.filter((c) => c.board === 'LEADS') const Oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES') const idsVendedores = new Set(usuarios.filter((u) => u.rol === 'vendedor' && u.activo).map((u) => u.id)) const sinAsignarLeads = Leads.filter( (c) => !esEstadoTerminalLead(c.etapa) && c.etapa.toUpperCase() === 'NUEVO' && !idsVendedores.has(c.usuarioId), ).length const sinAsignarOportunidades = Oportunidades.filter( (c) => c.etapa.toUpperCase() === 'AFLUENCIA' && !idsVendedores.has(c.usuarioId), ).length const porVendedor: NonNullable['porVendedor'] = usuarios .filter((u) => u.rol === 'vendedor' && u.activo) .map((u) => { const leadsAsignados = Leads.filter( (c) => c.usuarioId === u.id && !esEstadoTerminalLead(c.etapa), ).length const leadsNuevosSemana = Leads.filter( (c) => c.usuarioId === u.id && c.createdAt.getTime() >= hace7DiasMs, ).length return { mondayUserId: 0, nombre: u.nombre, leadsAsignados, leadsNuevosSemana, } }) .sort((a, b) => b.leadsAsignados - a.leadsAsignados) equipo = { leadsNuevosSinAsignar: sinAsignarLeads + sinAsignarOportunidades, porVendedor, } } return { generadoEn: ahora.toISOString(), sinMapeoMonday: usuario.mondayUserId === null, misLeads, misCapturas: misCapturasSinDuplicadosLocal, misClientes, misActividadesHoy, apartadosPorVencer, misOportunidades: { porEtapa, items: itemsOportunidades }, misApartados, conteos: { leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter( (c) => c.createdAt.getTime() >= hace7DiasMs, ).length, actividadesAbiertas, }, ...(equipo !== undefined ? { equipo } : {}), } } /** * Unión durante la transición (Monday vivo): agrega a las listas del resumen * los items locales con pendienteSync=true que Monday aún no conoce (ids * loc_*), con el flag pendienteSync para el badge ⏳ del front. Si la fila * local ya tiene mondayItemId (cambio encolado sobre un item de Monday), no se * duplica: solo se marca el item existente. También marca/rellena Mis capturas * (estado local cuando Monday aún no lo tiene). Muta y devuelve `resumen`. */ export function unirPendientesLocales( resumen: ResumenMiDia, pendientes: FilaLocalMiDia[], nombreUsuario: string, ): ResumenMiDia { const leadsPorId = new Map(resumen.misLeads.map((l) => [l.id, l])) const oportunidadesPorId = new Map(resumen.misOportunidades.items.map((o) => [o.id, o])) const apartadosPorId = new Map(resumen.misApartados.map((a) => [a.id, a])) const capturasPorId = new Map(resumen.misCapturas.map((c) => [c.leadId, c])) for (const p of pendientes) { // Mis capturas: flag + estado/origen local cuando Monday aún no lo refleja const captura = capturasPorId.get(p.leadId) if (captura) { captura.pendienteSync = true if (captura.estado === '') captura.estado = p.etapa if (captura.origen === '') captura.origen = p.origen ?? '' } if (p.mondayItemId !== null) { // Cambio pendiente sobre un item que Monday ya muestra: solo se marca const existente = leadsPorId.get(p.mondayItemId) ?? oportunidadesPorId.get(p.mondayItemId) ?? apartadosPorId.get(p.mondayItemId) if (existente) existente.pendienteSync = true continue } // Item puramente local (loc_*): se agrega a la lista de su board if (p.board === 'LEADS' && !esEstadoTerminalLead(p.etapa)) { resumen.misLeads.push({ id: p.leadId, nombre: p.nombreLead, telefono: p.telefono ?? '', email: p.email ?? '', estado: p.etapa, origen: p.origen ?? '', updatedAt: p.updatedAt.toISOString(), fuente: 'lead', ultimoCambio: ultimoCambioLocal(p, nombreUsuario), pendienteSync: true, }) } else if (p.board === 'OPORTUNIDADES') { // porEtapa cuenta TODAS las oportunidades (incluye terminales, como Monday) const etapa = p.etapa.trim() === '' ? '(sin etapa)' : p.etapa.trim() resumen.misOportunidades.porEtapa[etapa] = (resumen.misOportunidades.porEtapa[etapa] ?? 0) + 1 if (!esEtapaTerminal(p.etapa)) { resumen.misLeads.push({ id: p.leadId, nombre: p.nombreLead, telefono: p.telefono ?? '', email: p.email ?? '', estado: p.etapa, origen: p.origen ?? '', updatedAt: p.updatedAt.toISOString(), fuente: 'oportunidad', ultimoCambio: ultimoCambioLocal(p, nombreUsuario), pendienteSync: true, }) resumen.misOportunidades.items.push({ id: p.leadId, nombre: p.nombreLead, telefono: p.telefono ?? '', email: p.email ?? '', etapa: p.etapa, desarrollo: p.desarrollo ?? '', modeloInteres: p.modelo ?? '', unidadCodigo: '', updatedAt: p.updatedAt.toISOString(), ultimoCambio: ultimoCambioLocal(p, nombreUsuario), pendienteSync: true, }) } } else if (p.board === 'APARTADO' && !esEstadoFinalApartado(p.etapa)) { resumen.misApartados.push({ id: p.leadId, nombre: p.nombreLead, unidadCodigo: '', estado: p.etapa, fechaLimite: '', ultimoCambio: ultimoCambioLocal(p, nombreUsuario), pendienteSync: true, }) } } resumen.misLeads.sort(porUpdatedAtDesc) resumen.misOportunidades.items.sort(porUpdatedAtDesc) resumen.misApartados.sort((a, b) => (a.fechaLimite || '9999-12-31').localeCompare(b.fechaLimite || '9999-12-31')) return resumen }