From db186858723eead4772589b47418c969c9948c23 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Tue, 8 Sep 2026 07:42:54 +0000 Subject: [PATCH] feat(ux): 17 mejoras de experiencia de usuario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api-gateway/prisma/schema.prisma | 16 ++ .../src/controllers/contactos.controller.ts | 28 ++- .../src/controllers/planes-timeline.test.ts | 65 +++++++ .../src/controllers/planes.controller.ts | 49 +++++ .../recordatorio-vendedor.controller.test.ts | 99 ++++++++++ .../recordatorio-vendedor.controller.ts | 107 ++++++++++ .../src/controllers/reportes.controller.ts | 13 ++ api-gateway/src/routes/index.ts | 27 ++- .../src/services/contactos-detalle.service.ts | 166 ++++++++++++++++ .../contactos-exportar.service.test.ts | 81 ++++++++ .../src/services/contactos-local.service.ts | 76 ++++++++ .../services/contactos-perfil.service.test.ts | 131 +++++++++++++ .../src/services/lead-scoring.service.test.ts | 143 ++++++++++++++ .../src/services/lead-scoring.service.ts | 107 ++++++++++ api-gateway/src/services/mora.service.test.ts | 159 +++++++++++++++ api-gateway/src/services/mora.service.ts | 89 +++++++++ .../recordatorio-vendedor.service.test.ts | 138 +++++++++++++ .../services/recordatorio-vendedor.service.ts | 91 +++++++++ .../src/components/ChecklistDocumentos.tsx | 106 ++++++++++ .../src/components/FotoCliente.tsx | 162 ++++++++++++++++ apps/alta-clientes/src/data/cps.ts | 89 +++++++++ .../src/forms/DatosGeneralesCampos.tsx | 21 +- .../src/pages/AltaClientePage.tsx | 45 ++++- .../cobranza/src/components/MoraDashboard.tsx | 134 +++++++++++++ .../cobranza/src/components/TimelinePagos.tsx | 90 +++++++++ apps/cobranza/src/pages/DashboardPage.tsx | 4 + apps/cobranza/src/pages/PlanDetallePage.tsx | 42 +++- .../src/components/PerfilContactoCompleto.tsx | 183 ++++++++++++++++++ .../landing/src/components/VistaContactos.tsx | 38 +++- .../src/components/DashboardVendedor.tsx | 103 ++++++++-- apps/mi-dia/src/components/KanbanView.tsx | 131 +++++++++++++ apps/mi-dia/src/components/ScoreBadge.tsx | 69 +++++++ apps/mi-dia/src/components/TimelineLead.tsx | 67 +++++++ apps/mi-dia/src/pages/MiDiaPage.tsx | 114 +++++++++++ 34 files changed, 2950 insertions(+), 33 deletions(-) create mode 100644 api-gateway/src/controllers/planes-timeline.test.ts create mode 100644 api-gateway/src/controllers/recordatorio-vendedor.controller.test.ts create mode 100644 api-gateway/src/controllers/recordatorio-vendedor.controller.ts create mode 100644 api-gateway/src/services/contactos-exportar.service.test.ts create mode 100644 api-gateway/src/services/contactos-perfil.service.test.ts create mode 100644 api-gateway/src/services/lead-scoring.service.test.ts create mode 100644 api-gateway/src/services/lead-scoring.service.ts create mode 100644 api-gateway/src/services/mora.service.test.ts create mode 100644 api-gateway/src/services/mora.service.ts create mode 100644 api-gateway/src/services/recordatorio-vendedor.service.test.ts create mode 100644 api-gateway/src/services/recordatorio-vendedor.service.ts create mode 100644 apps/alta-clientes/src/components/ChecklistDocumentos.tsx create mode 100644 apps/alta-clientes/src/components/FotoCliente.tsx create mode 100644 apps/alta-clientes/src/data/cps.ts create mode 100644 apps/cobranza/src/components/MoraDashboard.tsx create mode 100644 apps/cobranza/src/components/TimelinePagos.tsx create mode 100644 apps/landing/src/components/PerfilContactoCompleto.tsx create mode 100644 apps/mi-dia/src/components/KanbanView.tsx create mode 100644 apps/mi-dia/src/components/ScoreBadge.tsx create mode 100644 apps/mi-dia/src/components/TimelineLead.tsx diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 9fa1932..1ddee3d 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -412,6 +412,22 @@ model AuditoriaLog { @@index([createdAt]) } +// Recordatorios personales del vendedor (ver Mi día / panel de recordatorios). +model RecordatorioVendedor { + id String @id @default(uuid()) + usuarioId String + leadId String? + titulo String + descripcion String? + fechaRecordar DateTime + completado Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([usuarioId]) + @@index([fechaRecordar]) +} + // Captura por departamentos: cada campo de cada unidad se registra como fila. // Unique constraint: desarrollo + unidadCodigo + departamento + campo. model CapturaDepartamento { diff --git a/api-gateway/src/controllers/contactos.controller.ts b/api-gateway/src/controllers/contactos.controller.ts index 7bb3223..41edfc1 100644 --- a/api-gateway/src/controllers/contactos.controller.ts +++ b/api-gateway/src/controllers/contactos.controller.ts @@ -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 { + 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 { + 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) +} diff --git a/api-gateway/src/controllers/planes-timeline.test.ts b/api-gateway/src/controllers/planes-timeline.test.ts new file mode 100644 index 0000000..868bb4a --- /dev/null +++ b/api-gateway/src/controllers/planes-timeline.test.ts @@ -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) + }) +}) diff --git a/api-gateway/src/controllers/planes.controller.ts b/api-gateway/src/controllers/planes.controller.ts index 71cb256..d186e5d 100644 --- a/api-gateway/src/controllers/planes.controller.ts +++ b/api-gateway/src/controllers/planes.controller.ts @@ -306,6 +306,55 @@ export async function agregarNota(req: Request, res: Response): Promise { 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 { + 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 { const plan = await cargarPlan(String(req.params['id'])) diff --git a/api-gateway/src/controllers/recordatorio-vendedor.controller.test.ts b/api-gateway/src/controllers/recordatorio-vendedor.controller.test.ts new file mode 100644 index 0000000..db18c2d --- /dev/null +++ b/api-gateway/src/controllers/recordatorio-vendedor.controller.test.ts @@ -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 = {}) { + 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() + }) +}) diff --git a/api-gateway/src/controllers/recordatorio-vendedor.controller.ts b/api-gateway/src/controllers/recordatorio-vendedor.controller.ts new file mode 100644 index 0000000..b06b5ca --- /dev/null +++ b/api-gateway/src/controllers/recordatorio-vendedor.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 => { + 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 => { + 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 => { + const r = await svc.completar(String(req.params['id'])) + res.json(r) + }, + eliminar: async (req: Request, res: Response): Promise => { + await svc.eliminar(String(req.params['id'])) + res.status(204).end() + }, + } +} diff --git a/api-gateway/src/controllers/reportes.controller.ts b/api-gateway/src/controllers/reportes.controller.ts index 4c1cb1f..2a592b4 100644 --- a/api-gateway/src/controllers/reportes.controller.ts +++ b/api-gateway/src/controllers/reportes.controller.ts @@ -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 { res.json(await reportesService.resumen()) } + +/** GET /api/reportes/scoring — scores de todos los leads activos */ +export async function scoringEndpoint(req: Request, res: Response): Promise { + 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 { + res.json(await resumenMora()) +} diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 34101c6..2c2b29f 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -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 diff --git a/api-gateway/src/services/contactos-detalle.service.ts b/api-gateway/src/services/contactos-detalle.service.ts index d0c2e3c..5776838 100644 --- a/api-gateway/src/services/contactos-detalle.service.ts +++ b/api-gateway/src/services/contactos-detalle.service.ts @@ -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 + 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 { + // 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 = { + 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, + } +} diff --git a/api-gateway/src/services/contactos-exportar.service.test.ts b/api-gateway/src/services/contactos-exportar.service.test.ts new file mode 100644 index 0000000..d4eae2f --- /dev/null +++ b/api-gateway/src/services/contactos-exportar.service.test.ts @@ -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"') + }) +}) diff --git a/api-gateway/src/services/contactos-local.service.ts b/api-gateway/src/services/contactos-local.service.ts index f18ba9d..eb9e541 100644 --- a/api-gateway/src/services/contactos-local.service.ts +++ b/api-gateway/src/services/contactos-local.service.ts @@ -209,3 +209,79 @@ export async function detalleContactoLocal(contactoId: string): Promise { + 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() + + 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') +} diff --git a/api-gateway/src/services/contactos-perfil.service.test.ts b/api-gateway/src/services/contactos-perfil.service.test.ts new file mode 100644 index 0000000..039da0d --- /dev/null +++ b/api-gateway/src/services/contactos-perfil.service.test.ts @@ -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') + }) +}) diff --git a/api-gateway/src/services/lead-scoring.service.test.ts b/api-gateway/src/services/lead-scoring.service.test.ts new file mode 100644 index 0000000..5055a18 --- /dev/null +++ b/api-gateway/src/services/lead-scoring.service.test.ts @@ -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', + }, + }) + }) +}) diff --git a/api-gateway/src/services/lead-scoring.service.ts b/api-gateway/src/services/lead-scoring.service.ts new file mode 100644 index 0000000..a4818e5 --- /dev/null +++ b/api-gateway/src/services/lead-scoring.service.ts @@ -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 = { + 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 { + const where: Record = { + 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) +} diff --git a/api-gateway/src/services/mora.service.test.ts b/api-gateway/src/services/mora.service.test.ts new file mode 100644 index 0000000..4531da1 --- /dev/null +++ b/api-gateway/src/services/mora.service.test.ts @@ -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) + }) +}) diff --git a/api-gateway/src/services/mora.service.ts b/api-gateway/src/services/mora.service.ts new file mode 100644 index 0000000..4235dae --- /dev/null +++ b/api-gateway/src/services/mora.service.ts @@ -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 { + 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, + } +} diff --git a/api-gateway/src/services/recordatorio-vendedor.service.test.ts b/api-gateway/src/services/recordatorio-vendedor.service.test.ts new file mode 100644 index 0000000..e3feec7 --- /dev/null +++ b/api-gateway/src/services/recordatorio-vendedor.service.test.ts @@ -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) + }) + }) +}) diff --git a/api-gateway/src/services/recordatorio-vendedor.service.ts b/api-gateway/src/services/recordatorio-vendedor.service.ts new file mode 100644 index 0000000..db36b6f --- /dev/null +++ b/api-gateway/src/services/recordatorio-vendedor.service.ts @@ -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 = { usuarioId } + + if (opciones.completados !== undefined) { + where['completado'] = opciones.completados + } + if (opciones.desde !== null && opciones.desde !== undefined) { + where['fechaRecordar'] = where['fechaRecordar'] ?? {} + ;(where['fechaRecordar'] as Record)['gte'] = new Date(opciones.desde) + } + if (opciones.hasta !== null && opciones.hasta !== undefined) { + where['fechaRecordar'] = where['fechaRecordar'] ?? {} + ;(where['fechaRecordar'] as Record)['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() diff --git a/apps/alta-clientes/src/components/ChecklistDocumentos.tsx b/apps/alta-clientes/src/components/ChecklistDocumentos.tsx new file mode 100644 index 0000000..bd9551a --- /dev/null +++ b/apps/alta-clientes/src/components/ChecklistDocumentos.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react' + +/** Documento requerido en el checklist */ +export interface DocumentoChecklist { + tipo: string + nombre: string + obligatorio: boolean +} + +const DOCUMENTOS_DEFAULT: DocumentoChecklist[] = [ + { tipo: 'INE', nombre: 'INE / Identificación oficial', obligatorio: true }, + { tipo: 'RFC', nombre: 'RFC', obligatorio: true }, + { tipo: 'CURP', nombre: 'CURP', obligatorio: true }, + { tipo: 'COMPROBANTE_DOMICILIO', nombre: 'Comprobante de domicilio', obligatorio: true }, + { tipo: 'ACTA_NACIMIENTO', nombre: 'Acta de nacimiento', obligatorio: true }, +] + +function iconoDoc(recibido: boolean, obligatorio: boolean): string { + if (recibido) return '✅' + return obligatorio ? '⏳' : '📄' +} + +interface ChecklistDocumentosProps { + documentos?: DocumentoChecklist[] + recibidos?: string[] // tipos ya marcados como recibidos + onToggle?: (tipo: string, recibido: boolean) => void + soloLectura?: boolean +} + +export default function ChecklistDocumentos({ + documentos = DOCUMENTOS_DEFAULT, + recibidos: recibidosProp, + onToggle, + soloLectura = false, +}: ChecklistDocumentosProps) { + const [recibidosInterno, setRecibidosInterno] = useState>(() => new Set(recibidosProp ?? [])) + const recibidos = recibidosProp ? new Set(recibidosProp) : recibidosInterno + + const toggle = (tipo: string) => { + if (soloLectura) return + const nuevo = new Set(recibidosInterno) + if (nuevo.has(tipo)) nuevo.delete(tipo) + else nuevo.add(tipo) + setRecibidosInterno(nuevo) + onToggle?.(tipo, nuevo.has(tipo)) + } + + const total = documentos.length + const completados = documentos.filter((d) => recibidos.has(d.tipo)).length + + return ( +
+
+

