feat(ux): 17 mejoras de experiencia de usuario

Backend (10 endpoints nuevos):
- Recordatorios personales (CRUD + tabla RecordatorioVendedor)
- Lead scoring 0-100 (etapa+recencia+datos)
- Dashboard mora (pagos vencidos, monto, días atraso)
- Timeline de pagos (pagado/vence/futuro)
- Perfil completo contacto (pagos+cotizaciones+docs+score)
- Exportar contactos a CSV

Frontend Mi día:
- Resumen matutino (saludo+KPIs)
- Historial visual lead (timeline)
- Score badge 0-100 (verde/amarillo/rojo)
- Vista kanban (drag & drop)

Frontend Cobranza:
- Dashboard mora (tabla+WhatsApp)
- Timeline pagos visual (nodos coloreados)

Frontend Alta Clientes:
- Checklist documentos visual
- Foto cliente (cámara)

Frontend Landing:
- Perfil contacto completo
- Exportar contactos CSV

Frontend Mi día (extras):
- Comparativa mensual
- KPIs dashboard (leads hoy, ventas, conversión)

806 tests, 5 apps construidas.
This commit is contained in:
2026-09-08 07:42:54 +00:00
parent 5063ffe281
commit db18685872
34 changed files with 2950 additions and 33 deletions

View File

@@ -1,7 +1,8 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
import { detalleContacto, claveTelefono } from '../services/contactos-detalle.service.js'
import { detalleContactoLocal, listarContactosLocal } from '../services/contactos-local.service.js'
import { detalleContactoLocal, listarContactosLocal, exportarCSV } from '../services/contactos-local.service.js'
import { perfilCompleto } from '../services/contactos-detalle.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { prisma } from '../services/prisma.js'
@@ -122,3 +123,28 @@ export async function detalleContactoEndpoint(req: Request, res: Response): Prom
res.json(detalle)
}
}
/**
* GET /api/contactos/:id/completo — perfil completo del contacto:
* datos, historial pagos, cotizaciones, documentos, lead score, última actividad
*/
export async function perfilCompletoEndpoint(req: Request, res: Response): Promise<void> {
const contactoId = String(req.params['id'])
const perfil = await perfilCompleto(contactoId)
if (perfil === null) throw new NotFoundError('Contacto no encontrado')
res.json(perfil)
}
/**
* GET /api/contactos/exportar — exportar contactos a CSV
* Query params: desarrollo, estatus
*/
export async function exportarContactosEndpoint(req: Request, res: Response): Promise<void> {
const csv = await exportarCSV({
desarrollo: (req.query['desarrollo'] as string) ?? undefined,
estatus: (req.query['estatus'] as string) ?? undefined,
})
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="contactos.csv"')
res.send(csv)
}

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { construirTimeline } from './planes.controller.js'
describe('construirTimeline', () => {
const hoy = new Date('2026-09-08T12:00:00Z')
const planBase = {
id: 'p1',
clienteNombre: 'Test',
unidadCodigo: 'U-1',
proyecto: 'SENDA',
fechaInicio: '2026-01-01',
engancheTotal: 10000,
numPagos: 3,
mensualidad: 3333.33,
status: 'ACTIVO' as const,
pagos: [
{ id: 'pg1', numPago: 1, fechaVencimiento: '2026-02-01', monto: 3333.33, montoPagado: 3333.33, aplicaciones: [] },
{ id: 'pg2', numPago: 2, fechaVencimiento: '2026-08-01', monto: 3333.33, montoPagado: 0, aplicaciones: [] },
{ id: 'pg3', numPago: 3, fechaVencimiento: '2026-10-01', monto: 3333.34, montoPagado: 0, aplicaciones: [] },
],
notas: [],
createdAt: '2026-01-01T00:00:00Z',
}
it('marca pagos pagados, vencidos y futuros correctamente', () => {
const timeline = construirTimeline(planBase, hoy)
expect(timeline).toHaveLength(3)
// Pago 1: pagado
expect(timeline[0]?.estado).toBe('pagado')
expect(timeline[0]?.diasRestantes).toBeNull()
// Pago 2: vencido (01/08/2026 < 08/09/2026) — díasRestantes negativos
expect(timeline[1]?.estado).toBe('vence')
expect(timeline[1]?.diasRestantes).toBeLessThan(0)
// Pago 3: futuro (01/10/2026 > 08/09/2026) — díasRestantes positivos
expect(timeline[2]?.estado).toBe('futuro')
expect(timeline[2]?.diasRestantes).toBeGreaterThan(0)
})
it('ordena por numPago asc', () => {
const timeline = construirTimeline(planBase, hoy)
expect(timeline.map((t) => t.numPago)).toEqual([1, 2, 3])
})
it('devuelve array vacío si no hay pagos', () => {
const planSinPagos = { ...planBase, pagos: [] }
expect(construirTimeline(planSinPagos, hoy)).toEqual([])
})
it('pago parcialmente pagado y vencido se marca como vence', () => {
const plan = {
...planBase,
pagos: [
{ id: 'pg1', numPago: 1, fechaVencimiento: '2026-08-01', monto: 5000, montoPagado: 2000, aplicaciones: [] },
],
}
const timeline = construirTimeline(plan, hoy)
expect(timeline[0]?.estado).toBe('vence')
expect(timeline[0]?.montoPagado).toBe(2000)
})
})

View File

