feat(purga): Monday eliminado completamente del sistema

14 servicios eliminados, 7 controllers local-only, crons locales,
config sin MONDAY_*, frontend sin banners/avisos Monday.
552 tests API + 7 apps construidas.
This commit is contained in:
2026-09-11 11:47:17 +00:00
parent 64b01246d6
commit 89d95eb53b
130 changed files with 2018 additions and 14747 deletions

View File

@@ -1,16 +1,8 @@
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).
* 100% local: todo se construye desde la tabla LeadCaptura (los tabs del
* embudo), Cliente (mis clientes) y ActividadLocal (agenda). El "mío" se
* resuelve por el usuarioId de cada fila.
*/
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
@@ -42,10 +34,32 @@ export const ESTADOS_FINALES_APARTADO = [
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
const MS_DIA = 24 * 60 * 60 * 1000
/** 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 interface UsuarioMiDia {
nombre: string
rol: string
mondayUserId: number | null
}
export interface LeadMiDia {
@@ -57,12 +71,10 @@ export interface LeadMiDia {
origen: string
desarrollo?: string
updatedAt: string
/** Tablero del item: 'lead' (tablero Leads) u 'oportunidad' (Oportunidades) */
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
fuente: 'lead' | 'oportunidad'
/** Último evento del activity log del board; null si el item no tiene eventos */
/** Último cambio conocido; null si no hay */
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 {
@@ -86,11 +98,9 @@ export interface CapturaMiDia {
leadId: string
nombre: string
telefono: string
estado: string // estado ACTUAL en Monday; '' si el lead ya no existe en el board
estado: string
origen: string
capturadoEn: string // ISO
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
export interface ClienteMiDia {
@@ -100,7 +110,7 @@ export interface ClienteMiDia {
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 */
/** Estado actual del apartado ligado; null si no hay apartado ligado */
estadoApartado: string | null
}
@@ -115,12 +125,10 @@ export interface OportunidadMiDia {
modeloInteres: string
unidadCodigo: string
updatedAt: string
/** Último evento del activity log del board; null si el item no tiene eventos */
/** Último cambio conocido; null si no hay */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */
fechaCita?: string | undefined
/** 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) */
@@ -130,15 +138,12 @@ export interface ApartadoMiDia {
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 */
/** Último cambio conocido; null si no hay */
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[]
@@ -158,7 +163,7 @@ export interface ResumenMiDia {
equipo?: {
leadsNuevosSinAsignar: number
porVendedor: {
mondayUserId: number
usuarioId: string
nombre: string | null
leadsAsignados: number
/** Leads del vendedor creados en los últimos 7 días naturales */
@@ -167,407 +172,6 @@ export interface ResumenMiDia {
}
}
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<number, string>,
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
capturas: {
leadId: string
nombreLead: string
telefono: string | null
createdAt: Date
/** Oportunidad ligada (LeadOportunidad); null si la captura aún no convertió */
oportunidadId?: string | null
}[] = [],
/** 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<string>,
): Promise<ResumenMiDia> {
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 esAdmin = usuario.rol === 'admin' || usuario.rol === 'direccion_general' || usuario.rol === 'director_ventas'
const esMio = (peopleIds: number[], itemId: string): boolean => {
if (esAdmin) return true
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,
desarrollo: l.desarrollo ?? '',
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,
desarrollo: o.desarrollo ?? '',
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,
fechaCita: o.fechaCita,
}))
.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)
.filter((id): id is string => id != null),
)
const miosApartadosTodos = apartados.filter((a) =>
esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id),
)
const miosApartados = miosApartadosTodos.filter((a) => !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)
// Todos los apartados míos incluyendo terminales (para pestaña Escrituración);
// se calcula desde la lista ANTES de excluir estados finales
const todosApartados: ApartadoMiDia[] = miosApartadosTodos
.map((a) => ({
id: a.id,
nombre: a.nombre,
unidadCodigo: a.unidadCodigo,
estado: a.estado,
fechaLimite: a.fechaLimite,
ultimoCambio: null as { fechaISO: string; usuario: string | null; detalle: string } | null,
// Los apartados leídos de Monday ya están sincronizados por definición
pendienteSync: false,
}))
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<string, number> = {}
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,
todosApartados,
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<number, number>()
const nuevosPorId = new Map<number, number>()
// 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
@@ -590,14 +194,14 @@ export interface FilaLocalMiDia {
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) */
/** Item heredado del CRM anterior (compatibilidad de datos; null en capturas locales) */
mondayItemId: string | null
pendienteSync: boolean
createdAt: Date
updatedAt: Date
}
/** Cliente local tal como lo necesita el modo local (misma forma que en resumenMiDia) */
/** Cliente local tal como lo necesita el resumen */
export interface ClienteLocalMiDia {
id: string
nombreCompleto: string
@@ -643,12 +247,12 @@ export interface UsuarioLocal {
}
/**
* 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.
* ResumenMiDia COMPLETO construido solo con datos locales: 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%:
* Parámetros opcionales:
* - `actividades`: actividades locales del usuario (tabla ActividadLocal)
* - `usuarios`: todos los usuarios activos (para la sección equipo)
*/
@@ -685,7 +289,7 @@ export function resumenMiDiaLocal(
.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)
// Conteo por etapa de TODAS las oportunidades (incluye terminales)
const porEtapa: Record<string, number> = {}
for (const o of oportunidadesLocales) {
const etapa = o.etapa.trim() === '' ? '(sin etapa)' : o.etapa.trim()
@@ -855,7 +459,7 @@ export function resumenMiDiaLocal(
(c) => c.usuarioId === u.id && c.createdAt.getTime() >= hace7DiasMs,
).length
return {
mondayUserId: 0,
usuarioId: u.id,
nombre: u.nombre,
leadsAsignados,
leadsNuevosSemana,
@@ -871,7 +475,6 @@ export function resumenMiDiaLocal(
return {
generadoEn: ahora.toISOString(),
sinMapeoMonday: usuario.mondayUserId === null,
misLeads,
misCapturas: misCapturasSinDuplicadosLocal,
misClientes,
@@ -900,105 +503,3 @@ export function resumenMiDiaLocal(
...(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),
fechaCita: (() => { try { const d = JSON.parse(p.datosJson ?? '{}') as Record<string, unknown>; return typeof d.fechaCita === 'string' ? d.fechaCita : undefined } catch { return undefined } })(),
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
}