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:
@@ -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)
|
||||
}
|
||||
|
||||
65
api-gateway/src/controllers/planes-timeline.test.ts
Normal file
65
api-gateway/src/controllers/planes-timeline.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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']))
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
107
api-gateway/src/controllers/recordatorio-vendedor.controller.ts
Normal file
107
api-gateway/src/controllers/recordatorio-vendedor.controller.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user