@@ -306,6 +306,55 @@ export async function agregarNota(req: Request, res: Response): Promise<void> {
res.status(201).json(creada)
}
// ── Timeline de pagos ───────────────────────────────────────────────
export interface TimelinePago {
numPago: number
fechaVencimiento: string
monto: number
montoPagado: number
estado: 'pagado' | 'vence' | 'futuro'
diasRestantes: number | null
}
/**
* Genera la línea de tiempo de pagos de un plan.
* Estados: pagado (montoPagado >= monto), vence (fecha pasada sin cubrir),
* futuro (fecha aún no llega).
*/
export function construirTimeline(plan: PlanPagoDominio, hoy: Date = new Date()): TimelinePago[] {
const inicioHoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
return plan.pagos
.slice()
.sort((a, b) => a.numPago - b.numPago)
.map((pago) => {
const fv = parseFechaLocal(pago.fechaVencimiento)
const pagado = pago.montoPagado >= pago.monto - 0.01
const vencido = fv < inicioHoy && !pagado
const estado: TimelinePago['estado'] = pagado ? 'pagado' : vencido ? 'vence' : 'futuro'
const diasRestantes = pagado
? null
: Math.round((fv.getTime() - inicioHoy.getTime()) / 86_400_000)
return {
numPago: pago.numPago,
fechaVencimiento: pago.fechaVencimiento,
monto: pago.monto,
montoPagado: pago.montoPagado,
estado,
diasRestantes,
}
})
}
/** GET /api/planes/:id/timeline — timeline visual de pagos */
export async function timelinePlan(req: Request, res: Response): Promise<void> {
const plan = await cargarPlan(String(req.params['id']))
const dominio = aDominio(plan)
res.json(construirTimeline(dominio))
}
/** POST /api/planes/:id/cancelar */
export async function cancelarPlan(req: Request, res: Response): Promise<void> {
const plan = await cargarPlan(String(req.params['id']))

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const svcMock = vi.hoisted(() => ({
crear: vi.fn(),
listar: vi.fn(),
completar: vi.fn(),
eliminar: vi.fn(),
proximos: vi.fn(),
}))
vi.mock('../services/recordatorio-vendedor.service.js', () => ({
recordatorioVendedorService: svcMock,
}))
import { crearController } from './recordatorio-vendedor.controller.js'
const controller = crearController(svcMock as never)
function resJson() {
const res = {
json: vi.fn(),
status: vi.fn().mockReturnThis(),
end: vi.fn(),
}
return res
}
function reqConToken(sub = 'u-1', overrides: Record<string, unknown> = {}) {
return { params: {}, query: {}, usuarioToken: { sub, rol: 'vendedor' }, ...overrides } as never
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('recordatorio-vendedor.controller', () => {
it('crear: llama al service con usuarioId del token', async () => {
svcMock.crear.mockResolvedValue({ id: 'r1', titulo: 'Test' })
const res = resJson()
await controller.crear(
reqConToken('u-1', { body: { titulo: 'Test', fechaRecordar: '2026-09-10T10:00:00' } }),
res as never,
)
expect(svcMock.crear).toHaveBeenCalledWith('u-1', {
titulo: 'Test',
descripcion: null,
leadId: null,
fechaRecordar: '2026-09-10T10:00:00',
})
expect(res.status).toHaveBeenCalledWith(201)
expect(res.json).toHaveBeenCalledWith({ id: 'r1', titulo: 'Test' })
})
it('listar: pasa filtros query al service', async () => {
svcMock.listar.mockResolvedValue([])
const res = resJson()
await controller.listar(
reqConToken('u-1', { query: { completados: 'true', desde: '2026-09-01' } }),
res as never,
)
expect(svcMock.listar).toHaveBeenCalledWith('u-1', {
completados: true,
desde: '2026-09-01',
hasta: null,
})
expect(res.json).toHaveBeenCalledWith([])
})
it('completar: llama al service con el id', async () => {
svcMock.completar.mockResolvedValue({ id: 'r1', completado: true })
const res = resJson()
await controller.completar(
reqConToken('u-1', { params: { id: 'r1' } }),
res as never,
)
expect(svcMock.completar).toHaveBeenCalledWith('r1')
expect(res.json).toHaveBeenCalledWith({ id: 'r1', completado: true })
})
it('eliminar: responde 204', async () => {
svcMock.eliminar.mockResolvedValue(undefined)
const res = resJson()
await controller.eliminar(
reqConToken('u-1', { params: { id: 'r1' } }),
res as never,
)
expect(svcMock.eliminar).toHaveBeenCalledWith('r1')
expect(res.status).toHaveBeenCalledWith(204)
expect(res.end).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,107 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import {
recordatorioVendedorService,
type RecordatorioVendedorService,
} from '../services/recordatorio-vendedor.service.js'
const crearSchema = z.object({
titulo: z.string().min(1),
descripcion: z.string().optional(),
leadId: z.string().optional(),
fechaRecordar: z.string().min(1),
})
/**
* POST /api/recordatorios — crear recordatorio (requiere auth)
*/
export async function crearRecordatorioEndpoint(req: Request, res: Response): Promise<void> {
const body = crearSchema.parse(req.body)
const usuarioId = req.usuarioToken?.sub
if (!usuarioId) throw new Error('Auth requerida')
const recordatorio = await recordatorioVendedorService.crear(usuarioId, {
titulo: body.titulo,
descripcion: body.descripcion ?? null,
leadId: body.leadId ?? null,
fechaRecordar: body.fechaRecordar,
})
res.status(201).json(recordatorio)
}
/**
* GET /api/recordatorios — listar recordatorios del usuario (requiere auth)
* Query params: completados, desde, hasta
*/
export async function listarRecordatoriosEndpoint(req: Request, res: Response): Promise<void> {
const usuarioId = req.usuarioToken?.sub
if (!usuarioId) throw new Error('Auth requerida')
const completadosStr = req.query['completados'] as string | undefined
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null } = {
desde: (req.query['desde'] as string) ?? null,
hasta: (req.query['hasta'] as string) ?? null,
}
if (completadosStr === 'true') opts.completados = true
else if (completadosStr === 'false') opts.completados = false
const recordatorios = await recordatorioVendedorService.listar(usuarioId, opts)
res.json(recordatorios)
}
/**
* PATCH /api/recordatorios/:id/completar — marcar como completado (requiere auth)
*/
export async function completarRecordatorioEndpoint(req: Request, res: Response): Promise<void> {
const id = String(req.params['id'])
const recordatorio = await recordatorioVendedorService.completar(id)
res.json(recordatorio)
}
/**
* DELETE /api/recordatorios/:id — eliminar recordatorio (requiere auth)
*/
export async function eliminarRecordatorioEndpoint(req: Request, res: Response): Promise<void> {
const id = String(req.params['id'])
await recordatorioVendedorService.eliminar(id)
res.status(204).end()
}
// Factory exportable para tests (inyección de dependencia)
export function crearController(svc: RecordatorioVendedorService) {
return {
crear: async (req: Request, res: Response): Promise<void> => {
const body = crearSchema.parse(req.body)
const usuarioId = req.usuarioToken?.sub
if (!usuarioId) throw new Error('Auth requerida')
const r = await svc.crear(usuarioId, {
titulo: body.titulo,
descripcion: body.descripcion ?? null,
leadId: body.leadId ?? null,
fechaRecordar: body.fechaRecordar,
})
res.status(201).json(r)
},
listar: async (req: Request, res: Response): Promise<void> => {
const usuarioId = req.usuarioToken?.sub
if (!usuarioId) throw new Error('Auth requerida')
const completadosStr = req.query['completados'] as string | undefined
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null } = {
desde: (req.query['desde'] as string) ?? null,
hasta: (req.query['hasta'] as string) ?? null,
}
if (completadosStr === 'true') opts.completados = true
else if (completadosStr === 'false') opts.completados = false
const items = await svc.listar(usuarioId, opts)
res.json(items)
},
completar: async (req: Request, res: Response): Promise<void> => {
const r = await svc.completar(String(req.params['id']))
res.json(r)
},
eliminar: async (req: Request, res: Response): Promise<void> => {
await svc.eliminar(String(req.params['id']))
res.status(204).end()
},
}
}

View File

@@ -2,6 +2,8 @@ import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
import { ReportesService } from '../services/reportes.service.js'
import { cargarPlanesDominio } from './planes.controller.js'
import { obtenerScores } from '../services/lead-scoring.service.js'
import { resumenMora } from '../services/mora.service.js'
/** Singleton del agregador (cache en memoria de 60 s; los tests instancian el suyo) */
export const reportesService = new ReportesService(mondayService, cargarPlanesDominio)
@@ -10,3 +12,14 @@ export const reportesService = new ReportesService(mondayService, cargarPlanesDo
export async function resumenReportes(_req: Request, res: Response): Promise<void> {
res.json(await reportesService.resumen())
}
/** GET /api/reportes/scoring — scores de todos los leads activos */
export async function scoringEndpoint(req: Request, res: Response): Promise<void> {
const usuarioId = req.query['usuarioId'] as string | undefined
res.json(await obtenerScores(usuarioId))
}
/** GET /api/reportes/mora — dashboard de mora: pagos vencidos */
export async function moraEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await resumenMora())
}

View File

@@ -43,6 +43,7 @@ import {
listarPlanes,
obtenerPlan,
registrarAbonoEndpoint,
timelinePlan,
} from '../controllers/planes.controller.js'
import {
cambiarEtapaOportunidadEndpoint,
@@ -64,7 +65,12 @@ import {
listarUsuariosMondayEndpoint,
} from '../controllers/usuarios.controller.js'
import { resumenMiDiaEndpoint } from '../controllers/mi-dia.controller.js'
import { listarContactosEndpoint, detalleContactoEndpoint } from '../controllers/contactos.controller.js'
import {
listarContactosEndpoint,
detalleContactoEndpoint,
perfilCompletoEndpoint,
exportarContactosEndpoint,
} from '../controllers/contactos.controller.js'
import { webhookMonday } from '../controllers/webhooks.controller.js'
import {
correrRoundRobin,
@@ -80,7 +86,7 @@ import {
subirDocumento,
} from '../controllers/ai.controller.js'
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
import { resumenReportes } from '../controllers/reportes.controller.js'
import { resumenReportes, scoringEndpoint, moraEndpoint } from '../controllers/reportes.controller.js'
import { estadoSistema } from '../controllers/sistema.controller.js'
import {
requireAdmin,
@@ -95,6 +101,12 @@ import {
} from '../controllers/config.controller.js'
import { crearNotaEndpoint, listarNotasEndpoint } from '../controllers/notas.controller.js'
import { listarAuditoriaEndpoint } from '../controllers/auditoria.controller.js'
import {
crearRecordatorioEndpoint,
listarRecordatoriosEndpoint,
completarRecordatorioEndpoint,
eliminarRecordatorioEndpoint,
} from '../controllers/recordatorio-vendedor.controller.js'
import { buscarEndpoint } from '../controllers/buscar.controller.js'
import {
miTableroEndpoint,
@@ -176,6 +188,7 @@ apiRouter.post('/planes', crearPlan)
apiRouter.get('/planes', listarPlanes)
apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy)
apiRouter.get('/planes/:id', obtenerPlan)
apiRouter.get('/planes/:id/timeline', timelinePlan)
apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint)
apiRouter.post('/planes/:id/notas', agregarNota)
apiRouter.post('/planes/:id/cancelar', cancelarPlan)
@@ -184,6 +197,12 @@ apiRouter.patch('/planes/:id/monday', ligarMondayItem)
apiRouter.get('/cobranza/recordatorios', listarRecordatorios)
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)
// ── Recordatorios personales del vendedor ────────────────────────────
apiRouter.post('/recordatorios', crearRecordatorioEndpoint)
apiRouter.get('/recordatorios', listarRecordatoriosEndpoint)
apiRouter.patch('/recordatorios/:id/completar', completarRecordatorioEndpoint)
apiRouter.delete('/recordatorios/:id', eliminarRecordatorioEndpoint)
apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
// Cambio de etapa desde los botones de "Mis oportunidades" (Mi día)
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
@@ -200,7 +219,9 @@ apiRouter.get('/apartados/:id/notas', listarNotasEndpoint)
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
apiRouter.get('/contactos', listarContactosEndpoint)
apiRouter.get('/contactos/exportar', exportarContactosEndpoint)
apiRouter.get('/contactos/:id/detalle', detalleContactoEndpoint)
apiRouter.get('/contactos/:id/completo', perfilCompletoEndpoint)
// GET estático: va ANTES de /leads/:itemId para no colisionar con el parámetro
apiRouter.get('/leads/buscar', buscarLeadsEndpoint)
@@ -218,6 +239,8 @@ apiRouter.patch('/usuarios/me/monday', actualizarVinculoMondayEndpoint)
apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
apiRouter.get('/reportes/resumen', resumenReportes)
apiRouter.get('/reportes/scoring', scoringEndpoint)
apiRouter.get('/reportes/mora', moraEndpoint)
// Tablero personal del vendedor: métricas, conversión, ventas, origen, desarrollos
apiRouter.get('/reportes/mi-tablero', miTableroEndpoint)
// Métricas de uso (admin/coordinador): leads por usuario, tasa de descarte, actividades

View File

@@ -1,3 +1,5 @@
import { prisma } from './prisma.js'
import { calcularScore } from './lead-scoring.service.js'
import { config } from '../config/index.js'
import { detalleEventoUltimoCambio, esEstadoFinalApartado } from './mi-dia.service.js'
import type { MondayService } from './monday.service.js'
@@ -195,3 +197,167 @@ export async function detalleContacto(
historial,
}
}
// ── Perfil Completo del Contacto (modo local) ─────────────────────────
export interface PerfilCompletoContacto {
contacto: Record<string, string>
historialPagos: Array<{
planId: string
clienteNombre: string
unidadCodigo: string
proyecto: string
numPago: number
fechaVencimiento: string
monto: number
montoPagado: number
estado: string
}>
cotizaciones: Array<{
id: string
unidadCodigo: string
tipoPropuesta: string
precioFinal: number
createdAt: string
}>
documentos: Array<{
id: string
tipo: string
nombreArchivo: string
createdAt: string
}>
leadScore: number | null
ultimaActividad: {
titulo: string
fecha: string
tipo: string
} | null
}
/**
* Perfil completo de un contacto: combina datos del cliente/lead, historial
* de pagos, cotizaciones, documentos, lead score y última actividad local.
*/
export async function perfilCompleto(contactoId: string): Promise<PerfilCompletoContacto | null> {
// Buscar el cliente
const cliente = await prisma.cliente.findFirst({
where: { OR: [{ id: contactoId }, { mondayContactoId: contactoId }] },
})
// Buscar la captura del lead
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: contactoId } })
if (!cliente && !captura) return null
const telefono = cliente?.celular ?? captura?.telefono ?? ''
const email = (cliente?.email ?? captura?.email ?? '').trim().toLowerCase()
const nombre = cliente?.nombreCompleto ?? captura?.nombreLead ?? 'Sin nombre'
// Contacto fields
const contacto: Record<string, string> = {
nombre,
Teléfono: telefono,
Email: email,
}
if (cliente) {
contacto['CURP'] = cliente.curp
contacto['RFC'] = cliente.rfc
contacto['Proyecto'] = cliente.proyecto
}
if (captura) {
contacto['Etapa'] = captura.etapa
contacto['Origen'] = captura.origen ?? ''
}
// Historial de pagos (por teléfono o nombre del cliente)
const historialPagos: PerfilCompletoContacto['historialPagos'] = []
if (cliente) {
const planes = await prisma.planPago.findMany({
where: { clienteNombre: cliente.nombreCompleto },
include: { pagos: true },
})
for (const plan of planes) {
for (const pago of plan.pagos) {
const restante = pago.monto - pago.montoPagado
const estado = restante <= 0.01 ? 'pagado' : 'pendiente'
historialPagos.push({
planId: plan.id,
clienteNombre: plan.clienteNombre,
unidadCodigo: plan.unidadCodigo,
proyecto: plan.proyecto,
numPago: pago.numPago,
fechaVencimiento: pago.fechaVencimiento.toISOString().slice(0, 10),
monto: pago.monto,
montoPagado: pago.montoPagado,
estado,
})
}
}
}
// Cotizaciones (por teléfono)
const clave = cliente?.celular?.replace(/\D/g, '').slice(-10) ?? ''
const cotizacionesRaw = clave !== ''
? await prisma.cotizacion.findMany({
where: { clienteTelefono: { contains: clave } },
orderBy: { createdAt: 'desc' },
})
: []
// Documentos
const documentosRaw = cliente
? await prisma.documento.findMany({
where: { clienteId: cliente.id },
orderBy: { createdAt: 'desc' },
})
: []
// Lead score
let leadScore: number | null = null
if (captura) {
leadScore = calcularScore({
etapa: captura.etapa,
telefono: captura.telefono,
email: captura.email,
origen: captura.origen,
createdAt: captura.createdAt,
updatedAt: captura.updatedAt,
})
}
// Última actividad
let ultimaActividad: PerfilCompletoContacto['ultimaActividad'] = null
if (captura) {
const ultima = await prisma.actividadLocal.findFirst({
where: { leadCapturaId: captura.leadId },
orderBy: { createdAt: 'desc' },
})
if (ultima) {
ultimaActividad = {
titulo: ultima.titulo,
fecha: ultima.createdAt.toISOString(),
tipo: ultima.tipo,
}
}
}
return {
contacto,
historialPagos,
cotizaciones: cotizacionesRaw.map((c) => ({
id: c.id,
unidadCodigo: c.unidadCodigo,
tipoPropuesta: c.tipoPropuesta,
precioFinal: c.precioFinal,
createdAt: c.createdAt.toISOString(),
})),
documentos: documentosRaw.map((d) => ({
id: d.id,
tipo: d.tipo,
nombreArchivo: d.nombreArchivo,
createdAt: d.createdAt.toISOString(),
})),
leadScore,
ultimaActividad,
}
}

