import { prisma } from './prisma.js' import { AppError } from '../utils/errors.js' // ---------- Interfaces ---------- export interface MiTablero { generadoEn: string leadsPorDia: { fecha: string; cantidad: number }[] conversion: { leads: number oportunidades: number apartados: number vendidos: number /** Leads descartados (etapa terminal DESCARTADO); etapa extra al final del funnel */ descartados: number } ventasMes: { monto: number unidades: number } porOrigen: { origen: string total: number convertidos: number }[] porDesarrollo: { desarrollo: string ventas: number }[] actividadesHoy: number actividadesPendientes: number } export interface MetricasUsoUsuario { usuarioId: string nombre: string rol: string leadsHoy: number leadsSemana: number leadsMes: number tasaDescarte: number actividadesCompletadas: number actividadesPendientes: number } export interface MetricasUso { generadoEn: string usuarios: MetricasUsoUsuario[] } // ---------- Helpers ---------- /** Inicio del día local de `ahora` (medianoche) */ function inicioDelDia(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), d.getDate()) } /** Fecha local 'YYYY-MM-DD' */ function formatoFecha(d: Date): string { 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}` } // ---------- Servicio: Mi Tablero ---------- /** * Construye el tablero personal del vendedor: leads por día (últimos 30 días), * tasa de conversión, ventas del mes, rendimiento por origen, ranking de * desarrollos y conteo de actividades. * * Función pura: recibe los datos crudos desde Prisma; testeable sin BD. */ export function construirMiTablero( capturas: { leadId: string board: string etapa: string origen: string | null desarrollo: string | null createdAt: Date /** Necesarios para las ventas cerradas del mes (fecha del cambio y precio) */ updatedAt?: Date datosJson?: string | null }[], cotizaciones: { precioFinal: number vendedorId: string | null createdAt: Date }[], actividades: { usuarioId: string estado: string fechaVencimiento: Date | null }[], ahora: Date, ): MiTablero { const hoy = inicioDelDia(ahora) const hace30Dias = new Date(hoy.getTime() - 30 * 24 * 60 * 60 * 1000) // -- Leads por día (últimos 30 días) -- const leadsPorDiaMap = new Map() // Inicializar todos los días para que la gráfica no tenga huecos for (let i = 0; i < 30; i++) { const d = new Date(hoy.getTime() - (29 - i) * 24 * 60 * 60 * 1000) leadsPorDiaMap.set(formatoFecha(d), 0) } for (const c of capturas) { const fecha = c.createdAt if (fecha >= hace30Dias) { const clave = formatoFecha(fecha) if (leadsPorDiaMap.has(clave)) { leadsPorDiaMap.set(clave, (leadsPorDiaMap.get(clave) ?? 0) + 1) } } } const leadsPorDia = [...leadsPorDiaMap.entries()] .map(([fecha, cantidad]) => ({ fecha, cantidad })) .sort((a, b) => a.fecha.localeCompare(b.fecha)) // -- Conversión (funnel) -- const leads = capturas.filter((c) => c.board === 'LEADS').length const oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES').length // Apartados ACTIVOS del funnel: ni cancelados (Cancelo/Cancelacion Apartado) // ni ya vendidos (Firma Cliente/Escriturado/Titulado pasan a "vendidos") const apartados = capturas.filter( (c) => c.board === 'APARTADO' && !['FIRMA CLIENTE', 'CANCELO', 'CANCELACION APARTADO', 'ESCRITURADO', 'TITULADO'].includes( c.etapa.toUpperCase(), ), ).length const vendidos = capturas.filter( (c) => c.board === 'APARTADO' && ['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()), ).length const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length // -- Ventas del mes -- // Antes sumaba COTIZACIONES (un inmueble recotizado contaba varias veces y una // cotización no es una venta). Ahora cuenta ventas cerradas del mes: apartados // con firma de cliente o posteriores (Venta, Escriturado, Titulado), sin // repetir la misma unidad. const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1) const esVentaCerrada = (etapa: string): boolean => { const e = etapa.toUpperCase() return ( e.includes('FIRMA') || e.includes('VENTA') || e.includes('ESCRITURADO') || e.includes('TITULADO') ) && !e.includes('CANCEL') } const ventasDelMes = capturas.filter( (c) => c.board === 'APARTADO' && esVentaCerrada(c.etapa) && (c.updatedAt ?? c.createdAt) >= inicioMes, ) const unidadesVendidas = new Map() for (const v of ventasDelMes) { let monto = 0 try { const datos = JSON.parse(v.datosJson ?? '{}') as Record const precio = Number(datos['precioFinal'] ?? datos['precioVenta'] ?? 0) monto = Number.isFinite(precio) ? precio : 0 } catch { monto = 0 } unidadesVendidas.set(v.leadId, monto) } const ventasMes = { monto: Math.round([...unidadesVendidas.values()].reduce((a, b) => a + b, 0) * 100) / 100, unidades: unidadesVendidas.size, } // -- Por origen -- const origenMap = new Map() for (const c of capturas) { const origen = c.origen?.trim() || '(sin origen)' const entrada = origenMap.get(origen) ?? { total: 0, convertidos: 0 } entrada.total++ if (c.board === 'OPORTUNIDADES' || c.board === 'APARTADO') { entrada.convertidos++ } origenMap.set(origen, entrada) } const porOrigen = [...origenMap.entries()] .map(([origen, v]) => ({ origen, ...v })) .sort((a, b) => b.total - a.total) // -- Por desarrollo (solo apartados vendidos) -- const desarrolloMap = new Map() for (const c of capturas) { if ( c.board === 'APARTADO' && ['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()) && c.desarrollo ) { const dev = c.desarrollo.trim() if (dev !== '') { desarrolloMap.set(dev, (desarrolloMap.get(dev) ?? 0) + 1) } } } const porDesarrollo = [...desarrolloMap.entries()] .map(([desarrollo, ventas]) => ({ desarrollo, ventas })) .sort((a, b) => b.ventas - a.ventas) // -- Actividades -- const manana = new Date(hoy.getTime() + 24 * 60 * 60 * 1000) const actividadesHoy = actividades.filter( (a) => a.estado.toUpperCase() === 'ABIERTA' && (!a.fechaVencimiento || a.fechaVencimiento < manana), ).length const actividadesPendientes = actividades.filter( (a) => a.estado.toUpperCase() === 'ABIERTA', ).length return { generadoEn: ahora.toISOString(), leadsPorDia, conversion: { leads, oportunidades, apartados, vendidos, descartados }, ventasMes, porOrigen, porDesarrollo, actividadesHoy, actividadesPendientes, } } // ---------- Servicio con Prisma ---------- /** * Obtiene el tablero personal de un vendedor desde la base de datos. */ export async function obtenerMiTablero(usuarioId: string): Promise { const ahora = new Date() const capturas = await prisma.leadCaptura.findMany({ where: { usuarioId }, select: { leadId: true, board: true, etapa: true, origen: true, desarrollo: true, createdAt: true, updatedAt: true, datosJson: true, }, }) const cotizaciones = await prisma.cotizacion.findMany({ where: { vendedorId: usuarioId }, select: { precioFinal: true, vendedorId: true, createdAt: true, }, }) const actividades = await prisma.actividadLocal.findMany({ where: { usuarioId }, select: { usuarioId: true, estado: true, fechaVencimiento: true, }, }) return construirMiTablero(capturas, cotizaciones, actividades, ahora) } // ---------- Servicio: Métricas de uso (admin/coordinador) ---------- /** * Métricas de uso por usuario: leads capturados (hoy, semana, mes), tasa de * descarte, actividades completadas vs pendientes. Visible solo para admin y * coordinador. */ export async function obtenerMetricasUso(usuarioId?: string): Promise { const ahora = new Date() const hoy = inicioDelDia(ahora) const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000) const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1) // Filtro por usuario específico (coordinador ve solo su equipo; admin ve todos) const usuarioFiltro = usuarioId ?? undefined const [capturas, actividades, usuarios] = await Promise.all([ prisma.leadCaptura.findMany({ ...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}), select: { usuarioId: true, etapa: true, createdAt: true, }, }), prisma.actividadLocal.findMany({ ...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}), select: { usuarioId: true, estado: true, }, }), prisma.usuario.findMany({ where: usuarioFiltro ? { id: usuarioFiltro } : { activo: true }, select: { id: true, nombre: true, rol: true, }, }), ]) const metricasPorUsuario: MetricasUsoUsuario[] = usuarios.map((u) => { const misCapturas = capturas.filter((c) => c.usuarioId === u.id) const leadsHoy = misCapturas.filter((c) => c.createdAt >= hoy).length const leadsSemana = misCapturas.filter((c) => c.createdAt >= hace7dias).length const leadsMes = misCapturas.filter((c) => c.createdAt >= inicioMes).length const descartados = misCapturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO', ).length const tasaDescarte = leadsMes > 0 ? Math.round((descartados / leadsMes) * 100) / 100 : 0 const misActividades = actividades.filter((a) => a.usuarioId === u.id) const actividadesCompletadas = misActividades.filter( (a) => a.estado.toUpperCase() === 'CERRADA', ).length const actividadesPendientes = misActividades.filter( (a) => a.estado.toUpperCase() === 'ABIERTA', ).length return { usuarioId: u.id, nombre: u.nombre, rol: u.rol, leadsHoy, leadsSemana, leadsMes, tasaDescarte, actividadesCompletadas, actividadesPendientes, } }) return { generadoEn: ahora.toISOString(), usuarios: metricasPorUsuario, } }