feat(modo-local): CRM 100% local sin dependencia de Monday.com
Nuevas tablas: - ActividadLocal (historial de llamadas, reuniones, tareas) - AsignacionLocal (round-robin local) - fechaLimite en LeadCaptura Servicios nuevos: - actividad-local.service.ts (CRUD actividades) - local-canal.ts (canal de envío para recordatorios) - seed-modo-local.js (activar modo local) Mi día local mejorado: - Mis actividades hoy (filtros usuario/fecha/estado) - Apartados por vencer (con fechaLimite real) - Equipo (contadores por vendedor, solo coordinador/admin) - unidadCodigo en oportunidades desde datosJson Automatizaciones locales: - Round-robin: asigna leads sin vendedor + crea actividad - Inactividad: detecta oportunidades >10 días sin update - Escalado: alerta oportunidades >48h sin contacto Reportes: actividadesAbiertas + parcial:false en modo local Cron wiring: crons locales cuando Monday caído 661 tests verdes.
This commit is contained in:
@@ -537,6 +537,12 @@ export interface FilaLocalMiDia {
|
||||
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
|
||||
@@ -566,20 +572,46 @@ function ultimoCambioLocal(fila: FilaLocalMiDia, nombreUsuario: string) {
|
||||
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. Lo que no existe en
|
||||
* local va vacío/cero sin romper el front: actividades del día (no hay tabla
|
||||
* local), apartadosPorVencer (los apartados locales aún no guardan fecha
|
||||
* límite) y la sección equipo (se omite).
|
||||
* 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
|
||||
|
||||
@@ -614,19 +646,29 @@ export function resumenMiDiaLocal(
|
||||
|
||||
const itemsOportunidades: OportunidadMiDia[] = oportunidadesLocales
|
||||
.filter((c) => !esEtapaTerminal(c.etapa))
|
||||
.map((c) => ({
|
||||
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,
|
||||
}))
|
||||
.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)
|
||||
@@ -642,7 +684,7 @@ export function resumenMiDiaLocal(
|
||||
nombre: c.nombreLead,
|
||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||
estado: c.etapa,
|
||||
fechaLimite: '',
|
||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
|
||||
pendienteSync: c.pendienteSync,
|
||||
}))
|
||||
@@ -680,22 +722,119 @@ export function resumenMiDiaLocal(
|
||||
}))
|
||||
.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: [], // no hay tabla local de actividades
|
||||
apartadosPorVencer: [], // los apartados locales aún no guardan fecha límite
|
||||
misActividadesHoy,
|
||||
apartadosPorVencer,
|
||||
misOportunidades: { porEtapa, items: itemsOportunidades },
|
||||
misApartados,
|
||||
conteos: {
|
||||
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
|
||||
(c) => c.createdAt.getTime() >= hace7DiasMs,
|
||||
).length,
|
||||
actividadesAbiertas: 0,
|
||||
actividadesAbiertas,
|
||||
},
|
||||
...(equipo !== undefined ? { equipo } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user