View File

@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
cliente: { findMany: vi.fn() },
leadCaptura: { findMany: vi.fn() },
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { exportarCSV } from './contactos-local.service.js'
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findMany.mockResolvedValue([])
})
describe('exportarCSV', () => {
it('genera CSV con encabezados cuando no hay contactos', async () => {
const csv = await exportarCSV()
expect(csv).toContain('Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad')
})
it('incluye clientes y capturas en el CSV', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
{
nombreCompleto: 'Juan Pérez',
celular: '4771234567',
email: 'juan@correo.mx',
proyecto: 'SENDA',
createdAt: new Date('2026-09-01'),
},
])
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
nombreLead: 'Ana López',
telefono: '6649998877',
email: 'ana@test.com',
desarrollo: 'DIAGONAL',
etapa: 'NUEVO',
updatedAt: new Date('2026-09-05'),
},
])
const csv = await exportarCSV()
const lineas = csv.split('\n')
expect(lineas).toHaveLength(3) // header + 2 rows
expect(lineas[1]).toContain('Juan Pérez')
expect(lineas[1]).toContain('Cliente')
expect(lineas[2]).toContain('Ana López')
expect(lineas[2]).toContain('NUEVO')
})
it('filtra por desarrollo', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
{ nombreCompleto: 'A', celular: null, email: null, proyecto: 'SENDA', createdAt: new Date() },
{ nombreCompleto: 'B', celular: null, email: null, proyecto: 'DIAGONAL', createdAt: new Date() },
])
prismaMock.leadCaptura.findMany.mockResolvedValue([])
const csv = await exportarCSV({ desarrollo: 'SENDA' })
expect(csv).toContain('A')
expect(csv).not.toContain('B')
})
it('escapa comas y comillas en los campos', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
{
nombreCompleto: 'Pérez, Juan',
celular: null,
email: null,
proyecto: 'SENDA',
createdAt: new Date(),
},
])
prismaMock.leadCaptura.findMany.mockResolvedValue([])
const csv = await exportarCSV()
expect(csv).toContain('"Pérez, Juan"')
})
})