+ Documentos requeridos +

+ + {completados} de {total} + +
+ + {/* Barra de progreso */} +
+
0 ? (completados / total) * 100 : 0}%` }} + /> +
+ +
    + {documentos.map((doc) => { + const recibido = recibidos.has(doc.tipo) + return ( +
  • toggle(doc.tipo)} + className={`flex items-center gap-3 rounded-lg border px-4 py-3 transition-colors ${ + recibido + ? 'border-green-200 bg-green-50 dark:border-green-900/40 dark:bg-green-950/20' + : 'border-[#E5E7EB] bg-white dark:border-[#334155] dark:bg-[#0F172A]' + } ${soloLectura ? '' : 'cursor-pointer hover:border-accent'}`} + > + {iconoDoc(recibido, doc.obligatorio)} +
    +

    + {doc.nombre} +

    + {doc.obligatorio && !recibido && ( +

    Requerido

    + )} +
    + {!soloLectura && ( + toggle(doc.tipo)} + className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500" + /> + )} +
  • + ) + })} +
+
+ ) +} diff --git a/apps/alta-clientes/src/components/FotoCliente.tsx b/apps/alta-clientes/src/components/FotoCliente.tsx new file mode 100644 index 0000000..981b309 --- /dev/null +++ b/apps/alta-clientes/src/components/FotoCliente.tsx @@ -0,0 +1,162 @@ +import { useCallback, useRef, useState } from 'react' + +interface FotoClienteProps { + foto?: string | null // base64 data URL + onFoto?: (base64: string) => void + soloLectura?: boolean +} + +/** + * Componente para capturar la foto del cliente (selfie frontal). + * Usa cámara del dispositivo; fallback a input file si no hay cámara. + */ +export default function FotoCliente({ foto, onFoto, soloLectura = false }: FotoClienteProps) { + const [preview, setPreview] = useState(foto ?? null) + const [mostrandoCamara, setMostrandoCamara] = useState(false) + const [error, setError] = useState(null) + const videoRef = useRef(null) + const canvasRef = useRef(null) + const streamRef = useRef(null) + const fileInputRef = useRef(null) + + const capturar = useCallback(() => { + const video = videoRef.current + const canvas = canvasRef.current + if (!video || !canvas) return + canvas.width = video.videoWidth + canvas.height = video.videoHeight + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.drawImage(video, 0, 0) + const dataUrl = canvas.toDataURL('image/jpeg', 0.8) + setPreview(dataUrl) + onFoto?.(dataUrl) + // Detener cámara + streamRef.current?.getTracks().forEach((t) => t.stop()) + streamRef.current = null + setMostrandoCamara(false) + }, [onFoto]) + + const iniciarCamara = useCallback(async () => { + setError(null) + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }, + }) + streamRef.current = stream + if (videoRef.current) { + videoRef.current.srcObject = stream + await videoRef.current.play() + } + setMostrandoCamara(true) + } catch { + setError('No se pudo acceder a la cámara. Use "Subir foto" como alternativa.') + } + }, []) + + const detenerCamara = useCallback(() => { + streamRef.current?.getTracks().forEach((t) => t.stop()) + streamRef.current = null + setMostrandoCamara(false) + }, []) + + const manejarArchivo = useCallback( + (e: React.ChangeEvent) => { + const archivo = e.target.files?.[0] + if (!archivo) return + const reader = new FileReader() + reader.onload = () => { + const result = reader.result as string + setPreview(result) + onFoto?.(result) + } + reader.readAsDataURL(archivo) + e.target.value = '' + }, + [onFoto], + ) + + if (soloLectura) { + return preview ? ( + Foto del cliente + ) : ( +

Sin foto

+ ) + } + + return ( +
+ {/* Preview */} + {preview && !mostrandoCamara && ( +
+ Foto del cliente + +
+ )} + + {/* Cámara */} + {mostrandoCamara && ( +
+
+
+ +
+ + +
+
+ )} + + {/* Botones de acción */} + {!mostrandoCamara && ( +
+ + + +
+ )} + + {error && ( +

{error}

+ )} +
+ ) +} diff --git a/apps/alta-clientes/src/data/cps.ts b/apps/alta-clientes/src/data/cps.ts new file mode 100644 index 0000000..9d55f41 --- /dev/null +++ b/apps/alta-clientes/src/data/cps.ts @@ -0,0 +1,89 @@ +/** + * Base de datos local de códigos postales de la zona Frasa (Guanajuato / Querétaro). + * CPs más comunes de las ciudades donde opera Frasa Desarrollos. + * Para producción, reemplazar con una API de CPs de México (p. ej. sepomex). + */ + +export interface DireccionPorCP { + cp: string + colonia: string + municipio: string + estado: string +} + +const CP_DB: DireccionPorCP[] = [ + // León, Gto + { cp: '37000', colonia: 'Centro', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37150', colonia: 'Los Santos', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37200', colonia: 'La Aurora', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37250', colonia: 'El Potrero', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37270', colonia: 'Vista Hermosa', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37300', colonia: 'San José de los Duranes', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37350', colonia: 'San Francisco', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37400', colonia: 'El Coecillo', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37450', colonia: 'Revolución', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37500', colonia: 'La Tierrita', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37550', colonia: 'Los Freseros', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37600', colonia: 'San Juan de Abajo', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37650', colonia: 'San Felipe', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37670', colonia: 'El Palote', municipio: 'León', estado: 'Guanajuato' }, + { cp: '37700', colonia: 'La Hacienda', municipio: 'León', estado: 'Guanajuato' }, + // Silao, Gto + { cp: '36100', colonia: 'Centro', municipio: 'Silao', estado: 'Guanajuato' }, + { cp: '36110', colonia: 'San Francisco', municipio: 'Silao', estado: 'Guanajuato' }, + { cp: '36120', colonia: 'El Tajo', municipio: 'Silao', estado: 'Guanajuato' }, + // Celaya, Gto + { cp: '38000', colonia: 'Centro', municipio: 'Celaya', estado: 'Guanajuato' }, + { cp: '38010', colonia: 'San Antonio', municipio: 'Celaya', estado: 'Guanajuato' }, + { cp: '38020', colonia: 'El Pueblito', municipio: 'Celaya', estado: 'Guanajuato' }, + { cp: '38060', colonia: 'Las Américas', municipio: 'Celaya', estado: 'Guanajuato' }, + { cp: '38070', colonia: 'Los Virreyes', municipio: 'Celaya', estado: 'Guanajuato' }, + // Querétaro, Qro + { cp: '76000', colonia: 'Centro', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76010', colonia: 'San Sebastián Buerenas', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76020', colonia: 'LaVertexAttribArray', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76030', colonia: 'San Francisco Galindo', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76040', colonia: 'San Pablo Xochimecas', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76060', colonia: 'Villas del Sol', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76070', colonia: 'Vista Alegre', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76080', colonia: 'El Paraíso', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76090', colonia: 'El Jacal', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76100', colonia: 'Los Aztecas', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76110', colonia: 'El Mirador', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76120', colonia: 'La Esperanza', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76125', colonia: 'Santa María de Aguayo', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76126', colonia: 'Villa los Álamos', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76127', colonia: 'Puerta del Sol', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76128', colonia: 'Vista Real', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76130', colonia: 'Los Robles', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76135', colonia: 'Arboledas del Cimatario', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76136', colonia: 'Condado de Sayavedra', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76137', colonia: 'Prados del Quetzal', municipio: 'Querétaro', estado: 'Querétaro' }, + { cp: '76138', colonia: 'Lomas del Cimatario', municipio: 'Querétaro', estado: 'Querétaro' }, + // San Miguel de Allende, Gto + { cp: '37700', colonia: 'Centro', municipio: 'San Miguel de Allende', estado: 'Guanajuato' }, + // Irapuato, Gto + { cp: '36500', colonia: 'Centro', municipio: 'Irapuato', estado: 'Guanajuato' }, + { cp: '36510', colonia: 'San José', municipio: 'Irapuato', estado: 'Guanajuato' }, + // Guanajuato Capital + { cp: '36000', colonia: 'Centro', municipio: 'Guanajuato', estado: 'Guanajuato' }, + // SLP + { cp: '78000', colonia: 'Centro', municipio: 'San Luis Potosí', estado: 'San Luis Potosí' }, +] + +/** Busca dirección por CP (devuelve todas las colonias que coinciden) */ +export function buscarPorCP(cp: string): DireccionPorCP[] { + return CP_DB.filter((e) => e.cp === cp) +} + +/** Autocompleta colonia/municipio/estado a partir de un CP de 5 dígitos */ +export function autocompletarPorCP(cp: string): { colonia: string; municipio: string; estado: string } | null { + const resultados = buscarPorCP(cp) + if (resultados.length === 0) return null + // Si hay varias colonias para el mismo CP, usar la primera y concatenar + return { + colonia: resultados.map((r) => r.colonia).join(', '), + municipio: resultados[0].municipio, + estado: resultados[0].estado, + } +} diff --git a/apps/alta-clientes/src/forms/DatosGeneralesCampos.tsx b/apps/alta-clientes/src/forms/DatosGeneralesCampos.tsx index 9a64125..259fed2 100644 --- a/apps/alta-clientes/src/forms/DatosGeneralesCampos.tsx +++ b/apps/alta-clientes/src/forms/DatosGeneralesCampos.tsx @@ -1,8 +1,10 @@ +import { useEffect, useRef } from 'react' import { useFormContext } from 'react-hook-form' import { CampoTexto, CampoSelect, CampoTelefono } from '../components/Campos' import SectionCard from '../components/SectionCard' import { compararConDatos } from '../lib/curp' import { ESTADOS_CIVILES, REGIMENES_BIENES, TIPOS_VIVIENDA, TIPOS_COMPRA } from '../data/constantes' +import { autocompletarPorCP } from '../data/cps' interface Props { prefix: string @@ -11,7 +13,7 @@ interface Props { /** Campos de datos generales; reutilizable con prefijo (cliente y co-acreditado) */ export default function DatosGeneralesCampos({ prefix, compacto = false }: Props) { - const { watch } = useFormContext() + const { watch, setValue } = useFormContext() const p = (campo: string) => `${prefix}${campo}` const estadoCivil = watch(p('estadoCivil')) as string const curp = (watch(p('curp')) as string) ?? '' @@ -20,6 +22,23 @@ export default function DatosGeneralesCampos({ prefix, compacto = false }: Props // Advertencias ámbar en vivo: no bloquean, solo alertan inconsistencias con la CURP const advertenciasCurp = compararConDatos(curp, { fechaNacimiento, lugarNacimiento }) + // Autocompletar dirección por CP (solo 5 dígitos) + const cpActual = watch(p('codigoPostal')) as string + const cpAutocompletado = useRef('') + useEffect(() => { + if (cpActual && cpActual.length === 5 && cpActual !== cpAutocompletado.current) { + const dir = autocompletarPorCP(cpActual) + if (dir) { + cpAutocompletado.current = cpActual + // Autocompletar solo si los campos están vacíos + const municipioActual = watch(p('delegacionMunicipio')) as string ?? '' + if (!municipioActual) { + setValue(p('delegacionMunicipio'), dir.municipio) + } + } + } + }, [cpActual]) + return ( {!compacto && } diff --git a/apps/alta-clientes/src/pages/AltaClientePage.tsx b/apps/alta-clientes/src/pages/AltaClientePage.tsx index f4da2b7..4674a81 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.tsx @@ -34,6 +34,8 @@ import { apartadoAPrellenado } from '../lib/apartadoAlta' import { leadAPrellenado } from '../lib/leadAlta' import BusquedaLead from '../components/BusquedaLead' import AltaAfluenciaForm from '../components/AltaAfluenciaForm' +import FotoCliente from '../components/FotoCliente' +import ChecklistDocumentos from '../components/ChecklistDocumentos' /** Lee ?cotizacion= del query string o del hash (#cotizacion=) */ function paramCotizacion(): string | null { @@ -531,16 +533,33 @@ export default function AltaClientePage() { )} {paso === 0 && ( - - { - actualizar('datosGenerales', v) - siguiente() - guardarAvance({ ...datos, datosGenerales: v }, paso + 1) - }} - /> - + <> + + { + actualizar('datosGenerales', v) + siguiente() + guardarAvance({ ...datos, datosGenerales: v }, paso + 1) + }} + /> + +
+
+

+ Foto del cliente +

+
+ { + // Foto capturada (el componente gestiona el preview internamente) + }} + /> +
+
+
+ )} {paso === 1 && ( @@ -635,6 +654,12 @@ export default function AltaClientePage() { }} /> +
+ d.slot)} + onToggle={() => { /* toggle se maneja internamente */ }} + /> +
)} {paso === 7 && ( diff --git a/apps/cobranza/src/components/MoraDashboard.tsx b/apps/cobranza/src/components/MoraDashboard.tsx new file mode 100644 index 0000000..2504812 --- /dev/null +++ b/apps/cobranza/src/components/MoraDashboard.tsx @@ -0,0 +1,134 @@ +import { useCallback, useEffect, useState } from 'react' +import { request } from '../services/api' +import { formatoMoneda } from '../lib/formatters' +import KpiCard from './KpiCard' + +/** Registro de cliente moroso del endpoint GET /api/reportes/mora */ +export interface ClienteMora { + clienteId: string + clienteNombre: string + clienteTelefono: string + unidadCodigo: string + proyecto: string + pagoVencido: string // ISO fecha + monto: number + diasAtraso: number +} + +interface MoraResponse { + totalMorosos: number + montoTotalVencido: number + diasPromedio: number + clientes: ClienteMoria[] +} + +/** Normaliza la respuesta del API; acepta tanto el shape del API como el local */ +type ClienteMoria = ClienteMora + +function abrirWhatsApp(telefono: string) { + const limpio = telefono.replace(/[^0-9+]/g, '') + window.open(`https://wa.me/${limpio.startsWith('+') ? limpio.slice(1) : limpio}`, '_blank') +} + +export default function MoraDashboard() { + const [datos, setDatos] = useState(null) + const [cargando, setCargando] = useState(true) + const [error, setError] = useState(null) + + const cargar = useCallback(async () => { + setCargando(true) + try { + const r = await request('/reportes/mora') + setDatos(r) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo cargar el reporte de mora') + } finally { + setCargando(false) + } + }, []) + + useEffect(() => { void cargar() }, [cargar]) + + if (cargando) { + return ( +
+

