- POST /api/leads: captura ligera al tablero Leads (sin oportunidad), self-assign a Vendedor o round-robin; tambien cubre la captura manual desde la UI de Monday (webhook create_item re-registrado). - POST /api/afluencias (nuevo): la forma completa sigue cayendo en Oportunidades con todo el perfil (etapa Afluencia, columna Asesor). - Round-robin dual (ambos tableros), Mi dia mezcla leads+oportunidades con insignia de origen, acciones con parametro fuente, reportes: leads desde Leads, perfilamiento desde Oportunidades, funnel Leads->Afluencia->...->Ganado.
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
import type { MondayService } from './monday.service.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', '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'
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
export interface ResumenMiDia {
|
|
generadoEn: string
|
|
sinMapeoMonday: boolean
|
|
misLeads: LeadMiDia[]
|
|
misCapturas: CapturaMiDia[]
|
|
misClientes: ClienteMiDia[]
|
|
misActividadesHoy: ActividadMiDia[]
|
|
apartadosPorVencer: ApartadoPorVencer[]
|
|
/** Conteo de MIS oportunidades por etapa (columna Estado del board) */
|
|
misOportunidades: { porEtapa: Record<string, number> }
|
|
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'>
|
|
|
|
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 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 }[] = [],
|
|
/** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */
|
|
clientes: { id: string; nombreCompleto: string; proyecto: string; unidadCodigo: string | null; createdAt: Date }[] = [],
|
|
ahora: Date = new Date(),
|
|
): Promise<ResumenMiDia> {
|
|
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',
|
|
}))
|
|
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',
|
|
}))
|
|
const misLeads: LeadMiDia[] = [...misLeadsLeads, ...misLeadsOportunidades].sort((a, b) =>
|
|
b.updatedAt.localeCompare(a.updatedAt),
|
|
)
|
|
|
|
// 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))
|
|
|
|
// "Mis clientes" es 100% local (no requiere Monday): el alta en progreso
|
|
// lleva proyecto '', el alta completada lleva el proyecto lleno
|
|
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(),
|
|
}))
|
|
.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 },
|
|
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
|
|
}
|