View File

@@ -209,3 +209,79 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
return { contacto, propiedad, relaciones, historial }
}
// ── Exportar contactos a CSV ─────────────────────────────────────────
/**
* Genera CSV de todos los contactos (Cliente + LeadCaptura).
* Columnas: Nombre, Teléfono, Email, Desarrollo, Estatus, Última actividad.
*/
export async function exportarCSV(filtros: { desarrollo?: string; estatus?: string } = {}): Promise<string> {
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' } })
const capturas = await prisma.leadCaptura.findMany({
where: {
board: { in: ['OPORTUNIDADES', 'APARTADO', 'LEADS'] },
},
orderBy: { updatedAt: 'desc' },
})
interface FilaCSV {
Nombre: string
Teléfono: string
Email: string
Desarrollo: string
Estatus: string
'Última actividad': string
}
const filas: FilaCSV[] = []
const vistos = new Set<string>()
for (const c of clientes) {
const clave = (c.celular ?? '').replace(/\D/g, '').slice(-10)
if (clave !== '' && vistos.has(clave)) continue
if (clave !== '') vistos.add(clave)
filas.push({
Nombre: c.nombreCompleto,
Teléfono: c.celular ?? '',
Email: c.email ?? '',
Desarrollo: c.proyecto,
Estatus: 'Cliente',
'Última actividad': c.createdAt.toISOString().slice(0, 10),
})
}
for (const cap of capturas) {
const clave = (cap.telefono ?? '').replace(/\D/g, '').slice(-10)
if (clave !== '' && vistos.has(clave)) continue
if (clave !== '') vistos.add(clave)
filas.push({
Nombre: cap.nombreLead,
Teléfono: cap.telefono ?? '',
Email: cap.email ?? '',
Desarrollo: cap.desarrollo ?? '',
Estatus: cap.etapa,
'Última actividad': cap.updatedAt.toISOString().slice(0, 10),
})
}
// Aplicar filtros
let filtradas = filas
if (filtros.desarrollo) {
const d = filtros.desarrollo.toUpperCase()
filtradas = filtradas.filter((f) => f.Desarrollo.toUpperCase().includes(d))
}
if (filtros.estatus) {
const e = filtros.estatus.toUpperCase()
filtradas = filtradas.filter((f) => f.Estatus.toUpperCase().includes(e))
}
// Generar CSV
const encabezado = 'Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad'
const escape = (s: string) => (s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s)
const lineas = filtradas.map((f) =>
[f.Nombre, f.Teléfono, f.Email, f.Desarrollo, f.Estatus, f['Última actividad']].map(escape).join(',')
)
return [encabezado, ...lineas].join('\n')
}

