- Firma Cliente (Venta) renombrada a Alta Enkontrol (mismo redirect a alta cliente) - Campo 'Usuario Enkontrol' añadido en Datos Generales del alta cliente - Unidad se bloquea (APARTADA) al pasar cliente a etapa Aparto - Backend y frontend actualizados
944 lines
34 KiB
TypeScript
944 lines
34 KiB
TypeScript
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<string, number>; 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<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
|
|
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 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<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,
|
|
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
|
|
* 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<string, number> = {}
|
|
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<string, unknown>
|
|
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<ResumenMiDia['equipo']> | 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<ResumenMiDia['equipo']>['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
|
|
}
|