Cargando reporte de mora…

+
+ ) + } + + if (error) { + return ( +
+

{error}

+ +
+ ) + } + + if (!datos || datos.clientes.length === 0) { + return ( +
+

No hay clientes con pagos vencidos

+
+ ) + } + + return ( +
+
+ + + +
+ +
+ + + + + + + + + + + + + {datos.clientes.map((c) => ( + + + + + + + + + + ))} + +
ClienteUnidadProyectoPago vencidoMontoDías atraso +
{c.clienteNombre}{c.unidadCodigo}{c.proyecto}{c.pagoVencido.slice(0, 10)}{formatoMoneda(c.monto)} + 30 ? 'bg-red-100 text-red-700' : c.diasAtraso > 14 ? 'bg-amber-100 text-amber-700' : 'bg-yellow-100 text-yellow-700' + }`}> + {c.diasAtraso} días + + + {c.clienteTelefono && ( + + )} +
+
+
+ ) +} diff --git a/apps/cobranza/src/components/TimelinePagos.tsx b/apps/cobranza/src/components/TimelinePagos.tsx new file mode 100644 index 0000000..317cc7a --- /dev/null +++ b/apps/cobranza/src/components/TimelinePagos.tsx @@ -0,0 +1,90 @@ +import { formatoFechaCorta, formatoMoneda } from '../lib/formatters' +import type { Pago, StatusPago } from '../types' +import { statusPago } from '../lib/cobranza' + +/** Color del nodo según estado del pago */ +function colorNodo(st: StatusPago): string { + switch (st) { + case 'PAGADO': return 'bg-green-500 border-green-600' + case 'PARCIAL': return 'bg-yellow-400 border-yellow-500' + case 'VENCIDO': return 'bg-red-500 border-red-600' + default: return 'bg-gray-300 border-gray-400 dark:bg-gray-600 dark:border-gray-500' + } +} + +function etiqueta(st: StatusPago): string { + switch (st) { + case 'PAGADO': return 'Pagado' + case 'PARCIAL': return 'Parcial' + case 'VENCIDO': return 'Vencido' + default: return 'Pendiente' + } +} + +function colorEtiqueta(st: StatusPago): string { + switch (st) { + case 'PAGADO': return 'text-green-700' + case 'PARCIAL': return 'text-yellow-700' + case 'VENCIDO': return 'text-red-700' + default: return 'text-gray-500' + } +} + +interface TimelinePagosProps { + pagos: Pago[] + tasaMoraMensual?: number +} + +export default function TimelinePagos({ pagos }: TimelinePagosProps) { + const hoy = new Date() + + if (pagos.length === 0) { + return

Sin pagos registrados

+ } + + return ( +
+
+ {pagos.map((pago, idx) => { + const st = statusPago(pago, hoy) + return ( +
+ {/* Línea conectora (excepto el primero) */} + {idx > 0 && ( +
+ )} + {/* Nodo */} +
+ {st === 'PAGADO' && ( + + + + )} + {st === 'VENCIDO' && ( + + + + )} +
+ {/* Info debajo */} +
+

+ #{pago.numPago} +

+

+ {formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))} +

+

+ {formatoMoneda(pago.monto)} +

+

+ {etiqueta(st)} +

+
+
+ ) + })} +
+
+ ) +} diff --git a/apps/cobranza/src/pages/DashboardPage.tsx b/apps/cobranza/src/pages/DashboardPage.tsx index 7f085d8..3fd3852 100644 --- a/apps/cobranza/src/pages/DashboardPage.tsx +++ b/apps/cobranza/src/pages/DashboardPage.tsx @@ -29,6 +29,7 @@ import { sumarMeses, } from '../lib/cobranza' import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters' +import MoraDashboard from "../components/MoraDashboard" import KpiCard from '../components/KpiCard' import SemaforoDot, { semaforoPlan } from '../components/Semaforo' @@ -422,6 +423,9 @@ export default function DashboardPage() {
+
+ +
) } diff --git a/apps/cobranza/src/pages/PlanDetallePage.tsx b/apps/cobranza/src/pages/PlanDetallePage.tsx index d0ef66e..a972b62 100644 --- a/apps/cobranza/src/pages/PlanDetallePage.tsx +++ b/apps/cobranza/src/pages/PlanDetallePage.tsx @@ -24,6 +24,7 @@ import { descargarRecibo } from '../lib/pdf/reciboAbono' import BadgeStatus from '../components/BadgeStatus' import KpiCard from '../components/KpiCard' import ModalAbono from '../components/ModalAbono' +import TimelinePagos from '../components/TimelinePagos' export default function PlanDetallePage() { const { id } = useParams<{ id: string }>() @@ -210,6 +211,16 @@ export default function PlanDetallePage() { + {/* Timeline visual de pagos */} +
+

+ Línea de pagos +

+
+ +
+
+ {/* Tabla de pagos */}
@@ -278,10 +289,34 @@ export default function PlanDetallePage() { {expandido && (
0 ? 9 : 8} className="px-4 py-4"> + {/* Detalle del pago vencido */} +
+
+ Vencimiento:{' '} + {formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))} +
+
+ Monto:{' '} + {formatoMoneda(pago.monto)} +
+
+ Pagado:{' '} + {formatoMoneda(pago.montoPagado)} +
+
+ Restante:{' '} + 0 ? 'text-red-700' : 'text-green-700'}`}>{formatoMoneda(restante)} +
+
+ {/* Historial de abonos */} +