View File

@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
cliente: { findFirst: vi.fn() },
leadCaptura: { findUnique: vi.fn() },
planPago: { findMany: vi.fn() },
cotizacion: { findMany: vi.fn() },
documento: { findMany: vi.fn() },
actividadLocal: { findFirst: vi.fn() },
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
// Mock lead-scoring to avoid circular dependency
vi.mock('../services/lead-scoring.service.js', () => ({
calcularScore: vi.fn(() => 75),
}))
import { perfilCompleto } from './contactos-detalle.service.js'
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.planPago.findMany.mockResolvedValue([])
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.documento.findMany.mockResolvedValue([])
prismaMock.actividadLocal.findFirst.mockResolvedValue(null)
})
describe('perfilCompleto', () => {
it('devuelve null si el contacto no existe', async () => {
const result = await perfilCompleto('no-existe')
expect(result).toBeNull()
})
it('arma perfil completo desde Cliente', async () => {
prismaMock.cliente.findFirst.mockResolvedValue({
id: 'c1',
nombreCompleto: 'Juan Pérez',
curp: 'CURP123',
rfc: 'RFC123',
email: 'juan@test.com',
celular: '4771234567',
proyecto: 'SENDA',
unidadCodigo: 'SENDA-101',
mondayContactoId: null,
createdAt: new Date('2026-09-01'),
})
prismaMock.planPago.findMany.mockResolvedValue([
{
id: 'plan1',
clienteNombre: 'Juan Pérez',
unidadCodigo: 'SENDA-101',
proyecto: 'SENDA',
pagos: [
{ numPago: 1, fechaVencimiento: new Date('2026-10-01'), monto: 5000, montoPagado: 0 },
],
},
])
prismaMock.documento.findMany.mockResolvedValue([
{ id: 'd1', tipo: 'INE', nombreArchivo: 'ine.pdf', createdAt: new Date('2026-09-02') },
])
const result = await perfilCompleto('c1')
expect(result).not.toBeNull()
expect(result?.contacto['nombre']).toBe('Juan Pérez')
expect(result?.contacto['CURP']).toBe('CURP123')
expect(result?.historialPagos).toHaveLength(1)
expect(result?.historialPagos[0]?.estado).toBe('pendiente')
expect(result?.documentos).toHaveLength(1)
expect(result?.documentos[0]?.tipo).toBe('INE')
})
it('arma perfil desde LeadCaptura cuando no hay Cliente', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_1',
nombreLead: 'Ana López',
telefono: '6649998877',
email: 'ana@test.com',
origen: 'Facebook',
etapa: 'INTERESADO',
createdAt: new Date('2026-09-05'),
updatedAt: new Date('2026-09-07'),
})
prismaMock.actividadLocal.findFirst.mockResolvedValue({
titulo: 'Llamar a Ana',
tipo: 'SEGUIMIENTO',
createdAt: new Date('2026-09-07'),
})
const result = await perfilCompleto('loc_1')
expect(result).not.toBeNull()
expect(result?.contacto['nombre']).toBe('Ana López')
expect(result?.contacto['Etapa']).toBe('INTERESADO')
expect(result?.leadScore).toBe(75)
expect(result?.ultimaActividad?.titulo).toBe('Llamar a Ana')
})
it('cotizaciones se incluyen cuando hay teléfono', async () => {
prismaMock.cliente.findFirst.mockResolvedValue({
id: 'c1',
nombreCompleto: 'Test',
curp: '',
rfc: '',
email: null,
celular: '4771234567',
proyecto: 'A',
unidadCodigo: null,
mondayContactoId: null,
createdAt: new Date(),
})
prismaMock.cotizacion.findMany.mockResolvedValue([
{
id: 'cot1',
unidadCodigo: 'U-101',
tipoPropuesta: 'BANCARIO',
precioFinal: 3000000,
createdAt: new Date('2026-09-01'),
},
])
const result = await perfilCompleto('c1')
expect(result?.cotizaciones).toHaveLength(1)
expect(result?.cotizaciones[0]?.tipoPropuesta).toBe('BANCARIO')
})
})

View File

