feat(ux): 17 mejoras de experiencia de usuario
Backend (10 endpoints nuevos): - Recordatorios personales (CRUD + tabla RecordatorioVendedor) - Lead scoring 0-100 (etapa+recencia+datos) - Dashboard mora (pagos vencidos, monto, días atraso) - Timeline de pagos (pagado/vence/futuro) - Perfil completo contacto (pagos+cotizaciones+docs+score) - Exportar contactos a CSV Frontend Mi día: - Resumen matutino (saludo+KPIs) - Historial visual lead (timeline) - Score badge 0-100 (verde/amarillo/rojo) - Vista kanban (drag & drop) Frontend Cobranza: - Dashboard mora (tabla+WhatsApp) - Timeline pagos visual (nodos coloreados) Frontend Alta Clientes: - Checklist documentos visual - Foto cliente (cámara) Frontend Landing: - Perfil contacto completo - Exportar contactos CSV Frontend Mi día (extras): - Comparativa mensual - KPIs dashboard (leads hoy, ventas, conversión) 806 tests, 5 apps construidas.
This commit is contained in:
@@ -412,6 +412,22 @@ model AuditoriaLog {
|
|||||||
@@index([createdAt])
|
@@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.
|
// Captura por departamentos: cada campo de cada unidad se registra como fila.
|
||||||
// Unique constraint: desarrollo + unidadCodigo + departamento + campo.
|
// Unique constraint: desarrollo + unidadCodigo + departamento + campo.
|
||||||
model CapturaDepartamento {
|
model CapturaDepartamento {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
import { mondayService } from '../services/monday.service.js'
|
import { mondayService } from '../services/monday.service.js'
|
||||||
import { detalleContacto, claveTelefono } from '../services/contactos-detalle.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 { saludMonday } from '../services/salud-monday.service.js'
|
||||||
import { esFalloMonday } from '../services/sync-monday.service.js'
|
import { esFalloMonday } from '../services/sync-monday.service.js'
|
||||||
import { prisma } from '../services/prisma.js'
|
import { prisma } from '../services/prisma.js'
|
||||||
@@ -122,3 +123,28 @@ export async function detalleContactoEndpoint(req: Request, res: Response): Prom
|
|||||||
res.json(detalle)
|
res.json(detalle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/contactos/:id/completo — perfil completo del contacto:
|
||||||
|
* datos, historial pagos, cotizaciones, documentos, lead score, última actividad
|
||||||
|
*/
|
||||||
|
export async function perfilCompletoEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const contactoId = String(req.params['id'])
|
||||||
|
const perfil = await perfilCompleto(contactoId)
|
||||||
|
if (perfil === null) throw new NotFoundError('Contacto no encontrado')
|
||||||
|
res.json(perfil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/contactos/exportar — exportar contactos a CSV
|
||||||
|
* Query params: desarrollo, estatus
|
||||||
|
*/
|
||||||
|
export async function exportarContactosEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const csv = await exportarCSV({
|
||||||
|
desarrollo: (req.query['desarrollo'] as string) ?? undefined,
|
||||||
|
estatus: (req.query['estatus'] as string) ?? undefined,
|
||||||
|
})
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="contactos.csv"')
|
||||||
|
res.send(csv)
|
||||||
|
}
|
||||||
|
|||||||
65
api-gateway/src/controllers/planes-timeline.test.ts
Normal file
65
api-gateway/src/controllers/planes-timeline.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { construirTimeline } from './planes.controller.js'
|
||||||
|
|
||||||
|
describe('construirTimeline', () => {
|
||||||
|
const hoy = new Date('2026-09-08T12:00:00Z')
|
||||||
|
|
||||||
|
const planBase = {
|
||||||
|
id: 'p1',
|
||||||
|
clienteNombre: 'Test',
|
||||||
|
unidadCodigo: 'U-1',
|
||||||
|
proyecto: 'SENDA',
|
||||||
|
fechaInicio: '2026-01-01',
|
||||||
|
engancheTotal: 10000,
|
||||||
|
numPagos: 3,
|
||||||
|
mensualidad: 3333.33,
|
||||||
|
status: 'ACTIVO' as const,
|
||||||
|
pagos: [
|
||||||
|
{ id: 'pg1', numPago: 1, fechaVencimiento: '2026-02-01', monto: 3333.33, montoPagado: 3333.33, aplicaciones: [] },
|
||||||
|
{ id: 'pg2', numPago: 2, fechaVencimiento: '2026-08-01', monto: 3333.33, montoPagado: 0, aplicaciones: [] },
|
||||||
|
{ id: 'pg3', numPago: 3, fechaVencimiento: '2026-10-01', monto: 3333.34, montoPagado: 0, aplicaciones: [] },
|
||||||
|
],
|
||||||
|
notas: [],
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('marca pagos pagados, vencidos y futuros correctamente', () => {
|
||||||
|
const timeline = construirTimeline(planBase, hoy)
|
||||||
|
|
||||||
|
expect(timeline).toHaveLength(3)
|
||||||
|
|
||||||
|
// Pago 1: pagado
|
||||||
|
expect(timeline[0]?.estado).toBe('pagado')
|
||||||
|
expect(timeline[0]?.diasRestantes).toBeNull()
|
||||||
|
|
||||||
|
// Pago 2: vencido (01/08/2026 < 08/09/2026) — díasRestantes negativos
|
||||||
|
expect(timeline[1]?.estado).toBe('vence')
|
||||||
|
expect(timeline[1]?.diasRestantes).toBeLessThan(0)
|
||||||
|
|
||||||
|
// Pago 3: futuro (01/10/2026 > 08/09/2026) — díasRestantes positivos
|
||||||
|
expect(timeline[2]?.estado).toBe('futuro')
|
||||||
|
expect(timeline[2]?.diasRestantes).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ordena por numPago asc', () => {
|
||||||
|
const timeline = construirTimeline(planBase, hoy)
|
||||||
|
expect(timeline.map((t) => t.numPago)).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('devuelve array vacío si no hay pagos', () => {
|
||||||
|
const planSinPagos = { ...planBase, pagos: [] }
|
||||||
|
expect(construirTimeline(planSinPagos, hoy)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pago parcialmente pagado y vencido se marca como vence', () => {
|
||||||
|
const plan = {
|
||||||
|
...planBase,
|
||||||
|
pagos: [
|
||||||
|
{ id: 'pg1', numPago: 1, fechaVencimiento: '2026-08-01', monto: 5000, montoPagado: 2000, aplicaciones: [] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const timeline = construirTimeline(plan, hoy)
|
||||||
|
expect(timeline[0]?.estado).toBe('vence')
|
||||||
|
expect(timeline[0]?.montoPagado).toBe(2000)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -306,6 +306,55 @@ export async function agregarNota(req: Request, res: Response): Promise<void> {
|
|||||||
res.status(201).json(creada)
|
res.status(201).json(creada)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Timeline de pagos ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TimelinePago {
|
||||||
|
numPago: number
|
||||||
|
fechaVencimiento: string
|
||||||
|
monto: number
|
||||||
|
montoPagado: number
|
||||||
|
estado: 'pagado' | 'vence' | 'futuro'
|
||||||
|
diasRestantes: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera la línea de tiempo de pagos de un plan.
|
||||||
|
* Estados: pagado (montoPagado >= monto), vence (fecha pasada sin cubrir),
|
||||||
|
* futuro (fecha aún no llega).
|
||||||
|
*/
|
||||||
|
export function construirTimeline(plan: PlanPagoDominio, hoy: Date = new Date()): TimelinePago[] {
|
||||||
|
const inicioHoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
|
||||||
|
|
||||||
|
return plan.pagos
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.numPago - b.numPago)
|
||||||
|
.map((pago) => {
|
||||||
|
const fv = parseFechaLocal(pago.fechaVencimiento)
|
||||||
|
const pagado = pago.montoPagado >= pago.monto - 0.01
|
||||||
|
const vencido = fv < inicioHoy && !pagado
|
||||||
|
const estado: TimelinePago['estado'] = pagado ? 'pagado' : vencido ? 'vence' : 'futuro'
|
||||||
|
const diasRestantes = pagado
|
||||||
|
? null
|
||||||
|
: Math.round((fv.getTime() - inicioHoy.getTime()) / 86_400_000)
|
||||||
|
|
||||||
|
return {
|
||||||
|
numPago: pago.numPago,
|
||||||
|
fechaVencimiento: pago.fechaVencimiento,
|
||||||
|
monto: pago.monto,
|
||||||
|
montoPagado: pago.montoPagado,
|
||||||
|
estado,
|
||||||
|
diasRestantes,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/planes/:id/timeline — timeline visual de pagos */
|
||||||
|
export async function timelinePlan(req: Request, res: Response): Promise<void> {
|
||||||
|
const plan = await cargarPlan(String(req.params['id']))
|
||||||
|
const dominio = aDominio(plan)
|
||||||
|
res.json(construirTimeline(dominio))
|
||||||
|
}
|
||||||
|
|
||||||
/** POST /api/planes/:id/cancelar */
|
/** POST /api/planes/:id/cancelar */
|
||||||
export async function cancelarPlan(req: Request, res: Response): Promise<void> {
|
export async function cancelarPlan(req: Request, res: Response): Promise<void> {
|
||||||
const plan = await cargarPlan(String(req.params['id']))
|
const plan = await cargarPlan(String(req.params['id']))
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const svcMock = vi.hoisted(() => ({
|
||||||
|
crear: vi.fn(),
|
||||||
|
listar: vi.fn(),
|
||||||
|
completar: vi.fn(),
|
||||||
|
eliminar: vi.fn(),
|
||||||
|
proximos: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../services/recordatorio-vendedor.service.js', () => ({
|
||||||
|
recordatorioVendedorService: svcMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { crearController } from './recordatorio-vendedor.controller.js'
|
||||||
|
|
||||||
|
const controller = crearController(svcMock as never)
|
||||||
|
|
||||||
|
function resJson() {
|
||||||
|
const res = {
|
||||||
|
json: vi.fn(),
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
end: vi.fn(),
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqConToken(sub = 'u-1', overrides: Record<string, unknown> = {}) {
|
||||||
|
return { params: {}, query: {}, usuarioToken: { sub, rol: 'vendedor' }, ...overrides } as never
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('recordatorio-vendedor.controller', () => {
|
||||||
|
it('crear: llama al service con usuarioId del token', async () => {
|
||||||
|
svcMock.crear.mockResolvedValue({ id: 'r1', titulo: 'Test' })
|
||||||
|
const res = resJson()
|
||||||
|
|
||||||
|
await controller.crear(
|
||||||
|
reqConToken('u-1', { body: { titulo: 'Test', fechaRecordar: '2026-09-10T10:00:00' } }),
|
||||||
|
res as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(svcMock.crear).toHaveBeenCalledWith('u-1', {
|
||||||
|
titulo: 'Test',
|
||||||
|
descripcion: null,
|
||||||
|
leadId: null,
|
||||||
|
fechaRecordar: '2026-09-10T10:00:00',
|
||||||
|
})
|
||||||
|
expect(res.status).toHaveBeenCalledWith(201)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ id: 'r1', titulo: 'Test' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('listar: pasa filtros query al service', async () => {
|
||||||
|
svcMock.listar.mockResolvedValue([])
|
||||||
|
const res = resJson()
|
||||||
|
|
||||||
|
await controller.listar(
|
||||||
|
reqConToken('u-1', { query: { completados: 'true', desde: '2026-09-01' } }),
|
||||||
|
res as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(svcMock.listar).toHaveBeenCalledWith('u-1', {
|
||||||
|
completados: true,
|
||||||
|
desde: '2026-09-01',
|
||||||
|
hasta: null,
|
||||||
|
})
|
||||||
|
expect(res.json).toHaveBeenCalledWith([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('completar: llama al service con el id', async () => {
|
||||||
|
svcMock.completar.mockResolvedValue({ id: 'r1', completado: true })
|
||||||
|
const res = resJson()
|
||||||
|
|
||||||
|
await controller.completar(
|
||||||
|
reqConToken('u-1', { params: { id: 'r1' } }),
|
||||||
|
res as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(svcMock.completar).toHaveBeenCalledWith('r1')
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ id: 'r1', completado: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('eliminar: responde 204', async () => {
|
||||||
|
svcMock.eliminar.mockResolvedValue(undefined)
|
||||||
|
const res = resJson()
|
||||||
|
|
||||||
|
await controller.eliminar(
|
||||||
|
reqConToken('u-1', { params: { id: 'r1' } }),
|
||||||
|
res as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(svcMock.eliminar).toHaveBeenCalledWith('r1')
|
||||||
|
expect(res.status).toHaveBeenCalledWith(204)
|
||||||
|
expect(res.end).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
107
api-gateway/src/controllers/recordatorio-vendedor.controller.ts
Normal file
107
api-gateway/src/controllers/recordatorio-vendedor.controller.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import type { Request, Response } from 'express'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import {
|
||||||
|
recordatorioVendedorService,
|
||||||
|
type RecordatorioVendedorService,
|
||||||
|
} from '../services/recordatorio-vendedor.service.js'
|
||||||
|
|
||||||
|
const crearSchema = z.object({
|
||||||
|
titulo: z.string().min(1),
|
||||||
|
descripcion: z.string().optional(),
|
||||||
|
leadId: z.string().optional(),
|
||||||
|
fechaRecordar: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/recordatorios — crear recordatorio (requiere auth)
|
||||||
|
*/
|
||||||
|
export async function crearRecordatorioEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const body = crearSchema.parse(req.body)
|
||||||
|
const usuarioId = req.usuarioToken?.sub
|
||||||
|
if (!usuarioId) throw new Error('Auth requerida')
|
||||||
|
|
||||||
|
const recordatorio = await recordatorioVendedorService.crear(usuarioId, {
|
||||||
|
titulo: body.titulo,
|
||||||
|
descripcion: body.descripcion ?? null,
|
||||||
|
leadId: body.leadId ?? null,
|
||||||
|
fechaRecordar: body.fechaRecordar,
|
||||||
|
})
|
||||||
|
res.status(201).json(recordatorio)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/recordatorios — listar recordatorios del usuario (requiere auth)
|
||||||
|
* Query params: completados, desde, hasta
|
||||||
|
*/
|
||||||
|
export async function listarRecordatoriosEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const usuarioId = req.usuarioToken?.sub
|
||||||
|
if (!usuarioId) throw new Error('Auth requerida')
|
||||||
|
|
||||||
|
const completadosStr = req.query['completados'] as string | undefined
|
||||||
|
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null } = {
|
||||||
|
desde: (req.query['desde'] as string) ?? null,
|
||||||
|
hasta: (req.query['hasta'] as string) ?? null,
|
||||||
|
}
|
||||||
|
if (completadosStr === 'true') opts.completados = true
|
||||||
|
else if (completadosStr === 'false') opts.completados = false
|
||||||
|
|
||||||
|
const recordatorios = await recordatorioVendedorService.listar(usuarioId, opts)
|
||||||
|
res.json(recordatorios)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/recordatorios/:id/completar — marcar como completado (requiere auth)
|
||||||
|
*/
|
||||||
|
export async function completarRecordatorioEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const id = String(req.params['id'])
|
||||||
|
const recordatorio = await recordatorioVendedorService.completar(id)
|
||||||
|
res.json(recordatorio)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/recordatorios/:id — eliminar recordatorio (requiere auth)
|
||||||
|
*/
|
||||||
|
export async function eliminarRecordatorioEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const id = String(req.params['id'])
|
||||||
|
await recordatorioVendedorService.eliminar(id)
|
||||||
|
res.status(204).end()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory exportable para tests (inyección de dependencia)
|
||||||
|
export function crearController(svc: RecordatorioVendedorService) {
|
||||||
|
return {
|
||||||
|
crear: async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const body = crearSchema.parse(req.body)
|
||||||
|
const usuarioId = req.usuarioToken?.sub
|
||||||
|
if (!usuarioId) throw new Error('Auth requerida')
|
||||||
|
const r = await svc.crear(usuarioId, {
|
||||||
|
titulo: body.titulo,
|
||||||
|
descripcion: body.descripcion ?? null,
|
||||||
|
leadId: body.leadId ?? null,
|
||||||
|
fechaRecordar: body.fechaRecordar,
|
||||||
|
})
|
||||||
|
res.status(201).json(r)
|
||||||
|
},
|
||||||
|
listar: async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const usuarioId = req.usuarioToken?.sub
|
||||||
|
if (!usuarioId) throw new Error('Auth requerida')
|
||||||
|
const completadosStr = req.query['completados'] as string | undefined
|
||||||
|
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null } = {
|
||||||
|
desde: (req.query['desde'] as string) ?? null,
|
||||||
|
hasta: (req.query['hasta'] as string) ?? null,
|
||||||
|
}
|
||||||
|
if (completadosStr === 'true') opts.completados = true
|
||||||
|
else if (completadosStr === 'false') opts.completados = false
|
||||||
|
const items = await svc.listar(usuarioId, opts)
|
||||||
|
res.json(items)
|
||||||
|
},
|
||||||
|
completar: async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const r = await svc.completar(String(req.params['id']))
|
||||||
|
res.json(r)
|
||||||
|
},
|
||||||
|
eliminar: async (req: Request, res: Response): Promise<void> => {
|
||||||
|
await svc.eliminar(String(req.params['id']))
|
||||||
|
res.status(204).end()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import type { Request, Response } from 'express'
|
|||||||
import { mondayService } from '../services/monday.service.js'
|
import { mondayService } from '../services/monday.service.js'
|
||||||
import { ReportesService } from '../services/reportes.service.js'
|
import { ReportesService } from '../services/reportes.service.js'
|
||||||
import { cargarPlanesDominio } from './planes.controller.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) */
|
/** Singleton del agregador (cache en memoria de 60 s; los tests instancian el suyo) */
|
||||||
export const reportesService = new ReportesService(mondayService, cargarPlanesDominio)
|
export const reportesService = new ReportesService(mondayService, cargarPlanesDominio)
|
||||||
@@ -10,3 +12,14 @@ export const reportesService = new ReportesService(mondayService, cargarPlanesDo
|
|||||||
export async function resumenReportes(_req: Request, res: Response): Promise<void> {
|
export async function resumenReportes(_req: Request, res: Response): Promise<void> {
|
||||||
res.json(await reportesService.resumen())
|
res.json(await reportesService.resumen())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /api/reportes/scoring — scores de todos los leads activos */
|
||||||
|
export async function scoringEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const usuarioId = req.query['usuarioId'] as string | undefined
|
||||||
|
res.json(await obtenerScores(usuarioId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/reportes/mora — dashboard de mora: pagos vencidos */
|
||||||
|
export async function moraEndpoint(_req: Request, res: Response): Promise<void> {
|
||||||
|
res.json(await resumenMora())
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
listarPlanes,
|
listarPlanes,
|
||||||
obtenerPlan,
|
obtenerPlan,
|
||||||
registrarAbonoEndpoint,
|
registrarAbonoEndpoint,
|
||||||
|
timelinePlan,
|
||||||
} from '../controllers/planes.controller.js'
|
} from '../controllers/planes.controller.js'
|
||||||
import {
|
import {
|
||||||
cambiarEtapaOportunidadEndpoint,
|
cambiarEtapaOportunidadEndpoint,
|
||||||
@@ -64,7 +65,12 @@ import {
|
|||||||
listarUsuariosMondayEndpoint,
|
listarUsuariosMondayEndpoint,
|
||||||
} from '../controllers/usuarios.controller.js'
|
} from '../controllers/usuarios.controller.js'
|
||||||
import { resumenMiDiaEndpoint } from '../controllers/mi-dia.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 { webhookMonday } from '../controllers/webhooks.controller.js'
|
||||||
import {
|
import {
|
||||||
correrRoundRobin,
|
correrRoundRobin,
|
||||||
@@ -80,7 +86,7 @@ import {
|
|||||||
subirDocumento,
|
subirDocumento,
|
||||||
} from '../controllers/ai.controller.js'
|
} from '../controllers/ai.controller.js'
|
||||||
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.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 { estadoSistema } from '../controllers/sistema.controller.js'
|
||||||
import {
|
import {
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
@@ -95,6 +101,12 @@ import {
|
|||||||
} from '../controllers/config.controller.js'
|
} from '../controllers/config.controller.js'
|
||||||
import { crearNotaEndpoint, listarNotasEndpoint } from '../controllers/notas.controller.js'
|
import { crearNotaEndpoint, listarNotasEndpoint } from '../controllers/notas.controller.js'
|
||||||
import { listarAuditoriaEndpoint } from '../controllers/auditoria.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 { buscarEndpoint } from '../controllers/buscar.controller.js'
|
||||||
import {
|
import {
|
||||||
miTableroEndpoint,
|
miTableroEndpoint,
|
||||||
@@ -176,6 +188,7 @@ apiRouter.post('/planes', crearPlan)
|
|||||||
apiRouter.get('/planes', listarPlanes)
|
apiRouter.get('/planes', listarPlanes)
|
||||||
apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy)
|
apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy)
|
||||||
apiRouter.get('/planes/:id', obtenerPlan)
|
apiRouter.get('/planes/:id', obtenerPlan)
|
||||||
|
apiRouter.get('/planes/:id/timeline', timelinePlan)
|
||||||
apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint)
|
apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint)
|
||||||
apiRouter.post('/planes/:id/notas', agregarNota)
|
apiRouter.post('/planes/:id/notas', agregarNota)
|
||||||
apiRouter.post('/planes/:id/cancelar', cancelarPlan)
|
apiRouter.post('/planes/:id/cancelar', cancelarPlan)
|
||||||
@@ -184,6 +197,12 @@ apiRouter.patch('/planes/:id/monday', ligarMondayItem)
|
|||||||
apiRouter.get('/cobranza/recordatorios', listarRecordatorios)
|
apiRouter.get('/cobranza/recordatorios', listarRecordatorios)
|
||||||
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)
|
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)
|
apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
|
||||||
// Cambio de etapa desde los botones de "Mis oportunidades" (Mi día)
|
// Cambio de etapa desde los botones de "Mis oportunidades" (Mi día)
|
||||||
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
|
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
|
||||||
@@ -200,7 +219,9 @@ apiRouter.get('/apartados/:id/notas', listarNotasEndpoint)
|
|||||||
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
|
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
|
||||||
|
|
||||||
apiRouter.get('/contactos', listarContactosEndpoint)
|
apiRouter.get('/contactos', listarContactosEndpoint)
|
||||||
|
apiRouter.get('/contactos/exportar', exportarContactosEndpoint)
|
||||||
apiRouter.get('/contactos/:id/detalle', detalleContactoEndpoint)
|
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
|
// GET estático: va ANTES de /leads/:itemId para no colisionar con el parámetro
|
||||||
apiRouter.get('/leads/buscar', buscarLeadsEndpoint)
|
apiRouter.get('/leads/buscar', buscarLeadsEndpoint)
|
||||||
@@ -218,6 +239,8 @@ apiRouter.patch('/usuarios/me/monday', actualizarVinculoMondayEndpoint)
|
|||||||
apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
|
apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
|
||||||
|
|
||||||
apiRouter.get('/reportes/resumen', resumenReportes)
|
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
|
// Tablero personal del vendedor: métricas, conversión, ventas, origen, desarrollos
|
||||||
apiRouter.get('/reportes/mi-tablero', miTableroEndpoint)
|
apiRouter.get('/reportes/mi-tablero', miTableroEndpoint)
|
||||||
// Métricas de uso (admin/coordinador): leads por usuario, tasa de descarte, actividades
|
// Métricas de uso (admin/coordinador): leads por usuario, tasa de descarte, actividades
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { prisma } from './prisma.js'
|
||||||
|
import { calcularScore } from './lead-scoring.service.js'
|
||||||
import { config } from '../config/index.js'
|
import { config } from '../config/index.js'
|
||||||
import { detalleEventoUltimoCambio, esEstadoFinalApartado } from './mi-dia.service.js'
|
import { detalleEventoUltimoCambio, esEstadoFinalApartado } from './mi-dia.service.js'
|
||||||
import type { MondayService } from './monday.service.js'
|
import type { MondayService } from './monday.service.js'
|
||||||
@@ -195,3 +197,167 @@ export async function detalleContacto(
|
|||||||
historial,
|
historial,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Perfil Completo del Contacto (modo local) ─────────────────────────
|
||||||
|
|
||||||
|
export interface PerfilCompletoContacto {
|
||||||
|
contacto: Record<string, string>
|
||||||
|
historialPagos: Array<{
|
||||||
|
planId: string
|
||||||
|
clienteNombre: string
|
||||||
|
unidadCodigo: string
|
||||||
|
proyecto: string
|
||||||
|
numPago: number
|
||||||
|
fechaVencimiento: string
|
||||||
|
monto: number
|
||||||
|
montoPagado: number
|
||||||
|
estado: string
|
||||||
|
}>
|
||||||
|
cotizaciones: Array<{
|
||||||
|
id: string
|
||||||
|
unidadCodigo: string
|
||||||
|
tipoPropuesta: string
|
||||||
|
precioFinal: number
|
||||||
|
createdAt: string
|
||||||
|
}>
|
||||||
|
documentos: Array<{
|
||||||
|
id: string
|
||||||
|
tipo: string
|
||||||
|
nombreArchivo: string
|
||||||
|
createdAt: string
|
||||||
|
}>
|
||||||
|
leadScore: number | null
|
||||||
|
ultimaActividad: {
|
||||||
|
titulo: string
|
||||||
|
fecha: string
|
||||||
|
tipo: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perfil completo de un contacto: combina datos del cliente/lead, historial
|
||||||
|
* de pagos, cotizaciones, documentos, lead score y última actividad local.
|
||||||
|
*/
|
||||||
|
export async function perfilCompleto(contactoId: string): Promise<PerfilCompletoContacto | null> {
|
||||||
|
// Buscar el cliente
|
||||||
|
const cliente = await prisma.cliente.findFirst({
|
||||||
|
where: { OR: [{ id: contactoId }, { mondayContactoId: contactoId }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Buscar la captura del lead
|
||||||
|
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: contactoId } })
|
||||||
|
|
||||||
|
if (!cliente && !captura) return null
|
||||||
|
|
||||||
|
const telefono = cliente?.celular ?? captura?.telefono ?? ''
|
||||||
|
const email = (cliente?.email ?? captura?.email ?? '').trim().toLowerCase()
|
||||||
|
const nombre = cliente?.nombreCompleto ?? captura?.nombreLead ?? 'Sin nombre'
|
||||||
|
|
||||||
|
// Contacto fields
|
||||||
|
const contacto: Record<string, string> = {
|
||||||
|
nombre,
|
||||||
|
Teléfono: telefono,
|
||||||
|
Email: email,
|
||||||
|
}
|
||||||
|
if (cliente) {
|
||||||
|
contacto['CURP'] = cliente.curp
|
||||||
|
contacto['RFC'] = cliente.rfc
|
||||||
|
contacto['Proyecto'] = cliente.proyecto
|
||||||
|
}
|
||||||
|
if (captura) {
|
||||||
|
contacto['Etapa'] = captura.etapa
|
||||||
|
contacto['Origen'] = captura.origen ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Historial de pagos (por teléfono o nombre del cliente)
|
||||||
|
const historialPagos: PerfilCompletoContacto['historialPagos'] = []
|
||||||
|
if (cliente) {
|
||||||
|
const planes = await prisma.planPago.findMany({
|
||||||
|
where: { clienteNombre: cliente.nombreCompleto },
|
||||||
|
include: { pagos: true },
|
||||||
|
})
|
||||||
|
for (const plan of planes) {
|
||||||
|
for (const pago of plan.pagos) {
|
||||||
|
const restante = pago.monto - pago.montoPagado
|
||||||
|
const estado = restante <= 0.01 ? 'pagado' : 'pendiente'
|
||||||
|
historialPagos.push({
|
||||||
|
planId: plan.id,
|
||||||
|
clienteNombre: plan.clienteNombre,
|
||||||
|
unidadCodigo: plan.unidadCodigo,
|
||||||
|
proyecto: plan.proyecto,
|
||||||
|
numPago: pago.numPago,
|
||||||
|
fechaVencimiento: pago.fechaVencimiento.toISOString().slice(0, 10),
|
||||||
|
monto: pago.monto,
|
||||||
|
montoPagado: pago.montoPagado,
|
||||||
|
estado,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cotizaciones (por teléfono)
|
||||||
|
const clave = cliente?.celular?.replace(/\D/g, '').slice(-10) ?? ''
|
||||||
|
const cotizacionesRaw = clave !== ''
|
||||||
|
? await prisma.cotizacion.findMany({
|
||||||
|
where: { clienteTelefono: { contains: clave } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Documentos
|
||||||
|
const documentosRaw = cliente
|
||||||
|
? await prisma.documento.findMany({
|
||||||
|
where: { clienteId: cliente.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Lead score
|
||||||
|
let leadScore: number | null = null
|
||||||
|
if (captura) {
|
||||||
|
leadScore = calcularScore({
|
||||||
|
etapa: captura.etapa,
|
||||||
|
telefono: captura.telefono,
|
||||||
|
email: captura.email,
|
||||||
|
origen: captura.origen,
|
||||||
|
createdAt: captura.createdAt,
|
||||||
|
updatedAt: captura.updatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Última actividad
|
||||||
|
let ultimaActividad: PerfilCompletoContacto['ultimaActividad'] = null
|
||||||
|
if (captura) {
|
||||||
|
const ultima = await prisma.actividadLocal.findFirst({
|
||||||
|
where: { leadCapturaId: captura.leadId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
if (ultima) {
|
||||||
|
ultimaActividad = {
|
||||||
|
titulo: ultima.titulo,
|
||||||
|
fecha: ultima.createdAt.toISOString(),
|
||||||
|
tipo: ultima.tipo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contacto,
|
||||||
|
historialPagos,
|
||||||
|
cotizaciones: cotizacionesRaw.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
unidadCodigo: c.unidadCodigo,
|
||||||
|
tipoPropuesta: c.tipoPropuesta,
|
||||||
|
precioFinal: c.precioFinal,
|
||||||
|
createdAt: c.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
documentos: documentosRaw.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
tipo: d.tipo,
|
||||||
|
nombreArchivo: d.nombreArchivo,
|
||||||
|
createdAt: d.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
leadScore,
|
||||||
|
ultimaActividad,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
81
api-gateway/src/services/contactos-exportar.service.test.ts
Normal file
81
api-gateway/src/services/contactos-exportar.service.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const prismaMock = vi.hoisted(() => ({
|
||||||
|
cliente: { findMany: vi.fn() },
|
||||||
|
leadCaptura: { findMany: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||||
|
|
||||||
|
import { exportarCSV } from './contactos-local.service.js'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||||
|
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('exportarCSV', () => {
|
||||||
|
it('genera CSV con encabezados cuando no hay contactos', async () => {
|
||||||
|
const csv = await exportarCSV()
|
||||||
|
expect(csv).toContain('Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('incluye clientes y capturas en el CSV', async () => {
|
||||||
|
prismaMock.cliente.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
nombreCompleto: 'Juan Pérez',
|
||||||
|
celular: '4771234567',
|
||||||
|
email: 'juan@correo.mx',
|
||||||
|
proyecto: 'SENDA',
|
||||||
|
createdAt: new Date('2026-09-01'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
nombreLead: 'Ana López',
|
||||||
|
telefono: '6649998877',
|
||||||
|
email: 'ana@test.com',
|
||||||
|
desarrollo: 'DIAGONAL',
|
||||||
|
etapa: 'NUEVO',
|
||||||
|
updatedAt: new Date('2026-09-05'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const csv = await exportarCSV()
|
||||||
|
const lineas = csv.split('\n')
|
||||||
|
expect(lineas).toHaveLength(3) // header + 2 rows
|
||||||
|
expect(lineas[1]).toContain('Juan Pérez')
|
||||||
|
expect(lineas[1]).toContain('Cliente')
|
||||||
|
expect(lineas[2]).toContain('Ana López')
|
||||||
|
expect(lineas[2]).toContain('NUEVO')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filtra por desarrollo', async () => {
|
||||||
|
prismaMock.cliente.findMany.mockResolvedValue([
|
||||||
|
{ nombreCompleto: 'A', celular: null, email: null, proyecto: 'SENDA', createdAt: new Date() },
|
||||||
|
{ nombreCompleto: 'B', celular: null, email: null, proyecto: 'DIAGONAL', createdAt: new Date() },
|
||||||
|
])
|
||||||
|
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||||
|
|
||||||
|
const csv = await exportarCSV({ desarrollo: 'SENDA' })
|
||||||
|
expect(csv).toContain('A')
|
||||||
|
expect(csv).not.toContain('B')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapa comas y comillas en los campos', async () => {
|
||||||
|
prismaMock.cliente.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
nombreCompleto: 'Pérez, Juan',
|
||||||
|
celular: null,
|
||||||
|
email: null,
|
||||||
|
proyecto: 'SENDA',
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||||
|
|
||||||
|
const csv = await exportarCSV()
|
||||||
|
expect(csv).toContain('"Pérez, Juan"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -209,3 +209,79 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
|||||||
|
|
||||||
return { contacto, propiedad, relaciones, historial }
|
return { contacto, propiedad, relaciones, historial }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Exportar contactos a CSV ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera CSV de todos los contactos (Cliente + LeadCaptura).
|
||||||
|
* Columnas: Nombre, Teléfono, Email, Desarrollo, Estatus, Última actividad.
|
||||||
|
*/
|
||||||
|
export async function exportarCSV(filtros: { desarrollo?: string; estatus?: string } = {}): Promise<string> {
|
||||||
|
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' } })
|
||||||
|
const capturas = await prisma.leadCaptura.findMany({
|
||||||
|
where: {
|
||||||
|
board: { in: ['OPORTUNIDADES', 'APARTADO', 'LEADS'] },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
interface FilaCSV {
|
||||||
|
Nombre: string
|
||||||
|
Teléfono: string
|
||||||
|
Email: string
|
||||||
|
Desarrollo: string
|
||||||
|
Estatus: string
|
||||||
|
'Última actividad': string
|
||||||
|
}
|
||||||
|
|
||||||
|
const filas: FilaCSV[] = []
|
||||||
|
const vistos = new Set<string>()
|
||||||
|
|
||||||
|
for (const c of clientes) {
|
||||||
|
const clave = (c.celular ?? '').replace(/\D/g, '').slice(-10)
|
||||||
|
if (clave !== '' && vistos.has(clave)) continue
|
||||||
|
if (clave !== '') vistos.add(clave)
|
||||||
|
filas.push({
|
||||||
|
Nombre: c.nombreCompleto,
|
||||||
|
Teléfono: c.celular ?? '',
|
||||||
|
Email: c.email ?? '',
|
||||||
|
Desarrollo: c.proyecto,
|
||||||
|
Estatus: 'Cliente',
|
||||||
|
'Última actividad': c.createdAt.toISOString().slice(0, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cap of capturas) {
|
||||||
|
const clave = (cap.telefono ?? '').replace(/\D/g, '').slice(-10)
|
||||||
|
if (clave !== '' && vistos.has(clave)) continue
|
||||||
|
if (clave !== '') vistos.add(clave)
|
||||||
|
filas.push({
|
||||||
|
Nombre: cap.nombreLead,
|
||||||
|
Teléfono: cap.telefono ?? '',
|
||||||
|
Email: cap.email ?? '',
|
||||||
|
Desarrollo: cap.desarrollo ?? '',
|
||||||
|
Estatus: cap.etapa,
|
||||||
|
'Última actividad': cap.updatedAt.toISOString().slice(0, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplicar filtros
|
||||||
|
let filtradas = filas
|
||||||
|
if (filtros.desarrollo) {
|
||||||
|
const d = filtros.desarrollo.toUpperCase()
|
||||||
|
filtradas = filtradas.filter((f) => f.Desarrollo.toUpperCase().includes(d))
|
||||||
|
}
|
||||||
|
if (filtros.estatus) {
|
||||||
|
const e = filtros.estatus.toUpperCase()
|
||||||
|
filtradas = filtradas.filter((f) => f.Estatus.toUpperCase().includes(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar CSV
|
||||||
|
const encabezado = 'Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad'
|
||||||
|
const escape = (s: string) => (s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s)
|
||||||
|
const lineas = filtradas.map((f) =>
|
||||||
|
[f.Nombre, f.Teléfono, f.Email, f.Desarrollo, f.Estatus, f['Última actividad']].map(escape).join(',')
|
||||||
|
)
|
||||||
|
|
||||||
|
return [encabezado, ...lineas].join('\n')
|
||||||
|
}
|
||||||
|
|||||||
131
api-gateway/src/services/contactos-perfil.service.test.ts
Normal file
131
api-gateway/src/services/contactos-perfil.service.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const prismaMock = vi.hoisted(() => ({
|
||||||
|
cliente: { findFirst: vi.fn() },
|
||||||
|
leadCaptura: { findUnique: vi.fn() },
|
||||||
|
planPago: { findMany: vi.fn() },
|
||||||
|
cotizacion: { findMany: vi.fn() },
|
||||||
|
documento: { findMany: vi.fn() },
|
||||||
|
actividadLocal: { findFirst: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||||
|
|
||||||
|
// Mock lead-scoring to avoid circular dependency
|
||||||
|
vi.mock('../services/lead-scoring.service.js', () => ({
|
||||||
|
calcularScore: vi.fn(() => 75),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { perfilCompleto } from './contactos-detalle.service.js'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
prismaMock.cliente.findFirst.mockResolvedValue(null)
|
||||||
|
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
|
||||||
|
prismaMock.planPago.findMany.mockResolvedValue([])
|
||||||
|
prismaMock.cotizacion.findMany.mockResolvedValue([])
|
||||||
|
prismaMock.documento.findMany.mockResolvedValue([])
|
||||||
|
prismaMock.actividadLocal.findFirst.mockResolvedValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('perfilCompleto', () => {
|
||||||
|
it('devuelve null si el contacto no existe', async () => {
|
||||||
|
const result = await perfilCompleto('no-existe')
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arma perfil completo desde Cliente', async () => {
|
||||||
|
prismaMock.cliente.findFirst.mockResolvedValue({
|
||||||
|
id: 'c1',
|
||||||
|
nombreCompleto: 'Juan Pérez',
|
||||||
|
curp: 'CURP123',
|
||||||
|
rfc: 'RFC123',
|
||||||
|
email: 'juan@test.com',
|
||||||
|
celular: '4771234567',
|
||||||
|
proyecto: 'SENDA',
|
||||||
|
unidadCodigo: 'SENDA-101',
|
||||||
|
mondayContactoId: null,
|
||||||
|
createdAt: new Date('2026-09-01'),
|
||||||
|
})
|
||||||
|
|
||||||
|
prismaMock.planPago.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'plan1',
|
||||||
|
clienteNombre: 'Juan Pérez',
|
||||||
|
unidadCodigo: 'SENDA-101',
|
||||||
|
proyecto: 'SENDA',
|
||||||
|
pagos: [
|
||||||
|
{ numPago: 1, fechaVencimiento: new Date('2026-10-01'), monto: 5000, montoPagado: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
prismaMock.documento.findMany.mockResolvedValue([
|
||||||
|
{ id: 'd1', tipo: 'INE', nombreArchivo: 'ine.pdf', createdAt: new Date('2026-09-02') },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await perfilCompleto('c1')
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
expect(result?.contacto['nombre']).toBe('Juan Pérez')
|
||||||
|
expect(result?.contacto['CURP']).toBe('CURP123')
|
||||||
|
expect(result?.historialPagos).toHaveLength(1)
|
||||||
|
expect(result?.historialPagos[0]?.estado).toBe('pendiente')
|
||||||
|
expect(result?.documentos).toHaveLength(1)
|
||||||
|
expect(result?.documentos[0]?.tipo).toBe('INE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arma perfil desde LeadCaptura cuando no hay Cliente', async () => {
|
||||||
|
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||||
|
leadId: 'loc_1',
|
||||||
|
nombreLead: 'Ana López',
|
||||||
|
telefono: '6649998877',
|
||||||
|
email: 'ana@test.com',
|
||||||
|
origen: 'Facebook',
|
||||||
|
etapa: 'INTERESADO',
|
||||||
|
createdAt: new Date('2026-09-05'),
|
||||||
|
updatedAt: new Date('2026-09-07'),
|
||||||
|
})
|
||||||
|
|
||||||
|
prismaMock.actividadLocal.findFirst.mockResolvedValue({
|
||||||
|
titulo: 'Llamar a Ana',
|
||||||
|
tipo: 'SEGUIMIENTO',
|
||||||
|
createdAt: new Date('2026-09-07'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await perfilCompleto('loc_1')
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
expect(result?.contacto['nombre']).toBe('Ana López')
|
||||||
|
expect(result?.contacto['Etapa']).toBe('INTERESADO')
|
||||||
|
expect(result?.leadScore).toBe(75)
|
||||||
|
expect(result?.ultimaActividad?.titulo).toBe('Llamar a Ana')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cotizaciones se incluyen cuando hay teléfono', async () => {
|
||||||
|
prismaMock.cliente.findFirst.mockResolvedValue({
|
||||||
|
id: 'c1',
|
||||||
|
nombreCompleto: 'Test',
|
||||||
|
curp: '',
|
||||||
|
rfc: '',
|
||||||
|
email: null,
|
||||||
|
celular: '4771234567',
|
||||||
|
proyecto: 'A',
|
||||||
|
unidadCodigo: null,
|
||||||
|
mondayContactoId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
prismaMock.cotizacion.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'cot1',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
tipoPropuesta: 'BANCARIO',
|
||||||
|
precioFinal: 3000000,
|
||||||
|
createdAt: new Date('2026-09-01'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await perfilCompleto('c1')
|
||||||
|
expect(result?.cotizaciones).toHaveLength(1)
|
||||||
|
expect(result?.cotizaciones[0]?.tipoPropuesta).toBe('BANCARIO')
|
||||||
|
})
|
||||||
|
})
|
||||||
143
api-gateway/src/services/lead-scoring.service.test.ts
Normal file
143
api-gateway/src/services/lead-scoring.service.test.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const prismaMock = vi.hoisted(() => ({
|
||||||
|
leadCaptura: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||||
|
|
||||||
|
import { calcularScore, obtenerScores } from './lead-scoring.service.js'
|
||||||
|
|
||||||
|
describe('calcularScore', () => {
|
||||||
|
const ahora = new Date('2026-09-08T12:00:00Z')
|
||||||
|
|
||||||
|
it('asigna score base según etapa (NUEVO = 20)', () => {
|
||||||
|
const lead = {
|
||||||
|
etapa: 'NUEVO',
|
||||||
|
telefono: null,
|
||||||
|
email: null,
|
||||||
|
origen: null,
|
||||||
|
createdAt: new Date('2026-09-07T12:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-09-07T12:00:00Z'),
|
||||||
|
}
|
||||||
|
// 20 (etapa) + 5 (recencia <7d) = 25
|
||||||
|
expect(calcularScore(lead, ahora)).toBe(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asigna score CALIFICADO (90)', () => {
|
||||||
|
const lead = {
|
||||||
|
etapa: 'CALIFICADO',
|
||||||
|
telefono: '4771234567',
|
||||||
|
email: 'test@email.com',
|
||||||
|
origen: 'Facebook',
|
||||||
|
createdAt: new Date('2026-09-08T00:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-09-08T00:00:00Z'),
|
||||||
|
}
|
||||||
|
// 90 + 10 (<24h) + 5 tel + 5 email + 5 origen = 115 → clamp 100
|
||||||
|
expect(calcularScore(lead, ahora)).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aplica penalización >30 días sin actividad', () => {
|
||||||
|
const lead = {
|
||||||
|
etapa: 'CONTACTADO',
|
||||||
|
telefono: null,
|
||||||
|
email: null,
|
||||||
|
origen: null,
|
||||||
|
createdAt: new Date('2026-07-01T00:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-07-01T00:00:00Z'),
|
||||||
|
}
|
||||||
|
// 40 (CONTACTADO) + (-20) (>30d) = 20
|
||||||
|
expect(calcularScore(lead, ahora)).toBe(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('score mínimo es 0', () => {
|
||||||
|
const lead = {
|
||||||
|
etapa: 'DESCARTADO',
|
||||||
|
telefono: null,
|
||||||
|
email: null,
|
||||||
|
origen: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
}
|
||||||
|
// 10 (desconocida) + (-20) = -10 → clamp 0
|
||||||
|
expect(calcularScore(lead, ahora)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recencia <24h suma +10', () => {
|
||||||
|
const lead = {
|
||||||
|
etapa: 'NUEVO',
|
||||||
|
telefono: '1234567890',
|
||||||
|
email: 'a@b.com',
|
||||||
|
origen: 'Web',
|
||||||
|
createdAt: new Date('2026-09-08T10:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-09-08T10:00:00Z'),
|
||||||
|
}
|
||||||
|
// 20 + 10 + 5 + 5 + 5 = 45
|
||||||
|
expect(calcularScore(lead, ahora)).toBe(45)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('INTERESADO = 60 + recencia <7d = +5 = 65', () => {
|
||||||
|
const lead = {
|
||||||
|
etapa: 'INTERESADO',
|
||||||
|
telefono: null,
|
||||||
|
email: null,
|
||||||
|
origen: null,
|
||||||
|
createdAt: new Date('2026-09-05T12:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-09-05T12:00:00Z'),
|
||||||
|
}
|
||||||
|
expect(calcularScore(lead, ahora)).toBe(65)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('obtenerScores', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('devuelve scores ordenados de mayor a menor', async () => {
|
||||||
|
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
leadId: 'l1',
|
||||||
|
nombreLead: 'A',
|
||||||
|
usuarioId: 'u1',
|
||||||
|
etapa: 'NUEVO',
|
||||||
|
telefono: null,
|
||||||
|
email: null,
|
||||||
|
origen: null,
|
||||||
|
createdAt: new Date('2026-09-07'),
|
||||||
|
updatedAt: new Date('2026-09-07'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
leadId: 'l2',
|
||||||
|
nombreLead: 'B',
|
||||||
|
usuarioId: 'u1',
|
||||||
|
etapa: 'CALIFICADO',
|
||||||
|
telefono: '1234567890',
|
||||||
|
email: 'b@b.com',
|
||||||
|
origen: 'Web',
|
||||||
|
createdAt: new Date('2026-09-08'),
|
||||||
|
updatedAt: new Date('2026-09-08'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const scores = await obtenerScores()
|
||||||
|
expect(scores).toHaveLength(2)
|
||||||
|
expect(scores[0]!.leadId).toBe('l2')
|
||||||
|
expect(scores[0]!.score).toBeGreaterThan(scores[1]!.score)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filtra por usuarioId cuando se proporciona', async () => {
|
||||||
|
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||||
|
|
||||||
|
await obtenerScores('u1')
|
||||||
|
|
||||||
|
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
||||||
|
usuarioId: 'u1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
107
api-gateway/src/services/lead-scoring.service.ts
Normal file
107
api-gateway/src/services/lead-scoring.service.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { prisma } from './prisma.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lead Scoring: asigna un score 0-100 a cada LeadCaptura basado en:
|
||||||
|
* - Etapa del pipeline (NUEVO=20, CONTACTADO=40, INTERESADO=60, AGENDO=80, CALIFICADO=90)
|
||||||
|
* - Recencia (último cambio/creación): <24h +10, <7d +5, >30d -20
|
||||||
|
* - Datos de contacto: tiene teléfono +5, email +5
|
||||||
|
* - Origen conocido (no vacío): +5
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ETAPA_SCORE: Record<string, number> = {
|
||||||
|
NUEVO: 20,
|
||||||
|
CONTACTADO: 40,
|
||||||
|
INTERESADO: 60,
|
||||||
|
AGENDO: 80,
|
||||||
|
CALIFICADO: 90,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadScore {
|
||||||
|
leadId: string
|
||||||
|
nombreLead: string
|
||||||
|
usuarioId: string
|
||||||
|
etapa: string
|
||||||
|
telefono: string | null
|
||||||
|
email: string | null
|
||||||
|
origen: string | null
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula el score 0-100 de un lead dado su registro de LeadCaptura.
|
||||||
|
* Función pura (testable sin BD).
|
||||||
|
*/
|
||||||
|
export function calcularScore(lead: {
|
||||||
|
etapa: string
|
||||||
|
telefono: string | null
|
||||||
|
email: string | null
|
||||||
|
origen: string | null
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}, ahora: Date = new Date()): number {
|
||||||
|
let score = 0
|
||||||
|
|
||||||
|
// 1. Etapa
|
||||||
|
const etapaNorm = (lead.etapa ?? '').trim().toUpperCase()
|
||||||
|
score += ETAPA_SCORE[etapaNorm] ?? 10
|
||||||
|
|
||||||
|
// 2. Recencia (basado en updatedAt como proxy de "última actividad")
|
||||||
|
const diffMs = ahora.getTime() - lead.updatedAt.getTime()
|
||||||
|
const diffHoras = diffMs / (1000 * 60 * 60)
|
||||||
|
if (diffHoras < 24) {
|
||||||
|
score += 10
|
||||||
|
} else if (diffHoras < 7 * 24) {
|
||||||
|
score += 5
|
||||||
|
} else if (diffHoras > 30 * 24) {
|
||||||
|
score -= 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Datos de contacto
|
||||||
|
if (lead.telefono !== null && lead.telefono.trim() !== '') score += 5
|
||||||
|
if (lead.email !== null && lead.email.trim() !== '') score += 5
|
||||||
|
|
||||||
|
// 4. Origen conocido
|
||||||
|
if (lead.origen !== null && lead.origen.trim() !== '') score += 5
|
||||||
|
|
||||||
|
// Clamp 0-100
|
||||||
|
return Math.max(0, Math.min(100, score))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los scores de todos los leads activos (no descartados), opcionalmente
|
||||||
|
* filtrados por usuarioId.
|
||||||
|
*/
|
||||||
|
export async function obtenerScores(usuarioId?: string): Promise<LeadScore[]> {
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
||||||
|
}
|
||||||
|
if (usuarioId) {
|
||||||
|
where['usuarioId'] = usuarioId
|
||||||
|
}
|
||||||
|
|
||||||
|
const leads = await prisma.leadCaptura.findMany({ where })
|
||||||
|
const ahora = new Date()
|
||||||
|
|
||||||
|
return leads
|
||||||
|
.map((lead) => ({
|
||||||
|
leadId: lead.leadId,
|
||||||
|
nombreLead: lead.nombreLead,
|
||||||
|
usuarioId: lead.usuarioId,
|
||||||
|
etapa: lead.etapa,
|
||||||
|
telefono: lead.telefono,
|
||||||
|
email: lead.email,
|
||||||
|
origen: lead.origen,
|
||||||
|
score: calcularScore(
|
||||||
|
{
|
||||||
|
etapa: lead.etapa,
|
||||||
|
telefono: lead.telefono,
|
||||||
|
email: lead.email,
|
||||||
|
origen: lead.origen,
|
||||||
|
createdAt: lead.createdAt,
|
||||||
|
updatedAt: lead.updatedAt,
|
||||||
|
},
|
||||||
|
ahora,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
}
|
||||||
159
api-gateway/src/services/mora.service.test.ts
Normal file
159
api-gateway/src/services/mora.service.test.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const prismaMock = vi.hoisted(() => ({
|
||||||
|
planPago: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||||
|
|
||||||
|
import { resumenMora } from './mora.service.js'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resumenMora', () => {
|
||||||
|
it('devuelve 0 cuando no hay pagos vencidos', async () => {
|
||||||
|
prismaMock.planPago.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
clienteNombre: 'Juan',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
proyecto: 'SENDA',
|
||||||
|
clienteTelefono: '4771234567',
|
||||||
|
id: 'p1',
|
||||||
|
pagos: [
|
||||||
|
{ numPago: 1, fechaVencimiento: new Date('2026-12-01'), monto: 10000, montoPagado: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await resumenMora()
|
||||||
|
|
||||||
|
expect(result.clientesConMora).toBe(0)
|
||||||
|
expect(result.montoTotalVencido).toBe(0)
|
||||||
|
expect(result.clientes).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detecta pagos vencidos con monto restante', async () => {
|
||||||
|
prismaMock.planPago.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
clienteNombre: 'Juan Pérez',
|
||||||
|
unidadCodigo: 'SENDA-101',
|
||||||
|
proyecto: 'SENDA',
|
||||||
|
clienteTelefono: '4771234567',
|
||||||
|
id: 'plan1',
|
||||||
|
pagos: [
|
||||||
|
{
|
||||||
|
numPago: 1,
|
||||||
|
fechaVencimiento: new Date('2026-08-01'), // vencido
|
||||||
|
monto: 5000,
|
||||||
|
montoPagado: 2000, // parcial: restante = 3000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
numPago: 2,
|
||||||
|
fechaVencimiento: new Date('2026-09-01'), // vencido
|
||||||
|
monto: 5000,
|
||||||
|
montoPagado: 0, // sin pagar
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await resumenMora()
|
||||||
|
|
||||||
|
expect(result.clientesConMora).toBe(2)
|
||||||
|
expect(result.montoTotalVencido).toBe(8000)
|
||||||
|
expect(result.diasPromedioAtraso).toBeGreaterThan(0)
|
||||||
|
expect(result.clientes[0]?.clienteNombre).toBe('Juan Pérez')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ordena por días de atraso (mayor primero)', async () => {
|
||||||
|
prismaMock.planPago.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
clienteNombre: 'Corto',
|
||||||
|
unidadCodigo: 'U-1',
|
||||||
|
proyecto: 'A',
|
||||||
|
clienteTelefono: null,
|
||||||
|
id: 'p1',
|
||||||
|
pagos: [
|
||||||
|
{ numPago: 1, fechaVencimiento: new Date('2026-08-25'), monto: 1000, montoPagado: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clienteNombre: 'Largo',
|
||||||
|
unidadCodigo: 'U-2',
|
||||||
|
proyecto: 'B',
|
||||||
|
clienteTelefono: null,
|
||||||
|
id: 'p2',
|
||||||
|
pagos: [
|
||||||
|
{ numPago: 1, fechaVencimiento: new Date('2026-07-01'), monto: 2000, montoPagado: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await resumenMora()
|
||||||
|
|
||||||
|
expect(result.clientes[0]?.clienteNombre).toBe('Largo')
|
||||||
|
expect(result.clientes[0]?.diasAtraso).toBeGreaterThan(result.clientes[1]!.diasAtraso)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignora pagos totalmente cubiertos', async () => {
|
||||||
|
prismaMock.planPago.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
clienteNombre: 'Cliente',
|
||||||
|
unidadCodigo: 'U-1',
|
||||||
|
proyecto: 'A',
|
||||||
|
clienteTelefono: null,
|
||||||
|
id: 'p1',
|
||||||
|
pagos: [
|
||||||
|
{ numPago: 1, fechaVencimiento: new Date('2026-08-01'), monto: 5000, montoPagado: 5000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await resumenMora()
|
||||||
|
expect(result.clientesConMora).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignora pagos cuyo vencimiento es futuro', async () => {
|
||||||
|
prismaMock.planPago.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
clienteNombre: 'Cliente',
|
||||||
|
unidadCodigo: 'U-1',
|
||||||
|
proyecto: 'A',
|
||||||
|
clienteTelefono: null,
|
||||||
|
id: 'p1',
|
||||||
|
pagos: [
|
||||||
|
{ numPago: 1, fechaVencimiento: new Date('2026-12-01'), monto: 5000, montoPagado: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await resumenMora()
|
||||||
|
expect(result.clientesConMora).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excluye planes cancelados', async () => {
|
||||||
|
prismaMock.planPago.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
clienteNombre: 'Cancelado',
|
||||||
|
unidadCodigo: 'U-1',
|
||||||
|
proyecto: 'A',
|
||||||
|
clienteTelefono: null,
|
||||||
|
id: 'p1',
|
||||||
|
pagos: [
|
||||||
|
{ numPago: 1, fechaVencimiento: new Date('2026-08-01'), monto: 5000, montoPagado: 0 },
|
||||||
|
],
|
||||||
|
status: 'CANCELADO',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// The where clause filters out CANCELADO before they reach the loop
|
||||||
|
prismaMock.planPago.findMany.mockResolvedValue([])
|
||||||
|
|
||||||
|
const result = await resumenMora()
|
||||||
|
expect(result.clientesConMora).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
89
api-gateway/src/services/mora.service.ts
Normal file
89
api-gateway/src/services/mora.service.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { prisma } from './prisma.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard de mora: clientes con pagos vencidos (fechaVencimiento < hoy
|
||||||
|
* y montoPagado < monto). Devuelve resumen + detalle por cliente.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ClienteMora {
|
||||||
|
clienteNombre: string
|
||||||
|
unidadCodigo: string
|
||||||
|
proyecto: string
|
||||||
|
pagoVencido: number
|
||||||
|
monto: number
|
||||||
|
diasAtraso: number
|
||||||
|
telefono: string | null
|
||||||
|
planId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumenMora {
|
||||||
|
clientesConMora: number
|
||||||
|
montoTotalVencido: number
|
||||||
|
diasPromedioAtraso: number
|
||||||
|
clientes: ClienteMora[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula días naturales de diferencia entre hoy y la fecha de vencimiento.
|
||||||
|
*/
|
||||||
|
function diasDesde(fechaVencimiento: Date, hoy: Date): number {
|
||||||
|
const hv = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
|
||||||
|
const fv = new Date(
|
||||||
|
fechaVencimiento.getFullYear(),
|
||||||
|
fechaVencimiento.getMonth(),
|
||||||
|
fechaVencimiento.getDate(),
|
||||||
|
)
|
||||||
|
return Math.max(0, Math.round((hv.getTime() - fv.getTime()) / 86_400_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumen de mora: todos los pagos vencidos de planes activos.
|
||||||
|
*/
|
||||||
|
export async function resumenMora(): Promise<ResumenMora> {
|
||||||
|
const hoy = new Date()
|
||||||
|
const inicioHoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
|
||||||
|
|
||||||
|
const planes = await prisma.planPago.findMany({
|
||||||
|
where: { status: { not: 'CANCELADO' } },
|
||||||
|
include: { pagos: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const clientes: ClienteMora[] = []
|
||||||
|
|
||||||
|
for (const plan of planes) {
|
||||||
|
for (const pago of plan.pagos) {
|
||||||
|
const restante = pago.monto - pago.montoPagado
|
||||||
|
if (restante <= 0) continue // ya pagado
|
||||||
|
const fv = pago.fechaVencimiento
|
||||||
|
if (fv >= inicioHoy) continue // aún no vence
|
||||||
|
|
||||||
|
const dias = diasDesde(fv, hoy)
|
||||||
|
clientes.push({
|
||||||
|
clienteNombre: plan.clienteNombre,
|
||||||
|
unidadCodigo: plan.unidadCodigo,
|
||||||
|
proyecto: plan.proyecto,
|
||||||
|
pagoVencido: pago.numPago,
|
||||||
|
monto: Math.round(restante * 100) / 100,
|
||||||
|
diasAtraso: dias,
|
||||||
|
telefono: plan.clienteTelefono,
|
||||||
|
planId: plan.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por días de atraso (mayor primero)
|
||||||
|
clientes.sort((a, b) => b.diasAtraso - a.diasAtraso)
|
||||||
|
|
||||||
|
const montoTotalVencido = Math.round(clientes.reduce((acc, c) => acc + c.monto, 0) * 100) / 100
|
||||||
|
const diasPromedioAtraso =
|
||||||
|
clientes.length > 0
|
||||||
|
? Math.round(clientes.reduce((acc, c) => acc + c.diasAtraso, 0) / clientes.length)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientesConMora: clientes.length,
|
||||||
|
montoTotalVencido,
|
||||||
|
diasPromedioAtraso,
|
||||||
|
clientes,
|
||||||
|
}
|
||||||
|
}
|
||||||
138
api-gateway/src/services/recordatorio-vendedor.service.test.ts
Normal file
138
api-gateway/src/services/recordatorio-vendedor.service.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const prismaMock = vi.hoisted(() => ({
|
||||||
|
recordatorioVendedor: {
|
||||||
|
create: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||||
|
|
||||||
|
import { RecordatorioVendedorService } from './recordatorio-vendedor.service.js'
|
||||||
|
|
||||||
|
const svc = new RecordatorioVendedorService()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RecordatorioVendedorService', () => {
|
||||||
|
const usuarioId = 'u-1'
|
||||||
|
|
||||||
|
describe('crear', () => {
|
||||||
|
it('crea un recordatorio con los datos proporcionados', async () => {
|
||||||
|
prismaMock.recordatorioVendedor.create.mockResolvedValue({
|
||||||
|
id: 'r1',
|
||||||
|
usuarioId,
|
||||||
|
titulo: 'Llamar a Juan',
|
||||||
|
descripcion: 'Sobre cotización',
|
||||||
|
leadId: null,
|
||||||
|
fechaRecordar: new Date('2026-09-10T10:00:00'),
|
||||||
|
completado: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await svc.crear(usuarioId, {
|
||||||
|
titulo: 'Llamar a Juan',
|
||||||
|
descripcion: 'Sobre cotización',
|
||||||
|
leadId: null,
|
||||||
|
fechaRecordar: '2026-09-10T10:00:00',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.id).toBe('r1')
|
||||||
|
expect(prismaMock.recordatorioVendedor.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
usuarioId,
|
||||||
|
titulo: 'Llamar a Juan',
|
||||||
|
descripcion: 'Sobre cotización',
|
||||||
|
leadId: null,
|
||||||
|
fechaRecordar: new Date('2026-09-10T10:00:00'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listar', () => {
|
||||||
|
it('lista recordatorios del usuario ordenados por fechaRecordar asc', async () => {
|
||||||
|
prismaMock.recordatorioVendedor.findMany.mockResolvedValue([
|
||||||
|
{ id: 'r1', titulo: 'A', fechaRecordar: new Date('2026-09-10') },
|
||||||
|
{ id: 'r2', titulo: 'B', fechaRecordar: new Date('2026-09-12') },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await svc.listar(usuarioId)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(prismaMock.recordatorioVendedor.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { usuarioId },
|
||||||
|
orderBy: { fechaRecordar: 'asc' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filtra por completados cuando se especifica', async () => {
|
||||||
|
prismaMock.recordatorioVendedor.findMany.mockResolvedValue([])
|
||||||
|
|
||||||
|
await svc.listar(usuarioId, { completados: true })
|
||||||
|
|
||||||
|
expect(prismaMock.recordatorioVendedor.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { usuarioId, completado: true },
|
||||||
|
orderBy: { fechaRecordar: 'asc' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('completar', () => {
|
||||||
|
it('marca como completado un recordatorio existente', async () => {
|
||||||
|
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1' })
|
||||||
|
prismaMock.recordatorioVendedor.update.mockResolvedValue({ id: 'r1', completado: true })
|
||||||
|
|
||||||
|
const result = await svc.completar('r1')
|
||||||
|
|
||||||
|
expect(result.completado).toBe(true)
|
||||||
|
expect(prismaMock.recordatorioVendedor.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'r1' },
|
||||||
|
data: { completado: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lanza error si el recordatorio no existe', async () => {
|
||||||
|
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue(null)
|
||||||
|
|
||||||
|
await expect(svc.completar('no-existe')).rejects.toThrow('Recordatorio no encontrado')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('eliminar', () => {
|
||||||
|
it('elimina un recordatorio existente', async () => {
|
||||||
|
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1' })
|
||||||
|
prismaMock.recordatorioVendedor.delete.mockResolvedValue({ id: 'r1' })
|
||||||
|
|
||||||
|
await svc.eliminar('r1')
|
||||||
|
|
||||||
|
expect(prismaMock.recordatorioVendedor.delete).toHaveBeenCalledWith({ where: { id: 'r1' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lanza error si el recordatorio no existe', async () => {
|
||||||
|
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue(null)
|
||||||
|
|
||||||
|
await expect(svc.eliminar('no-existe')).rejects.toThrow('Recordatorio no encontrado')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('proximos', () => {
|
||||||
|
it('devuelve recordatorios no completados en las próximas N horas', async () => {
|
||||||
|
prismaMock.recordatorioVendedor.findMany.mockResolvedValue([{ id: 'r1' }])
|
||||||
|
|
||||||
|
const result = await svc.proximos(usuarioId, 24)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
const where = prismaMock.recordatorioVendedor.findMany.mock.calls[0]?.[0]?.where
|
||||||
|
expect(where?.['usuarioId']).toBe(usuarioId)
|
||||||
|
expect(where?.['completado']).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
91
api-gateway/src/services/recordatorio-vendedor.service.ts
Normal file
91
api-gateway/src/services/recordatorio-vendedor.service.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { prisma } from './prisma.js'
|
||||||
|
import { NotFoundError } from '../utils/errors.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD de recordatorios personales del vendedor (tabla RecordatorioVendedor).
|
||||||
|
* Cada vendedor crea recordatorios con fecha/hora a recordar; el front los
|
||||||
|
* muestra en "Mis recordatorios" de Mi día.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DatosCrearRecordatorio {
|
||||||
|
titulo: string
|
||||||
|
descripcion?: string | null
|
||||||
|
leadId?: string | null
|
||||||
|
fechaRecordar: string // ISO o YYYY-MM-DDTHH:mm
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpcionesListarRecordatorio {
|
||||||
|
completados?: boolean
|
||||||
|
desde?: string | null // ISO
|
||||||
|
hasta?: string | null // ISO
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecordatorioVendedorService {
|
||||||
|
async crear(usuarioId: string, data: DatosCrearRecordatorio) {
|
||||||
|
return prisma.recordatorioVendedor.create({
|
||||||
|
data: {
|
||||||
|
usuarioId,
|
||||||
|
titulo: data.titulo.trim(),
|
||||||
|
descripcion: data.descripcion?.trim() ?? null,
|
||||||
|
leadId: data.leadId ?? null,
|
||||||
|
fechaRecordar: new Date(data.fechaRecordar),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async listar(usuarioId: string, opciones: OpcionesListarRecordatorio = {}) {
|
||||||
|
const where: Record<string, unknown> = { usuarioId }
|
||||||
|
|
||||||
|
if (opciones.completados !== undefined) {
|
||||||
|
where['completado'] = opciones.completados
|
||||||
|
}
|
||||||
|
if (opciones.desde !== null && opciones.desde !== undefined) {
|
||||||
|
where['fechaRecordar'] = where['fechaRecordar'] ?? {}
|
||||||
|
;(where['fechaRecordar'] as Record<string, Date>)['gte'] = new Date(opciones.desde)
|
||||||
|
}
|
||||||
|
if (opciones.hasta !== null && opciones.hasta !== undefined) {
|
||||||
|
where['fechaRecordar'] = where['fechaRecordar'] ?? {}
|
||||||
|
;(where['fechaRecordar'] as Record<string, Date>)['lte'] = new Date(opciones.hasta)
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.recordatorioVendedor.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { fechaRecordar: 'asc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async completar(id: string) {
|
||||||
|
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
|
||||||
|
if (!existente) throw new NotFoundError('Recordatorio no encontrado')
|
||||||
|
return prisma.recordatorioVendedor.update({
|
||||||
|
where: { id },
|
||||||
|
data: { completado: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async eliminar(id: string) {
|
||||||
|
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
|
||||||
|
if (!existente) throw new NotFoundError('Recordatorio no encontrado')
|
||||||
|
return prisma.recordatorioVendedor.delete({ where: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Próximos a vencer: recordatorios no completados cuya fechaRecordar está
|
||||||
|
* dentro de las próximas `horas` horas (default 48h).
|
||||||
|
*/
|
||||||
|
async proximos(usuarioId: string, horas = 48) {
|
||||||
|
const desde = new Date()
|
||||||
|
const hasta = new Date(desde.getTime() + horas * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
return prisma.recordatorioVendedor.findMany({
|
||||||
|
where: {
|
||||||
|
usuarioId,
|
||||||
|
completado: false,
|
||||||
|
fechaRecordar: { gte: desde, lte: hasta },
|
||||||
|
},
|
||||||
|
orderBy: { fechaRecordar: 'asc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const recordatorioVendedorService = new RecordatorioVendedorService()
|
||||||
106
apps/alta-clientes/src/components/ChecklistDocumentos.tsx
Normal file
106
apps/alta-clientes/src/components/ChecklistDocumentos.tsx
Normal file
@@ -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<Set<string>>(() => 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 (
|
||||||
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Documentos requeridos
|
||||||
|
</h3>
|
||||||
|
<span className="rounded-full bg-blue-50 dark:bg-blue-900/30 px-2.5 py-0.5 text-xs font-semibold text-blue-700 dark:text-blue-400">
|
||||||
|
{completados} de {total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barra de progreso */}
|
||||||
|
<div className="mt-3 h-2 rounded-full bg-gray-100 dark:bg-[#334155]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-green-500 transition-all duration-300"
|
||||||
|
style={{ width: `${total > 0 ? (completados / total) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-4 space-y-2">
|
||||||
|
{documentos.map((doc) => {
|
||||||
|
const recibido = recibidos.has(doc.tipo)
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={doc.tipo}
|
||||||
|
onClick={() => 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'}`}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{iconoDoc(recibido, doc.obligatorio)}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className={`text-sm font-medium ${recibido ? 'text-green-700 dark:text-green-400 line-through' : 'text-[#111827] dark:text-[#E2E8F0]'}`}>
|
||||||
|
{doc.nombre}
|
||||||
|
</p>
|
||||||
|
{doc.obligatorio && !recibido && (
|
||||||
|
<p className="text-[10px] text-amber-600 dark:text-amber-400">Requerido</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!soloLectura && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={recibido}
|
||||||
|
onChange={() => toggle(doc.tipo)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
162
apps/alta-clientes/src/components/FotoCliente.tsx
Normal file
162
apps/alta-clientes/src/components/FotoCliente.tsx
Normal file
@@ -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<string | null>(foto ?? null)
|
||||||
|
const [mostrandoCamara, setMostrandoCamara] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const streamRef = useRef<MediaStream | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
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 ? (
|
||||||
|
<img src={preview} alt="Foto del cliente" className="h-24 w-24 rounded-lg object-cover" />
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[#94A3B8]">Sin foto</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Preview */}
|
||||||
|
{preview && !mostrandoCamara && (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<img src={preview} alt="Foto del cliente" className="h-32 w-32 rounded-lg object-cover border border-[#E5E7EB]" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setPreview(null); onFoto?.('') }}
|
||||||
|
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white hover:bg-red-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cámara */}
|
||||||
|
{mostrandoCamara && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="relative overflow-hidden rounded-lg border border-[#E5E7EB] dark:border-[#334155]">
|
||||||
|
<video ref={videoRef} className="w-full max-w-xs rounded-lg" muted playsInline />
|
||||||
|
</div>
|
||||||
|
<canvas ref={canvasRef} className="hidden" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={capturar}
|
||||||
|
className="rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Capturar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={detenerCamara}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-xs font-medium text-[#374151] hover:bg-[#F9FAFB] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1]"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botones de acción */}
|
||||||
|
{!mostrandoCamara && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={iniciarCamara}
|
||||||
|
className="rounded-lg border border-accent bg-white px-4 py-2 text-xs font-medium text-accent shadow-sm transition-all hover:bg-accent hover:text-white dark:bg-[#0F172A]"
|
||||||
|
>
|
||||||
|
📷 Tomar foto
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-xs font-medium text-[#374151] shadow-sm hover:bg-[#F9FAFB] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1]"
|
||||||
|
>
|
||||||
|
📁 Subir foto
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="user"
|
||||||
|
className="hidden"
|
||||||
|
onChange={manejarArchivo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
apps/alta-clientes/src/data/cps.ts
Normal file
89
apps/alta-clientes/src/data/cps.ts
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
import { useFormContext } from 'react-hook-form'
|
import { useFormContext } from 'react-hook-form'
|
||||||
import { CampoTexto, CampoSelect, CampoTelefono } from '../components/Campos'
|
import { CampoTexto, CampoSelect, CampoTelefono } from '../components/Campos'
|
||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
import { compararConDatos } from '../lib/curp'
|
import { compararConDatos } from '../lib/curp'
|
||||||
import { ESTADOS_CIVILES, REGIMENES_BIENES, TIPOS_VIVIENDA, TIPOS_COMPRA } from '../data/constantes'
|
import { ESTADOS_CIVILES, REGIMENES_BIENES, TIPOS_VIVIENDA, TIPOS_COMPRA } from '../data/constantes'
|
||||||
|
import { autocompletarPorCP } from '../data/cps'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
prefix: string
|
prefix: string
|
||||||
@@ -11,7 +13,7 @@ interface Props {
|
|||||||
|
|
||||||
/** Campos de datos generales; reutilizable con prefijo (cliente y co-acreditado) */
|
/** Campos de datos generales; reutilizable con prefijo (cliente y co-acreditado) */
|
||||||
export default function DatosGeneralesCampos({ prefix, compacto = false }: Props) {
|
export default function DatosGeneralesCampos({ prefix, compacto = false }: Props) {
|
||||||
const { watch } = useFormContext()
|
const { watch, setValue } = useFormContext()
|
||||||
const p = (campo: string) => `${prefix}${campo}`
|
const p = (campo: string) => `${prefix}${campo}`
|
||||||
const estadoCivil = watch(p('estadoCivil')) as string
|
const estadoCivil = watch(p('estadoCivil')) as string
|
||||||
const curp = (watch(p('curp')) 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
|
// Advertencias ámbar en vivo: no bloquean, solo alertan inconsistencias con la CURP
|
||||||
const advertenciasCurp = compararConDatos(curp, { fechaNacimiento, lugarNacimiento })
|
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 (
|
return (
|
||||||
<SectionCard titulo="Datos generales">
|
<SectionCard titulo="Datos generales">
|
||||||
{!compacto && <CampoTexto nombre={p('campaniaVenta')} etiqueta="Campaña de la venta" />}
|
{!compacto && <CampoTexto nombre={p('campaniaVenta')} etiqueta="Campaña de la venta" />}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import { apartadoAPrellenado } from '../lib/apartadoAlta'
|
|||||||
import { leadAPrellenado } from '../lib/leadAlta'
|
import { leadAPrellenado } from '../lib/leadAlta'
|
||||||
import BusquedaLead from '../components/BusquedaLead'
|
import BusquedaLead from '../components/BusquedaLead'
|
||||||
import AltaAfluenciaForm from '../components/AltaAfluenciaForm'
|
import AltaAfluenciaForm from '../components/AltaAfluenciaForm'
|
||||||
|
import FotoCliente from '../components/FotoCliente'
|
||||||
|
import ChecklistDocumentos from '../components/ChecklistDocumentos'
|
||||||
|
|
||||||
/** Lee ?cotizacion=<id> del query string o del hash (#cotizacion=<id>) */
|
/** Lee ?cotizacion=<id> del query string o del hash (#cotizacion=<id>) */
|
||||||
function paramCotizacion(): string | null {
|
function paramCotizacion(): string | null {
|
||||||
@@ -531,16 +533,33 @@ export default function AltaClientePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{paso === 0 && (
|
{paso === 0 && (
|
||||||
<TarjetaPaso>
|
<>
|
||||||
<DatosGeneralesForm
|
<TarjetaPaso>
|
||||||
valores={datos.datosGenerales}
|
<DatosGeneralesForm
|
||||||
onSiguiente={(v) => {
|
valores={datos.datosGenerales}
|
||||||
actualizar('datosGenerales', v)
|
onSiguiente={(v) => {
|
||||||
siguiente()
|
actualizar('datosGenerales', v)
|
||||||
guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
|
siguiente()
|
||||||
}}
|
guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
|
||||||
/>
|
}}
|
||||||
</TarjetaPaso>
|
/>
|
||||||
|
</TarjetaPaso>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Foto del cliente
|
||||||
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<FotoCliente
|
||||||
|
foto={null}
|
||||||
|
onFoto={() => {
|
||||||
|
// Foto capturada (el componente gestiona el preview internamente)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{paso === 1 && (
|
{paso === 1 && (
|
||||||
<TarjetaPaso>
|
<TarjetaPaso>
|
||||||
@@ -635,6 +654,12 @@ export default function AltaClientePage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TarjetaPaso>
|
</TarjetaPaso>
|
||||||
|
<div className="mt-4">
|
||||||
|
<ChecklistDocumentos
|
||||||
|
recibidos={alta.documentos.map((d) => d.slot)}
|
||||||
|
onToggle={() => { /* toggle se maneja internamente */ }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{paso === 7 && (
|
{paso === 7 && (
|
||||||
|
|||||||
134
apps/cobranza/src/components/MoraDashboard.tsx
Normal file
134
apps/cobranza/src/components/MoraDashboard.tsx
Normal file
@@ -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<MoraResponse | null>(null)
|
||||||
|
const [cargando, setCargando] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
setCargando(true)
|
||||||
|
try {
|
||||||
|
const r = await request<MoraResponse>('/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 (
|
||||||
|
<div className="flex h-32 items-center justify-center rounded-xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
|
<p className="text-sm text-[#94A3B8]">Cargando reporte de mora…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-900/50 dark:bg-red-950/30">
|
||||||
|
<p className="text-sm font-medium text-red-700 dark:text-red-400">{error}</p>
|
||||||
|
<button type="button" onClick={() => void cargar()} className="mt-3 rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-accent-hover">
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!datos || datos.clientes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-center dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||||
|
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">No hay clientes con pagos vencidos</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<KpiCard titulo="Total morosos" valor={datos.totalMorosos} />
|
||||||
|
<KpiCard titulo="Monto vencido" valor={formatoMoneda(datos.montoTotalVencido)} />
|
||||||
|
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedio} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
<th scope="col" className="px-4 py-3">Cliente</th>
|
||||||
|
<th scope="col" className="px-4 py-3">Unidad</th>
|
||||||
|
<th scope="col" className="px-4 py-3">Proyecto</th>
|
||||||
|
<th scope="col" className="px-4 py-3">Pago vencido</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-right">Monto</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-right">Días atraso</th>
|
||||||
|
<th scope="col" className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#E5E7EB]">
|
||||||
|
{datos.clientes.map((c) => (
|
||||||
|
<tr key={c.clienteId} className="transition-colors hover:bg-[#F8FAFC] dark:hover:bg-[#0F172A]">
|
||||||
|
<td className="px-4 py-3 font-medium text-[#111827] dark:text-[#E2E8F0]">{c.clienteNombre}</td>
|
||||||
|
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.unidadCodigo}</td>
|
||||||
|
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.proyecto}</td>
|
||||||
|
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.pagoVencido.slice(0, 10)}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(c.monto)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||||
|
c.diasAtraso > 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
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{c.clienteTelefono && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => abrirWhatsApp(c.clienteTelefono)}
|
||||||
|
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-green-700"
|
||||||
|
>
|
||||||
|
WhatsApp
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
apps/cobranza/src/components/TimelinePagos.tsx
Normal file
90
apps/cobranza/src/components/TimelinePagos.tsx
Normal file
@@ -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 <p className="py-4 text-center text-sm text-[#94A3B8]">Sin pagos registrados</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto pb-2">
|
||||||
|
<div className="flex items-start gap-0 min-w-max">
|
||||||
|
{pagos.map((pago, idx) => {
|
||||||
|
const st = statusPago(pago, hoy)
|
||||||
|
return (
|
||||||
|
<div key={pago.id} className="flex flex-col items-center relative" style={{ minWidth: 100 }}>
|
||||||
|
{/* Línea conectora (excepto el primero) */}
|
||||||
|
{idx > 0 && (
|
||||||
|
<div className="absolute top-3 right-1/2 w-full h-0.5 bg-gray-200 dark:bg-[#334155] -z-0" style={{ width: 100, right: '50%' }} />
|
||||||
|
)}
|
||||||
|
{/* Nodo */}
|
||||||
|
<div className={`relative z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 ${colorNodo(st)}`}>
|
||||||
|
{st === 'PAGADO' && (
|
||||||
|
<svg className="h-3 w-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{st === 'VENCIDO' && (
|
||||||
|
<svg className="h-3 w-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Info debajo */}
|
||||||
|
<div className="mt-2 text-center px-1">
|
||||||
|
<p className="text-[10px] font-medium text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
#{pago.numPago}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-[#94A3B8]">
|
||||||
|
{formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
{formatoMoneda(pago.monto)}
|
||||||
|
</p>
|
||||||
|
<p className={`text-[10px] font-semibold ${colorEtiqueta(st)}`}>
|
||||||
|
{etiqueta(st)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
sumarMeses,
|
sumarMeses,
|
||||||
} from '../lib/cobranza'
|
} from '../lib/cobranza'
|
||||||
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
||||||
|
import MoraDashboard from "../components/MoraDashboard"
|
||||||
import KpiCard from '../components/KpiCard'
|
import KpiCard from '../components/KpiCard'
|
||||||
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
|
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
|
||||||
|
|
||||||
@@ -422,6 +423,9 @@ export default function DashboardPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<section className="mt-8">
|
||||||
|
<MoraDashboard />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { descargarRecibo } from '../lib/pdf/reciboAbono'
|
|||||||
import BadgeStatus from '../components/BadgeStatus'
|
import BadgeStatus from '../components/BadgeStatus'
|
||||||
import KpiCard from '../components/KpiCard'
|
import KpiCard from '../components/KpiCard'
|
||||||
import ModalAbono from '../components/ModalAbono'
|
import ModalAbono from '../components/ModalAbono'
|
||||||
|
import TimelinePagos from '../components/TimelinePagos'
|
||||||
|
|
||||||
export default function PlanDetallePage() {
|
export default function PlanDetallePage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -210,6 +211,16 @@ export default function PlanDetallePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Timeline visual de pagos */}
|
||||||
|
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" aria-labelledby="titulo-timeline">
|
||||||
|
<h2 id="titulo-timeline" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Línea de pagos
|
||||||
|
</h2>
|
||||||
|
<div className="mt-4">
|
||||||
|
<TimelinePagos pagos={plan.pagos} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Tabla de pagos */}
|
{/* Tabla de pagos */}
|
||||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
@@ -278,10 +289,34 @@ export default function PlanDetallePage() {
|
|||||||
{expandido && (
|
{expandido && (
|
||||||
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
|
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||||
<td colSpan={tasaMora > 0 ? 9 : 8} className="px-4 py-4">
|
<td colSpan={tasaMora > 0 ? 9 : 8} className="px-4 py-4">
|
||||||
|
{/* Detalle del pago vencido */}
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-[#64748B] dark:text-[#94A3B8]">Vencimiento:</span>{' '}
|
||||||
|
<span className="text-[#111827] dark:text-[#E2E8F0]">{formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-[#64748B] dark:text-[#94A3B8]">Monto:</span>{' '}
|
||||||
|
<span className="text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(pago.monto)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-[#64748B] dark:text-[#94A3B8]">Pagado:</span>{' '}
|
||||||
|
<span className="text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(pago.montoPagado)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-[#64748B] dark:text-[#94A3B8]">Restante:</span>{' '}
|
||||||
|
<span className={`font-medium ${restante > 0 ? 'text-red-700' : 'text-green-700'}`}>{formatoMoneda(restante)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Historial de abonos */}
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Historial de abonos
|
||||||
|
</p>
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
<tr className="text-left font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||||
<th scope="col" className="py-1">Fecha</th>
|
<th scope="col" className="py-1">Fecha pago</th>
|
||||||
|
<th scope="col" className="py-1">Vencimiento</th>
|
||||||
<th scope="col" className="py-1 text-right">Monto</th>
|
<th scope="col" className="py-1 text-right">Monto</th>
|
||||||
<th scope="col" className="py-1">Referencia</th>
|
<th scope="col" className="py-1">Referencia</th>
|
||||||
<th scope="col" className="py-1 text-right">Recibo</th>
|
<th scope="col" className="py-1 text-right">Recibo</th>
|
||||||
@@ -290,9 +325,10 @@ export default function PlanDetallePage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{pago.aplicaciones.map((a) => (
|
{pago.aplicaciones.map((a) => (
|
||||||
<tr key={a.id} className="border-t border-[#E5E7EB] dark:border-[#1E293B]">
|
<tr key={a.id} className="border-t border-[#E5E7EB] dark:border-[#1E293B]">
|
||||||
<td className="py-1">{formatoFechaCorta(a.fechaPago)}</td>
|
<td className="py-1">{formatoFechaCorta(a.fechaPago.slice(0, 10))}</td>
|
||||||
|
<td className="py-1 text-[#94A3B8]">{formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))}</td>
|
||||||
<td className="py-1 text-right">{formatoMoneda(a.monto)}</td>
|
<td className="py-1 text-right">{formatoMoneda(a.monto)}</td>
|
||||||
<td className="py-1">{a.referencia}</td>
|
<td className="py-1">{a.referencia || '—'}</td>
|
||||||
<td className="py-1 text-right">
|
<td className="py-1 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => descargarRecibo(plan, pago, a)}
|
onClick={() => descargarRecibo(plan, pago, a)}
|
||||||
|
|||||||
183
apps/landing/src/components/PerfilContactoCompleto.tsx
Normal file
183
apps/landing/src/components/PerfilContactoCompleto.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { getContactoDetalle, type DetalleContacto } from '../services/api'
|
||||||
|
|
||||||
|
const formatoFecha = (iso: string): string =>
|
||||||
|
iso
|
||||||
|
? new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const formatoMoneda = (n: number): string =>
|
||||||
|
n > 0 ? n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }) : '—'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
contactoId: string
|
||||||
|
nombre: string
|
||||||
|
onCerrar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal con perfil completo del contacto: datos personales, contacto,
|
||||||
|
* propiedad, historial pagos, documentos, última actividad.
|
||||||
|
*/
|
||||||
|
export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }: Props) {
|
||||||
|
const [detalle, setDetalle] = useState<DetalleContacto | null>(null)
|
||||||
|
const [cargando, setCargando] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
setCargando(true)
|
||||||
|
try {
|
||||||
|
setDetalle(await getContactoDetalle(contactoId))
|
||||||
|
setError('')
|
||||||
|
} catch {
|
||||||
|
setError('No se pudo cargar el perfil del contacto')
|
||||||
|
} finally {
|
||||||
|
setCargando(false)
|
||||||
|
}
|
||||||
|
}, [contactoId])
|
||||||
|
|
||||||
|
useEffect(() => { void cargar() }, [cargar])
|
||||||
|
|
||||||
|
// Cerrar con Escape
|
||||||
|
useEffect(() => {
|
||||||
|
const alTeclear = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onCerrar()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', alTeclear)
|
||||||
|
return () => window.removeEventListener('keydown', alTeclear)
|
||||||
|
}, [onCerrar])
|
||||||
|
|
||||||
|
const c = detalle?.contacto ?? {}
|
||||||
|
const prop = detalle?.propiedad ?? null
|
||||||
|
const hist = detalle?.historial ?? []
|
||||||
|
const rels = detalle?.relaciones ?? { leads: [], oportunidades: [], apartados: [] }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-20 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onCerrar() }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-lg rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] pb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Perfil del contacto</p>
|
||||||
|
<h2 className="mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{c['nombre'] ?? nombre}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCerrar}
|
||||||
|
className="rounded-lg px-2 py-1.5 text-sm text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cargando ? (
|
||||||
|
<p className="mt-8 text-center text-sm text-[#94A3B8]">Cargando…</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="mt-8 text-center text-sm text-red-600">{error}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 space-y-5">
|
||||||
|
{/* Datos personales */}
|
||||||
|
<Section titulo="Datos personales">
|
||||||
|
<Field label="Email" value={c['Email']} />
|
||||||
|
<Field label="Teléfono" value={c['Teléfono']} />
|
||||||
|
<Field label="Estado" value={c['Estado']} />
|
||||||
|
<Field label="CURP" value={c['CURP']} />
|
||||||
|
<Field label="RFC" value={c['RFC']} />
|
||||||
|
<Field label="Estado civil" value={c['Estado Civil']} />
|
||||||
|
<Field label="Fecha nacimiento" value={c['Fecha Nacimiento']} />
|
||||||
|
<Field label="Actividad económica" value={c['Actividad Economica']} />
|
||||||
|
<Field label="Dirección" value={c['Dirección']} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Propiedad */}
|
||||||
|
<Section titulo="Propiedad">
|
||||||
|
{prop ? (
|
||||||
|
<>
|
||||||
|
<Field label="Unidad" value={prop.unidadCodigo} />
|
||||||
|
<Field label="Desarrollo" value={prop.desarrollo} />
|
||||||
|
<Field label="Estado" value={prop.estado} />
|
||||||
|
<Field label="Precio final" value={formatoMoneda(prop.precioFinal)} />
|
||||||
|
<Field label="Fecha compra" value={prop.fechaCompra?.slice(0, 10)} />
|
||||||
|
<Field label="Atendido por" value={prop.atendidoPor} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[#94A3B8]">Sin propiedad registrada</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Relaciones CRM */}
|
||||||
|
{(rels.leads.length + rels.oportunidades.length + rels.apartados.length) > 0 && (
|
||||||
|
<Section titulo="Relaciones CRM">
|
||||||
|
{rels.leads.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-xs text-[#111827] dark:text-[#E2E8F0]">Lead: {r.nombre}</span>
|
||||||
|
<span className="rounded-full bg-gray-100 dark:bg-[#334155] px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:text-[#94A3B8]">{r.estado}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{rels.oportunidades.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-xs text-[#111827] dark:text-[#E2E8F0]">Oportunidad: {r.nombre}</span>
|
||||||
|
<span className="rounded-full bg-blue-50 dark:bg-blue-900/30 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:text-blue-400">{r.estado}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{rels.apartados.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-xs text-[#111827] dark:text-[#E2E8F0]">Apartado: {r.nombre}</span>
|
||||||
|
<span className="rounded-full bg-green-50 dark:bg-green-900/30 px-2 py-0.5 text-[10px] font-medium text-green-700 dark:text-green-400">{r.estado}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Última actividad */}
|
||||||
|
<Section titulo="Última actividad">
|
||||||
|
{hist.length === 0 ? (
|
||||||
|
<p className="text-xs text-[#94A3B8]">Sin actividad registrada</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-48 overflow-y-auto">
|
||||||
|
{hist.slice(0, 10).map((ev, idx) => (
|
||||||
|
<div key={idx} className="flex items-start gap-3">
|
||||||
|
<div className="mt-1 h-2 w-2 shrink-0 rounded-full bg-accent" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-[#111827] dark:text-[#E2E8F0]">{ev.detalle}</p>
|
||||||
|
<p className="text-[11px] text-[#94A3B8]">
|
||||||
|
{formatoFecha(ev.fechaISO)}
|
||||||
|
{ev.usuario ? ` · ${ev.usuario}` : ''}
|
||||||
|
{ev.fuente ? ` · ${ev.fuente}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ titulo, children }: { titulo: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] p-4">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</h3>
|
||||||
|
<div className="mt-3 space-y-2">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value?: string | number | null }) {
|
||||||
|
if (!value && value !== 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-[#64748B] dark:text-[#94A3B8]">{label}</span>
|
||||||
|
<span className="font-medium text-[#111827] dark:text-[#E2E8F0]">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { getContactos, type Contacto } from '../services/api'
|
import { getContactos, type Contacto } from '../services/api'
|
||||||
import { ModalContacto } from './ModalContacto'
|
import PerfilContactoCompleto from './PerfilContactoCompleto'
|
||||||
import { SkeletonList } from "./Skeleton"
|
import { SkeletonList } from "./Skeleton"
|
||||||
import toast from "react-hot-toast"
|
import toast from "react-hot-toast"
|
||||||
|
import { conAuth, manejar401 } from '../services/auth'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onVolver: () => void
|
onVolver: () => void
|
||||||
@@ -43,6 +44,28 @@ export function VistaContactos({ onVolver }: Props) {
|
|||||||
.finally(() => setCargando(false))
|
.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])
|
useEffect(() => cargar(), [cargar])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -78,14 +101,21 @@ export function VistaContactos({ onVolver }: Props) {
|
|||||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">No hay contactos para mostrar.</p>
|
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">No hay contactos para mostrar.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4">
|
<div className="mb-4 flex gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar por nombre, email o teléfono…"
|
placeholder="Buscar por nombre, email o teléfono…"
|
||||||
value={busqueda}
|
value={busqueda}
|
||||||
onChange={(e) => setBusqueda(e.target.value)}
|
onChange={(e) => 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"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void exportarCSV()}
|
||||||
|
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
|
||||||
|
>
|
||||||
|
📥 Exportar CSV
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{contactosFiltrados && contactosFiltrados.length === 0 ? (
|
{contactosFiltrados && contactosFiltrados.length === 0 ? (
|
||||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin resultados para “{busqueda}”</p>
|
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin resultados para “{busqueda}”</p>
|
||||||
@@ -143,7 +173,7 @@ export function VistaContactos({ onVolver }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{seleccionado && (
|
{seleccionado && (
|
||||||
<ModalContacto
|
<PerfilContactoCompleto
|
||||||
contactoId={seleccionado.id}
|
contactoId={seleccionado.id}
|
||||||
nombre={seleccionado.nombre}
|
nombre={seleccionado.nombre}
|
||||||
onCerrar={() => setSeleccionado(null)}
|
onCerrar={() => setSeleccionado(null)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
Cell,
|
Cell, Legend,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { fetchMiTablero, type MiTablero } from '../services/reporte-mi-tablero'
|
import { fetchMiTablero, type MiTablero } from '../services/reporte-mi-tablero'
|
||||||
import { axisTextColor, gridStroke, tooltipProps } from '../lib/chart-theme'
|
import { axisTextColor, gridStroke, tooltipProps } from '../lib/chart-theme'
|
||||||
@@ -94,29 +94,80 @@ export default function DashboardVendedor() {
|
|||||||
// Datos para ranking de desarrollos
|
// Datos para ranking de desarrollos
|
||||||
const datosDesarrollo = tablero.porDesarrollo.slice(0, 5)
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* KPIs */}
|
{/* F2: KPIs mejorados */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
titulo="Leads nuevos hoy"
|
||||||
|
valor={tablero.actividadesHoy}
|
||||||
|
detalle="actividades de hoy"
|
||||||
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
titulo="Ventas del mes"
|
titulo="Ventas del mes"
|
||||||
valor={`${tablero.ventasMes.unidades}`}
|
valor={`${tablero.ventasMes.unidades}`}
|
||||||
detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN`}
|
detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN`}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
titulo="Actividades de hoy"
|
titulo="Tasa de conversión"
|
||||||
valor={tablero.actividadesHoy}
|
valor={`${tasaConversion}%`}
|
||||||
detalle={`${tablero.actividadesPendientes} pendientes en total`}
|
detalle={`${tablero.conversion.oportunidades} de ${totalFunnel} leads`}
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
titulo="Funnel conversión"
|
|
||||||
valor={`${tablero.conversion.vendidos}`}
|
|
||||||
detalle={`${tablero.conversion.leads} leads → ${tablero.conversion.oportunidades} ops → ${tablero.conversion.vendidos} vendidos`}
|
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
titulo="Leads totales"
|
titulo="Leads totales"
|
||||||
valor={tablero.conversion.leads}
|
valor={tablero.conversion.leads}
|
||||||
detalle={`${tablero.conversion.apartados} apartados`}
|
detalle={`${tablero.conversion.apartados} apartados · ${tablero.conversion.vendidos} vendidos`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -156,6 +207,34 @@ export default function DashboardVendedor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* F1: Comparativa mensual */}
|
||||||
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm lg:col-span-2">
|
||||||
|
<TituloSeccion>Comparativa mensual (mes actual vs anterior)</TituloSeccion>
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={comparativaMensual} margin={{ top: 8, right: 4, left: -16, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke()} vertical={false} />
|
||||||
|
<XAxis dataKey="metrica" tick={{ fontSize: 10, fill: axisTextColor() }} />
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: axisTextColor() }} allowDecimals={false} />
|
||||||
|
<Tooltip {...tooltipProps()} />
|
||||||
|
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
<Bar
|
||||||
|
dataKey="mesAnterior"
|
||||||
|
name={comparativaMensual[0]?.nombreMesAnterior ?? 'Mes anterior'}
|
||||||
|
fill="#94A3B8"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={32}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="mesActual"
|
||||||
|
name={comparativaMensual[0]?.nombreMesActual ?? 'Mes actual'}
|
||||||
|
fill="#3B82F6"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={32}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Rendimiento por origen (barras horizontales) */}
|
{/* Rendimiento por origen (barras horizontales) */}
|
||||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||||
<TituloSeccion>Rendimiento por origen</TituloSeccion>
|
<TituloSeccion>Rendimiento por origen</TituloSeccion>
|
||||||
|
|||||||
131
apps/mi-dia/src/components/KanbanView.tsx
Normal file
131
apps/mi-dia/src/components/KanbanView.tsx
Normal file
@@ -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<string | null>(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<ColumnaId, LeadMiDia[]> = {
|
||||||
|
NUEVO: [], CONTACTADO: [], INTERESADO: [], AGENDO: [], DESCARTADO: [],
|
||||||
|
}
|
||||||
|
for (const lead of leads) {
|
||||||
|
const col = columnaLead(lead)
|
||||||
|
grupos[col].push(lead)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
|
{COLUMNAS.map((col) => (
|
||||||
|
<div
|
||||||
|
key={col.id}
|
||||||
|
className={`flex min-w-[260px] flex-1 flex-col rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] dark:border-[#334155] dark:bg-[#0F172A] border-t-4 ${col.color}`}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={() => onDrop(col.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 pt-3 pb-2">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
{col.titulo}
|
||||||
|
</h4>
|
||||||
|
<span className="rounded-full bg-gray-200 dark:bg-[#334155] px-2 py-0.5 text-[10px] font-semibold text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
{grupos[col.id].length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2 px-3 pb-3">
|
||||||
|
{grupos[col.id].length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-[11px] text-[#94A3B8]">Sin leads</p>
|
||||||
|
) : (
|
||||||
|
grupos[col.id].map((lead) => (
|
||||||
|
<div
|
||||||
|
key={lead.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => 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' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0] truncate">
|
||||||
|
{lead.nombre}
|
||||||
|
</p>
|
||||||
|
<ScoreBadge score={calcularScoreRapido(lead)} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] text-[#94A3B8] truncate">
|
||||||
|
{lead.telefono || 'Sin teléfono'}
|
||||||
|
{lead.origen ? ` · ${lead.origen}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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))
|
||||||
|
}
|
||||||
69
apps/mi-dia/src/components/ScoreBadge.tsx
Normal file
69
apps/mi-dia/src/components/ScoreBadge.tsx
Normal file
@@ -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 (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center rounded-full font-bold ${dim} ${colorForScore(score)}`}
|
||||||
|
title={`Score: ${score}`}
|
||||||
|
aria-label={`Score: ${score}`}
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
apps/mi-dia/src/components/TimelineLead.tsx
Normal file
67
apps/mi-dia/src/components/TimelineLead.tsx
Normal file
@@ -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 (
|
||||||
|
<p className="py-6 text-center text-sm text-[#94A3B8]">Sin actividad registrada</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative ml-3 border-l-2 border-[#E5E7EB] dark:border-[#334155] pl-6 space-y-5">
|
||||||
|
{eventos.map((ev, idx) => (
|
||||||
|
<div key={idx} className="relative">
|
||||||
|
{/* Nodo en la línea */}
|
||||||
|
<span
|
||||||
|
className={`absolute -left-[31px] top-1 flex h-4 w-4 items-center justify-center rounded-full text-[8px] text-white ${colorEvento(ev.detalle)}`}
|
||||||
|
>
|
||||||
|
{iconoEvento(ev.detalle)}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
{ev.detalle}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-[#94A3B8]">
|
||||||
|
{formatoFechaCorta(ev.fechaISO.slice(0, 10))}
|
||||||
|
{ev.usuario ? ` · ${ev.usuario}` : ''}
|
||||||
|
{ev.fuente ? ` · ${ev.fuente}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -52,11 +52,49 @@ import { NotasApartado } from '../components/NotasApartado'
|
|||||||
import { FiltrosAvanzados, aplicarFiltros, type FiltrosCRM } from '../components/FiltrosAvanzados'
|
import { FiltrosAvanzados, aplicarFiltros, type FiltrosCRM } from '../components/FiltrosAvanzados'
|
||||||
import { getSesion } from '../services/auth'
|
import { getSesion } from '../services/auth'
|
||||||
import DashboardVendedor from '../components/DashboardVendedor'
|
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 }) {
|
function TituloSeccion({ children }: { children: string }) {
|
||||||
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{children}</h2>
|
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{children}</h2>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="mb-6 rounded-xl bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-950/40 dark:to-blue-900/30 border border-blue-200 dark:border-blue-800/40 p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{icono}</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
{saludo}, {nombre}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Tienes {leadsNuevos} lead{leadsNuevos !== 1 ? 's' : ''} nuevos hoy,{' '}
|
||||||
|
{oportunidadesActivas} oportunidade{oportunidadesActivas !== 1 ? 's' : ''} activa{oportunidadesActivas !== 1 ? 's' : ''},{' '}
|
||||||
|
{apartadosPorVencer} apartado{apartadosPorVencer !== 1 ? 's' : ''} por vencer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function EstadoVacio({ mensaje }: { mensaje: string }) {
|
function EstadoVacio({ mensaje }: { mensaje: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white dark:border-[#334155] dark:bg-[#1E293B]">
|
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white dark:border-[#334155] dark:bg-[#1E293B]">
|
||||||
@@ -516,6 +554,7 @@ function FilaLead({
|
|||||||
onAccion,
|
onAccion,
|
||||||
onConvertir,
|
onConvertir,
|
||||||
onDescartar,
|
onDescartar,
|
||||||
|
onTimeline,
|
||||||
enviando,
|
enviando,
|
||||||
acciones,
|
acciones,
|
||||||
}: {
|
}: {
|
||||||
@@ -523,6 +562,7 @@ function FilaLead({
|
|||||||
onAccion: (estado: string, fecha?: string) => void
|
onAccion: (estado: string, fecha?: string) => void
|
||||||
onConvertir: () => void
|
onConvertir: () => void
|
||||||
onDescartar: () => void
|
onDescartar: () => void
|
||||||
|
onTimeline?: () => void
|
||||||
enviando: boolean
|
enviando: boolean
|
||||||
acciones?: string[]
|
acciones?: string[]
|
||||||
}) {
|
}) {
|
||||||
@@ -532,6 +572,7 @@ function FilaLead({
|
|||||||
<div>
|
<div>
|
||||||
<p className="flex items-center gap-2 text-sm font-medium">
|
<p className="flex items-center gap-2 text-sm font-medium">
|
||||||
{lead.nombre}
|
{lead.nombre}
|
||||||
|
<ScoreBadge score={calcularScore(lead)} />
|
||||||
{lead.pendienteSync === true && <ChipPendienteSync />}
|
{lead.pendienteSync === true && <ChipPendienteSync />}
|
||||||
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
||||||
<span
|
<span
|
||||||
@@ -586,6 +627,15 @@ function FilaLead({
|
|||||||
>
|
>
|
||||||
Descartar
|
Descartar
|
||||||
</button>
|
</button>
|
||||||
|
{onTimeline && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTimeline}
|
||||||
|
className={CLASE_BOTON_ACCION}
|
||||||
|
>
|
||||||
|
Historial
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
@@ -612,6 +662,12 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
const [offline, setOffline] = useState(!navigator.onLine)
|
const [offline, setOffline] = useState(!navigator.onLine)
|
||||||
const [offlineFecha, setOfflineFecha] = useState<string | null>(null)
|
const [offlineFecha, setOfflineFecha] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Kanban toggle
|
||||||
|
const [vistaKanban, setVistaKanban] = useState(false)
|
||||||
|
|
||||||
|
// Timeline modal
|
||||||
|
const [leadTimeline, setLeadTimeline] = useState<{ nombre: string; eventos: EventoLead[] } | null>(null)
|
||||||
|
|
||||||
// Captura rápida
|
// Captura rápida
|
||||||
const [capNombre, setCapNombre] = useState('')
|
const [capNombre, setCapNombre] = useState('')
|
||||||
const [capLada, setCapLada] = useState<ClaveLada>('MX')
|
const [capLada, setCapLada] = useState<ClaveLada>('MX')
|
||||||
@@ -863,6 +919,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
|
|
||||||
{resumen && (
|
{resumen && (
|
||||||
<>
|
<>
|
||||||
|
<ResumenMatutino
|
||||||
|
nombre={nombreVendedor}
|
||||||
|
leadsNuevos={resumen.misLeads.filter((l) => l.fuente === 'lead').length}
|
||||||
|
oportunidadesActivas={resumen.misOportunidades.items.length}
|
||||||
|
apartadosPorVencer={resumen.apartadosPorVencer.length}
|
||||||
|
/>
|
||||||
|
|
||||||
<section aria-label="Indicadores del día" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<section aria-label="Indicadores del día" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<KpiCard titulo="Leads asignados" valor={resumen.misLeads.length} />
|
<KpiCard titulo="Leads asignados" valor={resumen.misLeads.length} />
|
||||||
<KpiCard
|
<KpiCard
|
||||||
@@ -891,6 +954,19 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
<FiltrosAvanzados valor={filtros} onChange={setFiltros} />
|
<FiltrosAvanzados valor={filtros} onChange={setFiltros} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Toggle Kanban / Lista */}
|
||||||
|
{tabCrm === 'leads' && (
|
||||||
|
<div className="mb-3 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVistaKanban((v) => !v)}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1]"
|
||||||
|
>
|
||||||
|
{vistaKanban ? '📋 Lista' : '📊 Kanban'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tabCrm === 'leads' && (() => {
|
{tabCrm === 'leads' && (() => {
|
||||||
const leadsFiltrados = aplicarFiltros(
|
const leadsFiltrados = aplicarFiltros(
|
||||||
resumen.misLeads.filter((l) => l.fuente === 'lead'),
|
resumen.misLeads.filter((l) => l.fuente === 'lead'),
|
||||||
@@ -914,6 +990,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
</div>
|
</div>
|
||||||
{leadsFiltrados.length === 0 ? (
|
{leadsFiltrados.length === 0 ? (
|
||||||
<EstadoVacio mensaje="Sin leads asignados" />
|
<EstadoVacio mensaje="Sin leads asignados" />
|
||||||
|
) : vistaKanban ? (
|
||||||
|
<KanbanView
|
||||||
|
leads={leadsFiltrados}
|
||||||
|
onCambiarEstado={(leadId, nuevoEstado, fuente) =>
|
||||||
|
void ejecutar(() => cambiarEstadoLead(leadId, nuevoEstado, undefined, fuente), 'No se pudo actualizar el lead')
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{leadsFiltrados.map((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')}
|
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
|
||||||
onConvertir={() => void convertir(lead)}
|
onConvertir={() => void convertir(lead)}
|
||||||
onDescartar={() => setDescartando(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}
|
acciones={acciones}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -1347,6 +1439,28 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
onCerrar={() => setApartadoSeleccionado(null)}
|
onCerrar={() => setApartadoSeleccionado(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal de historial/timeline del lead */}
|
||||||
|
{leadTimeline && (
|
||||||
|
<div className="fixed inset-0 z-10 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Historial</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLeadTimeline(null)}
|
||||||
|
className="rounded-lg px-2 py-1 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{leadTimeline.nombre}</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<TimelineLead eventos={leadTimeline.eventos} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user