/** * Agregador "Mi día": lo que un vendedor necesita al abrir la app /hoy. * 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. */ import { camposAfluenciaFaltantes } from '../config/vocabulario.js' // 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', 'CANCELO', 'FIRMA CLIENTE', 'VENTA', 'SEGUIMIENTO', 'INTEGRACION', 'X AUTORIZAR', 'AUTORIZADO', 'X ESCRITURAR', 'TITULADO', ] const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin', 'manager'] 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 } export interface LeadMiDia { id: string nombre: string telefono: string email: string estado: string origen: string desarrollo?: string /** Vendedor asignado (nombre); '' si no tiene */ vendedor: string updatedAt: string /** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */ fuente: 'lead' | 'oportunidad' /** Último cambio conocido; null si no hay */ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null } export interface ActividadMiDia { id: string nombre: string tipo: string fecha: string estado: string /** Captura (lead/oportunidad/apartado) a la que pertenece la actividad; null si no está ligada */ leadCapturaId: string | null } 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 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 /** Estado actual del apartado ligado; null si no hay apartado ligado */ 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 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 /** ¿Afluencia con perfil completo? Sin esto no puede avanzar a 'Aparto' */ afluenciaCompleta: boolean } /** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */ export interface ApartadoMiDia { id: string nombre: string telefono: string email: string origen: string desarrollo: string /** Vendedor asignado (nombre); '' si no tiene */ vendedor: string unidadCodigo: string estado: string fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene) /** Último cambio conocido; null si no hay */ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null } export interface ResumenMiDia { generadoEn: string misLeads: LeadMiDia[] misCapturas: CapturaMiDia[] misClientes: ClienteMiDia[] misActividadesHoy: ActividadMiDia[] /** Todas las actividades ABIERTAS del usuario (sin filtro de fecha): alimenta el calendario */ todasMisActividades: 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[] /** Todos los apartados incluyendo terminales (para Escrituración) */ todosApartados: 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: { usuarioId: string nombre: string | null leadsAsignados: number /** Leads del vendedor creados en los últimos 7 días naturales */ leadsNuevosSemana: number }[] } } /** * 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 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 resumen */ 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: 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: * - `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 // Resolución del capturista real de cada fila (no el usuario que ve la pantalla) const nombrePorUsuarioId = new Map(usuarios.map((u) => [u.id, u.nombre])) 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 ?? '', desarrollo: c.desarrollo ?? '', vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '', updatedAt: c.updatedAt.toISOString(), fuente: 'lead' as const, ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''), 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) 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 = '' let fechaCita: string | undefined 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 if (typeof datos.fechaCita === 'string') fechaCita = datos.fechaCita } catch { /* datosJson corrupto: ignorar */ } } return { id: c.leadId, nombre: c.nombreLead, telefono: c.telefono ?? '', email: c.email ?? '', etapa: c.etapa, desarrollo: c.desarrollo ?? '', origen: c.origen ?? '', modeloInteres: c.modelo ?? '', vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '', unidadCodigo, updatedAt: c.updatedAt.toISOString(), ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''), fechaCita, afluenciaCompleta: camposAfluenciaFaltantes(c.datosJson).length === 0, 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, telefono: c.telefono ?? '', email: c.email ?? '', origen: c.origen ?? '', desarrollo: c.desarrollo ?? '', vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '', unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '', estado: c.etapa, fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '', ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''), pendienteSync: c.pendienteSync, })) // "Mis capturas" son sólo las altas del tablero Leads (antes listaba TODAS las // filas del usuario, así que el mismo cliente aparecía 2-3 veces con etapas // de oportunidad/apartado). // Todos los apartados, incluidos los terminales (Escrituración y cancelados) const todosApartadosLocal: ApartadoMiDia[] = apartadosLocales .filter((c) => c.board === 'APARTADO') .map((c) => ({ id: c.leadId, nombre: c.nombreLead, telefono: c.telefono ?? '', email: c.email ?? '', origen: c.origen ?? '', desarrollo: c.desarrollo ?? '', vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '', unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '', estado: c.etapa, fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '', ultimoCambio: null, pendienteSync: c.pendienteSync, })) const misCapturas: CapturaMiDia[] = capturas .filter((c) => c.board === 'LEADS') .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 cualquiera de los otros // tabs (por id y por cliente, para no repetir a la misma persona cuando su // lead se convirtió en oportunidad o apartado). const claveCliente = (nombre: string, telefono: string): string => `${nombre.trim().toLowerCase()}|${(telefono ?? '').replace(/\D/g, '').slice(-10)}` const idsEnOtrosTabsLocal = new Set([ ...misLeads.map((l) => l.id), ...itemsOportunidades.map((o) => o.id), ...misApartados.map((a) => a.id), ...todosApartadosLocal.map((a) => a.id), ]) const clavesEnOtrosTabs = new Set([ ...misLeads.map((l) => claveCliente(l.nombre, l.telefono)), ...itemsOportunidades.map((o) => claveCliente(o.nombre, o.telefono)), ...misApartados.map((a) => claveCliente(a.nombre, a.telefono)), ...todosApartadosLocal.map((a) => claveCliente(a.nombre, a.telefono)), ]) const misCapturasSinDuplicadosLocal = misCapturas.filter( (c) => !idsEnOtrosTabsLocal.has(c.leadId) && !clavesEnOtrosTabs.has(claveCliente(c.nombre, c.telefono)), ) 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 actividadesAbiertasOrdenadas = actividades .filter((a) => a.estado.toUpperCase() === 'ABIERTA') .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, leadCapturaId: a.leadCapturaId, })) const todasMisActividades: ActividadMiDia[] = actividadesAbiertasOrdenadas const misActividadesHoy: ActividadMiDia[] = actividadesAbiertasOrdenadas .filter((a) => { if (a.fecha === '') return true return a.fecha <= manana }) // --- 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', 'manager'] 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 { usuarioId: u.id, nombre: u.nombre, leadsAsignados, leadsNuevosSemana, } }) .sort((a, b) => b.leadsAsignados - a.leadsAsignados) equipo = { leadsNuevosSinAsignar: sinAsignarLeads + sinAsignarOportunidades, porVendedor, } } return { generadoEn: ahora.toISOString(), misLeads, misCapturas: misCapturasSinDuplicadosLocal, misClientes, misActividadesHoy, todasMisActividades, apartadosPorVencer, misOportunidades: { porEtapa, items: itemsOportunidades }, misApartados, // Todos los apartados incluyendo terminales (para Escrituración) todosApartados: todosApartadosLocal, conteos: { leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter( (c) => c.createdAt.getTime() >= hace7DiasMs, ).length, actividadesAbiertas, }, ...(equipo !== undefined ? { equipo } : {}), } }