@@ -0,0 +1,143 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
findMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { calcularScore, obtenerScores } from './lead-scoring.service.js'
describe('calcularScore', () => {
const ahora = new Date('2026-09-08T12:00:00Z')
it('asigna score base según etapa (NUEVO = 20)', () => {
const lead = {
etapa: 'NUEVO',
telefono: null,
email: null,
origen: null,
createdAt: new Date('2026-09-07T12:00:00Z'),
updatedAt: new Date('2026-09-07T12:00:00Z'),
}
// 20 (etapa) + 5 (recencia <7d) = 25
expect(calcularScore(lead, ahora)).toBe(25)
})
it('asigna score CALIFICADO (90)', () => {
const lead = {
etapa: 'CALIFICADO',
telefono: '4771234567',
email: 'test@email.com',
origen: 'Facebook',
createdAt: new Date('2026-09-08T00:00:00Z'),
updatedAt: new Date('2026-09-08T00:00:00Z'),
}
// 90 + 10 (<24h) + 5 tel + 5 email + 5 origen = 115 → clamp 100
expect(calcularScore(lead, ahora)).toBe(100)
})
it('aplica penalización >30 días sin actividad', () => {
const lead = {
etapa: 'CONTACTADO',
telefono: null,
email: null,
origen: null,
createdAt: new Date('2026-07-01T00:00:00Z'),
updatedAt: new Date('2026-07-01T00:00:00Z'),
}
// 40 (CONTACTADO) + (-20) (>30d) = 20
expect(calcularScore(lead, ahora)).toBe(20)
})
it('score mínimo es 0', () => {
const lead = {
etapa: 'DESCARTADO',
telefono: null,
email: null,
origen: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
}
// 10 (desconocida) + (-20) = -10 → clamp 0
expect(calcularScore(lead, ahora)).toBe(0)
})
it('recencia <24h suma +10', () => {
const lead = {
etapa: 'NUEVO',
telefono: '1234567890',
email: 'a@b.com',
origen: 'Web',
createdAt: new Date('2026-09-08T10:00:00Z'),
updatedAt: new Date('2026-09-08T10:00:00Z'),
}
// 20 + 10 + 5 + 5 + 5 = 45
expect(calcularScore(lead, ahora)).toBe(45)
})
it('INTERESADO = 60 + recencia <7d = +5 = 65', () => {
const lead = {
etapa: 'INTERESADO',
telefono: null,
email: null,
origen: null,
createdAt: new Date('2026-09-05T12:00:00Z'),
updatedAt: new Date('2026-09-05T12:00:00Z'),
}
expect(calcularScore(lead, ahora)).toBe(65)
})
})
describe('obtenerScores', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('devuelve scores ordenados de mayor a menor', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
leadId: 'l1',
nombreLead: 'A',
usuarioId: 'u1',
etapa: 'NUEVO',
telefono: null,
email: null,
origen: null,
createdAt: new Date('2026-09-07'),
updatedAt: new Date('2026-09-07'),
},
{
leadId: 'l2',
nombreLead: 'B',
usuarioId: 'u1',
etapa: 'CALIFICADO',
telefono: '1234567890',
email: 'b@b.com',
origen: 'Web',
createdAt: new Date('2026-09-08'),
updatedAt: new Date('2026-09-08'),
},
])
const scores = await obtenerScores()
expect(scores).toHaveLength(2)
expect(scores[0]!.leadId).toBe('l2')
expect(scores[0]!.score).toBeGreaterThan(scores[1]!.score)
})
it('filtra por usuarioId cuando se proporciona', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([])
await obtenerScores('u1')
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith({
where: {
board: { in: ['LEADS', 'OPORTUNIDADES'] },
usuarioId: 'u1',
},
})
})
})

View File

@@ -0,0 +1,107 @@
import { prisma } from './prisma.js'
/**
* Lead Scoring: asigna un score 0-100 a cada LeadCaptura basado en:
* - Etapa del pipeline (NUEVO=20, CONTACTADO=40, INTERESADO=60, AGENDO=80, CALIFICADO=90)
* - Recencia (último cambio/creación): <24h +10, <7d +5, >30d -20
* - Datos de contacto: tiene teléfono +5, email +5
* - Origen conocido (no vacío): +5
*/
const ETAPA_SCORE: Record<string, number> = {
NUEVO: 20,
CONTACTADO: 40,
INTERESADO: 60,
AGENDO: 80,
CALIFICADO: 90,
}
export interface LeadScore {
leadId: string
nombreLead: string
usuarioId: string
etapa: string
telefono: string | null
email: string | null
origen: string | null
score: number
}
/**
* Calcula el score 0-100 de un lead dado su registro de LeadCaptura.
* Función pura (testable sin BD).
*/
export function calcularScore(lead: {
etapa: string
telefono: string | null
email: string | null
origen: string | null
createdAt: Date
updatedAt: Date
}, ahora: Date = new Date()): number {
let score = 0
// 1. Etapa
const etapaNorm = (lead.etapa ?? '').trim().toUpperCase()
score += ETAPA_SCORE[etapaNorm] ?? 10
// 2. Recencia (basado en updatedAt como proxy de "última actividad")
const diffMs = ahora.getTime() - lead.updatedAt.getTime()
const diffHoras = diffMs / (1000 * 60 * 60)
if (diffHoras < 24) {
score += 10
} else if (diffHoras < 7 * 24) {
score += 5
} else if (diffHoras > 30 * 24) {
score -= 20
}
// 3. Datos de contacto
if (lead.telefono !== null && lead.telefono.trim() !== '') score += 5
if (lead.email !== null && lead.email.trim() !== '') score += 5
// 4. Origen conocido
if (lead.origen !== null && lead.origen.trim() !== '') score += 5
// Clamp 0-100
return Math.max(0, Math.min(100, score))
}
/**
* Obtiene los scores de todos los leads activos (no descartados), opcionalmente
* filtrados por usuarioId.
*/
export async function obtenerScores(usuarioId?: string): Promise<LeadScore[]> {
const where: Record<string, unknown> = {
board: { in: ['LEADS', 'OPORTUNIDADES'] },
}
if (usuarioId) {
where['usuarioId'] = usuarioId
}
const leads = await prisma.leadCaptura.findMany({ where })
const ahora = new Date()
return leads
.map((lead) => ({
leadId: lead.leadId,
nombreLead: lead.nombreLead,
usuarioId: lead.usuarioId,
etapa: lead.etapa,
telefono: lead.telefono,
email: lead.email,
origen: lead.origen,
score: calcularScore(
{
etapa: lead.etapa,
telefono: lead.telefono,
email: lead.email,
origen: lead.origen,
createdAt: lead.createdAt,
updatedAt: lead.updatedAt,
},
ahora,
),
}))
.sort((a, b) => b.score - a.score)
}

View File

