feat(mejoras): 14 mejoras al CRM Frasa
Fase 1 - UI: dark mode, skeletons, toasts Fase 2 - Filtros: avanzados Mi día, búsqueda global Ctrl+K Fase 3 - Datos: export Excel, notas apartados, auditoría Fase 4 - Dashboard: gráficas vendedor + métricas uso Fase 5 - Responsive: tablets optimizadas Fase 6 - PWA: IndexedDB offline, push notifications Backend: 3 tablas nuevas, 6 services, 6 endpoints nuevos 721 API tests + fronts verdes.
This commit is contained in:
311
api-gateway/src/services/reporte-mi-tablero.service.ts
Normal file
311
api-gateway/src/services/reporte-mi-tablero.service.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
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
|
||||
}
|
||||
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
|
||||
}[],
|
||||
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<string, number>()
|
||||
// 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
|
||||
const apartados = capturas.filter(
|
||||
(c) => c.board === 'APARTADO' && c.etapa.toUpperCase() !== 'CANCELADO',
|
||||
).length
|
||||
const vendidos = capturas.filter(
|
||||
(c) =>
|
||||
c.board === 'APARTADO' &&
|
||||
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
|
||||
).length
|
||||
|
||||
// -- Ventas del mes --
|
||||
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
|
||||
const cotizacionesMes = cotizaciones.filter(
|
||||
(ct) => ct.createdAt >= inicioMes,
|
||||
)
|
||||
const ventasMes = {
|
||||
monto: Math.round(cotizacionesMes.reduce((acc, ct) => acc + ct.precioFinal, 0) * 100) / 100,
|
||||
unidades: cotizacionesMes.length,
|
||||
}
|
||||
|
||||
// -- Por origen --
|
||||
const origenMap = new Map<string, { total: number; convertidos: number }>()
|
||||
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<string, number>()
|
||||
for (const c of capturas) {
|
||||
if (
|
||||
c.board === 'APARTADO' &&
|
||||
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].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 },
|
||||
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<MiTablero> {
|
||||
const ahora = new Date()
|
||||
|
||||
const capturas = await prisma.leadCaptura.findMany({
|
||||
where: { usuarioId },
|
||||
select: {
|
||||
board: true,
|
||||
etapa: true,
|
||||
origen: true,
|
||||
desarrollo: true,
|
||||
createdAt: 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<MetricasUso> {
|
||||
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 whereUsuario = usuarioId ? { usuarioId } : undefined
|
||||
|
||||
const [capturas, actividades, usuarios] = await Promise.all([
|
||||
prisma.leadCaptura.findMany({
|
||||
where: whereUsuario,
|
||||
select: {
|
||||
usuarioId: true,
|
||||
etapa: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.actividadLocal.findMany({
|
||||
where: whereUsuario,
|
||||
select: {
|
||||
usuarioId: true,
|
||||
estado: true,
|
||||
},
|
||||
}),
|
||||
prisma.usuario.findMany({
|
||||
where: whereUsuario ? { id: usuarioId } : { 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user