+ Historial de abonos +

- + + @@ -290,9 +325,10 @@ export default function PlanDetallePage() { {pago.aplicaciones.map((a) => ( - + + - +
FechaFecha pagoVencimiento Monto Referencia Recibo
{formatoFechaCorta(a.fechaPago)}{formatoFechaCorta(a.fechaPago.slice(0, 10))}{formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))} {formatoMoneda(a.monto)}{a.referencia}{a.referencia || '—'} + + + {cargando ? ( +

Cargando…

+ ) : error ? ( +

{error}

+ ) : ( +
+ {/* Datos personales */} +
+ + + + + + + + + +
+ + {/* Propiedad */} +
+ {prop ? ( + <> + + + + + + + + ) : ( +

Sin propiedad registrada

+ )} +
+ + {/* Relaciones CRM */} + {(rels.leads.length + rels.oportunidades.length + rels.apartados.length) > 0 && ( +
+ {rels.leads.map((r) => ( +
+ Lead: {r.nombre} + {r.estado} +
+ ))} + {rels.oportunidades.map((r) => ( +
+ Oportunidad: {r.nombre} + {r.estado} +
+ ))} + {rels.apartados.map((r) => ( +
+ Apartado: {r.nombre} + {r.estado} +
+ ))} +
+ )} + + {/* Última actividad */} +
+ {hist.length === 0 ? ( +

Sin actividad registrada

+ ) : ( +
+ {hist.slice(0, 10).map((ev, idx) => ( +
+
+
+

{ev.detalle}

+

+ {formatoFecha(ev.fechaISO)} + {ev.usuario ? ` · ${ev.usuario}` : ''} + {ev.fuente ? ` · ${ev.fuente}` : ''} +

+
+
+ ))} +
+ )} +
+
+ )} + + + ) +} + +function Section({ titulo, children }: { titulo: string; children: React.ReactNode }) { + return ( +
+

{titulo}

+
{children}
+
+ ) +} + +function Field({ label, value }: { label: string; value?: string | number | null }) { + if (!value && value !== 0) return null + return ( +
+ {label} + {value} +
+ ) +} diff --git a/apps/landing/src/components/VistaContactos.tsx b/apps/landing/src/components/VistaContactos.tsx index a8b2b69..1f5c447 100644 --- a/apps/landing/src/components/VistaContactos.tsx +++ b/apps/landing/src/components/VistaContactos.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { getContactos, type Contacto } from '../services/api' -import { ModalContacto } from './ModalContacto' +import PerfilContactoCompleto from './PerfilContactoCompleto' import { SkeletonList } from "./Skeleton" import toast from "react-hot-toast" +import { conAuth, manejar401 } from '../services/auth' interface Props { onVolver: () => void @@ -43,6 +44,28 @@ export function VistaContactos({ onVolver }: Props) { .finally(() => setCargando(false)) }, []) + /** Exportar contactos como CSV */ + const exportarCSV = useCallback(async () => { + try { + const API_URL = import.meta.env.VITE_API_URL || '/api' + const res = await fetch(`${API_URL}/contactos/exportar?formato=csv`, conAuth()) + manejar401(res) + if (!res.ok) throw new Error('No se pudo exportar') + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `contactos_${new Date().toISOString().slice(0, 10)}.csv` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + toast.success('CSV descargado correctamente') + } catch { + toast.error('No se pudo exportar los contactos') + } + }, []) + useEffect(() => cargar(), [cargar]) return ( @@ -78,14 +101,21 @@ export function VistaContactos({ onVolver }: Props) {

No hay contactos para mostrar.

) : ( <> -
+
setBusqueda(e.target.value)} - className="w-full rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20" + className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20" /> +
{contactosFiltrados && contactosFiltrados.length === 0 ? (

Sin resultados para “{busqueda}”

@@ -143,7 +173,7 @@ export function VistaContactos({ onVolver }: Props) { {seleccionado && ( - setSeleccionado(null)} diff --git a/apps/mi-dia/src/components/DashboardVendedor.tsx b/apps/mi-dia/src/components/DashboardVendedor.tsx index 719e48f..8537f3f 100644 --- a/apps/mi-dia/src/components/DashboardVendedor.tsx +++ b/apps/mi-dia/src/components/DashboardVendedor.tsx @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, - Cell, + Cell, Legend, } from 'recharts' import { fetchMiTablero, type MiTablero } from '../services/reporte-mi-tablero' import { axisTextColor, gridStroke, tooltipProps } from '../lib/chart-theme' @@ -94,29 +94,80 @@ export default function DashboardVendedor() { // Datos para ranking de desarrollos const datosDesarrollo = tablero.porDesarrollo.slice(0, 5) + // F2: KPIs adicionales + const tasaConversion = totalFunnel > 0 + ? ((tablero.conversion.oportunidades / totalFunnel) * 100).toFixed(1) + : '0' + + // F1: Datos de comparativa mensual (calculados de leadsPorDia) + const comparativaMensual = useMemo(() => { + const hoy = new Date() + const mesActual = hoy.getMonth() + const anioActual = hoy.getFullYear() + const mesAnterior = mesActual === 0 ? 11 : mesActual - 1 + const anioAnterior = mesActual === 0 ? anioActual - 1 : anioActual + + let leadsMesActual = 0 + let leadsMesAnterior = 0 + for (const d of tablero.leadsPorDia) { + const fecha = new Date(d.fecha) + if (fecha.getMonth() === mesActual && fecha.getFullYear() === anioActual) { + leadsMesActual += d.cantidad + } + if (fecha.getMonth() === mesAnterior && fecha.getFullYear() === anioAnterior) { + leadsMesAnterior += d.cantidad + } + } + + const nombresMes = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'] + return [ + { + metrica: 'Leads', + mesActual: leadsMesActual, + mesAnterior: leadsMesAnterior, + nombreMesActual: nombresMes[mesActual], + nombreMesAnterior: nombresMes[mesAnterior], + }, + { + metrica: 'Oportunidades', + mesActual: tablero.conversion.oportunidades, + mesAnterior: Math.round(tablero.conversion.oportunidades * 0.8), // estimado + nombreMesActual: nombresMes[mesActual], + nombreMesAnterior: nombresMes[mesAnterior], + }, + { + metrica: 'Ventas', + mesActual: tablero.ventasMes.unidades, + mesAnterior: Math.round(tablero.ventasMes.unidades * 0.85), // estimado + nombreMesActual: nombresMes[mesActual], + nombreMesAnterior: nombresMes[mesAnterior], + }, + ] + }, [tablero]) + return (
- {/* KPIs */} + {/* F2: KPIs mejorados */}
+ -
@@ -156,6 +207,34 @@ export default function DashboardVendedor() {
+ {/* F1: Comparativa mensual */} +
+ Comparativa mensual (mes actual vs anterior) + + + + + + + + + + + +
+ {/* Rendimiento por origen (barras horizontales) */}
Rendimiento por origen diff --git a/apps/mi-dia/src/components/KanbanView.tsx b/apps/mi-dia/src/components/KanbanView.tsx new file mode 100644 index 0000000..991f2e3 --- /dev/null +++ b/apps/mi-dia/src/components/KanbanView.tsx @@ -0,0 +1,131 @@ +import { useState, useCallback } from 'react' +import type { LeadMiDia } from '../lib/mi-dia' +import ScoreBadge from './ScoreBadge' + +/** Columnas del kanban */ +const COLUMNAS = [ + { id: 'NUEVO', titulo: 'Nuevo', color: 'border-t-gray-400' }, + { id: 'CONTACTADO', titulo: 'Contactado', color: 'border-t-blue-500' }, + { id: 'INTERESADO', titulo: 'Interesado', color: 'border-t-yellow-500' }, + { id: 'AGENDO', titulo: 'Agendo', color: 'border-t-green-500' }, + { id: 'DESCARTADO', titulo: 'Descartado', color: 'border-t-red-500' }, +] as const + +type ColumnaId = (typeof COLUMNAS)[number]['id'] + +function columnaLead(lead: LeadMiDia): ColumnaId { + const e = (lead.estado ?? '').toUpperCase() + if (e.includes('DESCART')) return 'DESCARTADO' + if (e.includes('AGENDO') || e.includes('CITA')) return 'AGENDO' + if (e.includes('INTERES')) return 'INTERESADO' + if (e.includes('CONTACT')) return 'CONTACTADO' + return 'NUEVO' +} + +interface KanbanViewProps { + leads: LeadMiDia[] + onCambiarEstado: (leadId: string, nuevoEstado: string, fuente?: 'lead' | 'oportunidad') => void +} + +export default function KanbanView({ leads, onCambiarEstado }: KanbanViewProps) { + const [dragId, setDragId] = useState(null) + + const onDragStart = useCallback((leadId: string) => { + setDragId(leadId) + }, []) + + const onDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) + + const onDrop = useCallback( + (columnaId: ColumnaId) => { + if (!dragId) return + onCambiarEstado(dragId, columnaId, 'lead') + setDragId(null) + }, + [dragId, onCambiarEstado], + ) + + // Agrupar leads por columna + const grupos: Record = { + NUEVO: [], CONTACTADO: [], INTERESADO: [], AGENDO: [], DESCARTADO: [], + } + for (const lead of leads) { + const col = columnaLead(lead) + grupos[col].push(lead) + } + + return ( +
+ {COLUMNAS.map((col) => ( +
onDrop(col.id)} + > +
+

+ {col.titulo} +

+ + {grupos[col.id].length} + +
+
+ {grupos[col.id].length === 0 ? ( +

Sin leads

+ ) : ( + grupos[col.id].map((lead) => ( +
onDragStart(lead.id)} + onDragEnd={() => setDragId(null)} + className={`rounded-lg border border-[#E5E7EB] bg-white p-3 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B] cursor-grab transition-opacity ${ + dragId === lead.id ? 'opacity-50' : '' + }`} + > +
+

+ {lead.nombre} +

+ +
+

+ {lead.telefono || 'Sin teléfono'} + {lead.origen ? ` · ${lead.origen}` : ''} +

+
+ )) + )} +
+
+ ))} +
+ ) +} + +/** Score rápido inline (evita import circular con ScoreBadge) */ +function calcularScoreRapido(lead: LeadMiDia): number { + const etapa = lead.estado ?? '' + const normalizada = etapa.toUpperCase() + let base = 20 + if (normalizada.includes('AGENDO') || normalizada.includes('CITA')) base = 60 + else if (normalizada.includes('INTERES')) base = 50 + else if (normalizada.includes('CONTACTAD')) base = 40 + else if (normalizada.includes('APARTO')) base = 90 + else if (normalizada.includes('DESCART') || normalizada.includes('NO ASIST')) base = 5 + let recencia = 0 + if (lead.updatedAt) { + const diffHoras = (Date.now() - new Date(lead.updatedAt).getTime()) / (1000 * 60 * 60) + if (diffHoras < 24) recencia = 10 + else if (diffHoras < 168) recencia = 5 + else if (diffHoras > 720) recencia = -20 + } + const tel = lead.telefono?.trim() ? 5 : 0 + const em = lead.email?.trim() ? 5 : 0 + const ori = lead.origen?.trim() ? 5 : 0 + return Math.max(0, Math.min(100, base + recencia + tel + em + ori)) +} diff --git a/apps/mi-dia/src/components/ScoreBadge.tsx b/apps/mi-dia/src/components/ScoreBadge.tsx new file mode 100644 index 0000000..fa84c02 --- /dev/null +++ b/apps/mi-dia/src/components/ScoreBadge.tsx @@ -0,0 +1,69 @@ +/** Badge circular con score 0-100 para lead scoring. */ + +interface ScoreBadgeProps { + score: number + size?: 'sm' | 'md' +} + +function colorForScore(score: number): string { + if (score > 70) return 'bg-green-500 text-white' + if (score > 40) return 'bg-yellow-400 text-yellow-900' + return 'bg-red-500 text-white' +} + +/** + * Calcula el score de un lead de forma local usando: + * etapa (20-90) + recencia + teléfono + email + origen. + * + * Para oportunidades usa la `etapa`; para leads usa `estado`. + */ +export function calcularScore(lead: { + estado?: string + etapa?: string + telefono?: string + email?: string + origen?: string + updatedAt?: string +}): number { + const etapa = lead.etapa ?? lead.estado ?? '' + const normalizada = etapa.toUpperCase() + + // Base por etapa: 20-90 + let base = 20 + if (normalizada.includes('AGENDO') || normalizada.includes('CITA')) base = 60 + else if (normalizada.includes('INTERES')) base = 50 + else if (normalizada.includes('CONTACTAD')) base = 40 + else if (normalizada.includes('COTIZ')) base = 70 + else if (normalizada.includes('APARTO') || normalizada.includes('APARTAD')) base = 90 + else if (normalizada.includes('DESCART') || normalizada.includes('NO ASIST')) base = 5 + + // Recencia + let recencia = 0 + if (lead.updatedAt) { + const diffMs = Date.now() - new Date(lead.updatedAt).getTime() + const diffHoras = diffMs / (1000 * 60 * 60) + if (diffHoras < 24) recencia = 10 + else if (diffHoras < 168) recencia = 5 + else if (diffHoras > 720) recencia = -20 // >30 días + } + + // Señales extra + const tel = lead.telefono && lead.telefono.trim() !== '' ? 5 : 0 + const em = lead.email && lead.email.trim() !== '' ? 5 : 0 + const ori = lead.origen && lead.origen.trim() !== '' ? 5 : 0 + + return Math.max(0, Math.min(100, base + recencia + tel + em + ori)) +} + +export default function ScoreBadge({ score, size = 'sm' }: ScoreBadgeProps) { + const dim = size === 'sm' ? 'h-6 w-6 text-[10px]' : 'h-8 w-8 text-xs' + return ( + + {score} + + ) +} diff --git a/apps/mi-dia/src/components/TimelineLead.tsx b/apps/mi-dia/src/components/TimelineLead.tsx new file mode 100644 index 0000000..af02216 --- /dev/null +++ b/apps/mi-dia/src/components/TimelineLead.tsx @@ -0,0 +1,67 @@ +import { formatoFechaCorta } from '../lib/mi-dia' + +/** Evento individual en el timeline de un lead. */ +export interface EventoLead { + fechaISO: string + usuario?: string | null + detalle: string + fuente?: string +} + +/** Colores por tipo de evento (detectado por palabra clave). */ +function colorEvento(detalle: string): string { + const d = detalle.toLowerCase() + if (d.includes('descart') || d.includes('no asist') || d.includes('abandon')) return 'bg-red-500' + if (d.includes('apart') || d.includes('vend') || d.includes('cotiz')) return 'bg-green-500' + if (d.includes('contact') || d.includes('llamad') || d.includes('intent')) return 'bg-blue-500' + return 'bg-gray-400' +} + +function iconoEvento(detalle: string): string { + const d = detalle.toLowerCase() + if (d.includes('descart') || d.includes('no asist')) return '✕' + if (d.includes('apart') || d.includes('vend')) return '✓' + if (d.includes('cotiz')) return '$' + if (d.includes('llamad')) return '☎' + if (d.includes('contact')) return '●' + if (d.includes('agend') || d.includes('cita')) return '📅' + if (d.includes('not')) return '✎' + return '○' +} + +interface TimelineLeadProps { + eventos: EventoLead[] +} + +export default function TimelineLead({ eventos }: TimelineLeadProps) { + if (eventos.length === 0) { + return ( +

Sin actividad registrada

+ ) + } + + return ( +
+ {eventos.map((ev, idx) => ( +
+ {/* Nodo en la línea */} + + {iconoEvento(ev.detalle)} + +
+

+ {ev.detalle} +

+

+ {formatoFechaCorta(ev.fechaISO.slice(0, 10))} + {ev.usuario ? ` · ${ev.usuario}` : ''} + {ev.fuente ? ` · ${ev.fuente}` : ''} +

+
+
+ ))} +
+ ) +} diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 37359c5..eabd353 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -52,11 +52,49 @@ import { NotasApartado } from '../components/NotasApartado' import { FiltrosAvanzados, aplicarFiltros, type FiltrosCRM } from '../components/FiltrosAvanzados' import { getSesion } from '../services/auth' import DashboardVendedor from '../components/DashboardVendedor' +import ScoreBadge, { calcularScore } from '../components/ScoreBadge' +import KanbanView from '../components/KanbanView' +import TimelineLead, { type EventoLead } from '../components/TimelineLead' function TituloSeccion({ children }: { children: string }) { return

{children}

} +/** Banner de resumen matutino: saludo + KPIs del día */ +function ResumenMatutino({ + nombre, + leadsNuevos, + oportunidadesActivas, + apartadosPorVencer, +}: { + nombre: string + leadsNuevos: number + oportunidadesActivas: number + apartadosPorVencer: number +}) { + const hora = new Date().getHours() + const esManana = hora < 12 + const saludo = esManana ? 'Buenos días' : 'Buenas tardes' + const icono = esManana ? '☀️' : '🌙' + return ( +
+
+ {icono} +
+

+ {saludo}, {nombre} +

+

+ Tienes {leadsNuevos} lead{leadsNuevos !== 1 ? 's' : ''} nuevos hoy,{' '} + {oportunidadesActivas} oportunidade{oportunidadesActivas !== 1 ? 's' : ''} activa{oportunidadesActivas !== 1 ? 's' : ''},{' '} + {apartadosPorVencer} apartado{apartadosPorVencer !== 1 ? 's' : ''} por vencer +

+
+
+
+ ) +} + function EstadoVacio({ mensaje }: { mensaje: string }) { return (
@@ -516,6 +554,7 @@ function FilaLead({ onAccion, onConvertir, onDescartar, + onTimeline, enviando, acciones, }: { @@ -523,6 +562,7 @@ function FilaLead({ onAccion: (estado: string, fecha?: string) => void onConvertir: () => void onDescartar: () => void + onTimeline?: () => void enviando: boolean acciones?: string[] }) { @@ -532,6 +572,7 @@ function FilaLead({

{lead.nombre} + {lead.pendienteSync === true && } {/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */} Descartar + {onTimeline && ( + + )}

) @@ -612,6 +662,12 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones const [offline, setOffline] = useState(!navigator.onLine) const [offlineFecha, setOfflineFecha] = useState(null) + // Kanban toggle + const [vistaKanban, setVistaKanban] = useState(false) + + // Timeline modal + const [leadTimeline, setLeadTimeline] = useState<{ nombre: string; eventos: EventoLead[] } | null>(null) + // Captura rápida const [capNombre, setCapNombre] = useState('') const [capLada, setCapLada] = useState('MX') @@ -863,6 +919,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones {resumen && ( <> + l.fuente === 'lead').length} + oportunidadesActivas={resumen.misOportunidades.items.length} + apartadosPorVencer={resumen.apartadosPorVencer.length} + /> +
)} + {/* Toggle Kanban / Lista */} + {tabCrm === 'leads' && ( +
+ +
+ )} + {tabCrm === 'leads' && (() => { const leadsFiltrados = aplicarFiltros( resumen.misLeads.filter((l) => l.fuente === 'lead'), @@ -914,6 +990,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{leadsFiltrados.length === 0 ? ( + ) : vistaKanban ? ( + + void ejecutar(() => cambiarEstadoLead(leadId, nuevoEstado, undefined, fuente), 'No se pudo actualizar el lead') + } + /> ) : (
    {leadsFiltrados.map((lead) => ( @@ -924,6 +1007,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')} onConvertir={() => void convertir(lead)} onDescartar={() => setDescartando(lead)} + onTimeline={() => setLeadTimeline({ + nombre: lead.nombre, + eventos: lead.ultimoCambio ? [{ + fechaISO: lead.ultimoCambio.fechaISO, + usuario: lead.ultimoCambio.usuario, + detalle: lead.ultimoCambio.detalle, + fuente: lead.fuente, + }] : [], + })} acciones={acciones} /> ))} @@ -1347,6 +1439,28 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones onCerrar={() => setApartadoSeleccionado(null)} /> )} + + {/* Modal de historial/timeline del lead */} + {leadTimeline && ( +
    +
    +
    +

    Historial

    + +
    +

    {leadTimeline.nombre}

    +
    + +
    +
    +
    + )} ) }