@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
planPago: {
findMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { resumenMora } from './mora.service.js'
beforeEach(() => {
vi.clearAllMocks()
})
describe('resumenMora', () => {
it('devuelve 0 cuando no hay pagos vencidos', async () => {
prismaMock.planPago.findMany.mockResolvedValue([
{
clienteNombre: 'Juan',
unidadCodigo: 'U-101',
proyecto: 'SENDA',
clienteTelefono: '4771234567',
id: 'p1',
pagos: [
{ numPago: 1, fechaVencimiento: new Date('2026-12-01'), monto: 10000, montoPagado: 0 },
],
},
])
const result = await resumenMora()
expect(result.clientesConMora).toBe(0)
expect(result.montoTotalVencido).toBe(0)
expect(result.clientes).toHaveLength(0)
})
it('detecta pagos vencidos con monto restante', async () => {
prismaMock.planPago.findMany.mockResolvedValue([
{
clienteNombre: 'Juan Pérez',
unidadCodigo: 'SENDA-101',
proyecto: 'SENDA',
clienteTelefono: '4771234567',
id: 'plan1',
pagos: [
{
numPago: 1,
fechaVencimiento: new Date('2026-08-01'), // vencido
monto: 5000,
montoPagado: 2000, // parcial: restante = 3000
},
{
numPago: 2,
fechaVencimiento: new Date('2026-09-01'), // vencido
monto: 5000,
montoPagado: 0, // sin pagar
},
],
},
])
const result = await resumenMora()
expect(result.clientesConMora).toBe(2)
expect(result.montoTotalVencido).toBe(8000)
expect(result.diasPromedioAtraso).toBeGreaterThan(0)
expect(result.clientes[0]?.clienteNombre).toBe('Juan Pérez')
})
it('ordena por días de atraso (mayor primero)', async () => {
prismaMock.planPago.findMany.mockResolvedValue([
{
clienteNombre: 'Corto',
unidadCodigo: 'U-1',
proyecto: 'A',
clienteTelefono: null,
id: 'p1',
pagos: [
{ numPago: 1, fechaVencimiento: new Date('2026-08-25'), monto: 1000, montoPagado: 0 },
],
},
{
clienteNombre: 'Largo',
unidadCodigo: 'U-2',
proyecto: 'B',
clienteTelefono: null,
id: 'p2',
pagos: [
{ numPago: 1, fechaVencimiento: new Date('2026-07-01'), monto: 2000, montoPagado: 0 },
],
},
])
const result = await resumenMora()
expect(result.clientes[0]?.clienteNombre).toBe('Largo')
expect(result.clientes[0]?.diasAtraso).toBeGreaterThan(result.clientes[1]!.diasAtraso)
})
it('ignora pagos totalmente cubiertos', async () => {
prismaMock.planPago.findMany.mockResolvedValue([
{
clienteNombre: 'Cliente',
unidadCodigo: 'U-1',
proyecto: 'A',
clienteTelefono: null,
id: 'p1',
pagos: [
{ numPago: 1, fechaVencimiento: new Date('2026-08-01'), monto: 5000, montoPagado: 5000 },
],
},
])
const result = await resumenMora()
expect(result.clientesConMora).toBe(0)
})
it('ignora pagos cuyo vencimiento es futuro', async () => {
prismaMock.planPago.findMany.mockResolvedValue([
{
clienteNombre: 'Cliente',
unidadCodigo: 'U-1',
proyecto: 'A',
clienteTelefono: null,
id: 'p1',
pagos: [
{ numPago: 1, fechaVencimiento: new Date('2026-12-01'), monto: 5000, montoPagado: 0 },
],
},
])
const result = await resumenMora()
expect(result.clientesConMora).toBe(0)
})
it('excluye planes cancelados', async () => {
prismaMock.planPago.findMany.mockResolvedValue([
{
clienteNombre: 'Cancelado',
unidadCodigo: 'U-1',
proyecto: 'A',
clienteTelefono: null,
id: 'p1',
pagos: [
{ numPago: 1, fechaVencimiento: new Date('2026-08-01'), monto: 5000, montoPagado: 0 },
],
status: 'CANCELADO',
},
])
// The where clause filters out CANCELADO before they reach the loop
prismaMock.planPago.findMany.mockResolvedValue([])
const result = await resumenMora()
expect(result.clientesConMora).toBe(0)
})
})

View File

@@ -0,0 +1,89 @@
import { prisma } from './prisma.js'
/**
* Dashboard de mora: clientes con pagos vencidos (fechaVencimiento < hoy
* y montoPagado < monto). Devuelve resumen + detalle por cliente.
*/
export interface ClienteMora {
clienteNombre: string
unidadCodigo: string
proyecto: string
pagoVencido: number
monto: number
diasAtraso: number
telefono: string | null
planId: string
}
export interface ResumenMora {
clientesConMora: number
montoTotalVencido: number
diasPromedioAtraso: number
clientes: ClienteMora[]
}
/**
* Calcula días naturales de diferencia entre hoy y la fecha de vencimiento.
*/
function diasDesde(fechaVencimiento: Date, hoy: Date): number {
const hv = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
const fv = new Date(
fechaVencimiento.getFullYear(),
fechaVencimiento.getMonth(),
fechaVencimiento.getDate(),
)
return Math.max(0, Math.round((hv.getTime() - fv.getTime()) / 86_400_000))
}
/**
* Resumen de mora: todos los pagos vencidos de planes activos.
*/
export async function resumenMora(): Promise<ResumenMora> {
const hoy = new Date()
const inicioHoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
const planes = await prisma.planPago.findMany({
where: { status: { not: 'CANCELADO' } },
include: { pagos: true },
})
const clientes: ClienteMora[] = []
for (const plan of planes) {
for (const pago of plan.pagos) {
const restante = pago.monto - pago.montoPagado
if (restante <= 0) continue // ya pagado
const fv = pago.fechaVencimiento
if (fv >= inicioHoy) continue // aún no vence
const dias = diasDesde(fv, hoy)
clientes.push({
clienteNombre: plan.clienteNombre,
unidadCodigo: plan.unidadCodigo,
proyecto: plan.proyecto,
pagoVencido: pago.numPago,
monto: Math.round(restante * 100) / 100,
diasAtraso: dias,
telefono: plan.clienteTelefono,
planId: plan.id,
})
}
}
// Ordenar por días de atraso (mayor primero)
clientes.sort((a, b) => b.diasAtraso - a.diasAtraso)
const montoTotalVencido = Math.round(clientes.reduce((acc, c) => acc + c.monto, 0) * 100) / 100
const diasPromedioAtraso =
clientes.length > 0
? Math.round(clientes.reduce((acc, c) => acc + c.diasAtraso, 0) / clientes.length)
: 0
return {
clientesConMora: clientes.length,
montoTotalVencido,
diasPromedioAtraso,
clientes,
}
}

View File

@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
recordatorioVendedor: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { RecordatorioVendedorService } from './recordatorio-vendedor.service.js'
const svc = new RecordatorioVendedorService()
beforeEach(() => {
vi.clearAllMocks()
})
describe('RecordatorioVendedorService', () => {
const usuarioId = 'u-1'
describe('crear', () => {
it('crea un recordatorio con los datos proporcionados', async () => {
prismaMock.recordatorioVendedor.create.mockResolvedValue({
id: 'r1',
usuarioId,
titulo: 'Llamar a Juan',
descripcion: 'Sobre cotización',
leadId: null,
fechaRecordar: new Date('2026-09-10T10:00:00'),
completado: false,
createdAt: new Date(),
updatedAt: new Date(),
})
const result = await svc.crear(usuarioId, {
titulo: 'Llamar a Juan',
descripcion: 'Sobre cotización',
leadId: null,
fechaRecordar: '2026-09-10T10:00:00',
})
expect(result.id).toBe('r1')
expect(prismaMock.recordatorioVendedor.create).toHaveBeenCalledWith({
data: {
usuarioId,
titulo: 'Llamar a Juan',
descripcion: 'Sobre cotización',
leadId: null,
fechaRecordar: new Date('2026-09-10T10:00:00'),
},
})
})
})
describe('listar', () => {
it('lista recordatorios del usuario ordenados por fechaRecordar asc', async () => {
prismaMock.recordatorioVendedor.findMany.mockResolvedValue([
{ id: 'r1', titulo: 'A', fechaRecordar: new Date('2026-09-10') },
{ id: 'r2', titulo: 'B', fechaRecordar: new Date('2026-09-12') },
])
const result = await svc.listar(usuarioId)
expect(result).toHaveLength(2)
expect(prismaMock.recordatorioVendedor.findMany).toHaveBeenCalledWith({
where: { usuarioId },
orderBy: { fechaRecordar: 'asc' },
})
})
it('filtra por completados cuando se especifica', async () => {
prismaMock.recordatorioVendedor.findMany.mockResolvedValue([])
await svc.listar(usuarioId, { completados: true })
expect(prismaMock.recordatorioVendedor.findMany).toHaveBeenCalledWith({
where: { usuarioId, completado: true },
orderBy: { fechaRecordar: 'asc' },
})
})
})
describe('completar', () => {
it('marca como completado un recordatorio existente', async () => {
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1' })
prismaMock.recordatorioVendedor.update.mockResolvedValue({ id: 'r1', completado: true })
const result = await svc.completar('r1')
expect(result.completado).toBe(true)
expect(prismaMock.recordatorioVendedor.update).toHaveBeenCalledWith({
where: { id: 'r1' },
data: { completado: true },
})
})
it('lanza error si el recordatorio no existe', async () => {
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue(null)
await expect(svc.completar('no-existe')).rejects.toThrow('Recordatorio no encontrado')
})
})
describe('eliminar', () => {
it('elimina un recordatorio existente', async () => {
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1' })
prismaMock.recordatorioVendedor.delete.mockResolvedValue({ id: 'r1' })
await svc.eliminar('r1')
expect(prismaMock.recordatorioVendedor.delete).toHaveBeenCalledWith({ where: { id: 'r1' } })
})
it('lanza error si el recordatorio no existe', async () => {
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue(null)
await expect(svc.eliminar('no-existe')).rejects.toThrow('Recordatorio no encontrado')
})
})
describe('proximos', () => {
it('devuelve recordatorios no completados en las próximas N horas', async () => {
prismaMock.recordatorioVendedor.findMany.mockResolvedValue([{ id: 'r1' }])
const result = await svc.proximos(usuarioId, 24)
expect(result).toHaveLength(1)
const where = prismaMock.recordatorioVendedor.findMany.mock.calls[0]?.[0]?.where
expect(where?.['usuarioId']).toBe(usuarioId)
expect(where?.['completado']).toBe(false)
})
})
})

View File

@@ -0,0 +1,91 @@
import { prisma } from './prisma.js'
import { NotFoundError } from '../utils/errors.js'
/**
* CRUD de recordatorios personales del vendedor (tabla RecordatorioVendedor).
* Cada vendedor crea recordatorios con fecha/hora a recordar; el front los
* muestra en "Mis recordatorios" de Mi día.
*/
export interface DatosCrearRecordatorio {
titulo: string
descripcion?: string | null
leadId?: string | null
fechaRecordar: string // ISO o YYYY-MM-DDTHH:mm
}
export interface OpcionesListarRecordatorio {
completados?: boolean
desde?: string | null // ISO
hasta?: string | null // ISO
}
export class RecordatorioVendedorService {
async crear(usuarioId: string, data: DatosCrearRecordatorio) {
return prisma.recordatorioVendedor.create({
data: {
usuarioId,
titulo: data.titulo.trim(),
descripcion: data.descripcion?.trim() ?? null,
leadId: data.leadId ?? null,
fechaRecordar: new Date(data.fechaRecordar),
},
})
}
async listar(usuarioId: string, opciones: OpcionesListarRecordatorio = {}) {
const where: Record<string, unknown> = { usuarioId }
if (opciones.completados !== undefined) {
where['completado'] = opciones.completados
}
if (opciones.desde !== null && opciones.desde !== undefined) {
where['fechaRecordar'] = where['fechaRecordar'] ?? {}
;(where['fechaRecordar'] as Record<string, Date>)['gte'] = new Date(opciones.desde)
}
if (opciones.hasta !== null && opciones.hasta !== undefined) {
where['fechaRecordar'] = where['fechaRecordar'] ?? {}
;(where['fechaRecordar'] as Record<string, Date>)['lte'] = new Date(opciones.hasta)
}
return prisma.recordatorioVendedor.findMany({
where,
orderBy: { fechaRecordar: 'asc' },
})
}
async completar(id: string) {
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
if (!existente) throw new NotFoundError('Recordatorio no encontrado')
return prisma.recordatorioVendedor.update({
where: { id },
data: { completado: true },
})
}
async eliminar(id: string) {
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
if (!existente) throw new NotFoundError('Recordatorio no encontrado')
return prisma.recordatorioVendedor.delete({ where: { id } })
}
/**
* Próximos a vencer: recordatorios no completados cuya fechaRecordar está
* dentro de las próximas `horas` horas (default 48h).
*/
async proximos(usuarioId: string, horas = 48) {
const desde = new Date()
const hasta = new Date(desde.getTime() + horas * 60 * 60 * 1000)
return prisma.recordatorioVendedor.findMany({
where: {
usuarioId,
completado: false,
fechaRecordar: { gte: desde, lte: hasta },
},
orderBy: { fechaRecordar: 'asc' },
})
}
}
export const recordatorioVendedorService = new RecordatorioVendedorService()