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:
2026-09-07 06:04:49 +00:00
parent 388e1da338
commit c77b4c0628
170 changed files with 5802 additions and 637 deletions

View 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,
}
}