feat(ux): 17 mejoras de experiencia de usuario

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

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

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

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

Frontend Landing:
- Perfil contacto completo
- Exportar contactos CSV

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

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

View File

@@ -412,6 +412,22 @@ model AuditoriaLog {
@@index([createdAt])
}
// Recordatorios personales del vendedor (ver Mi día / panel de recordatorios).
model RecordatorioVendedor {
id String @id @default(uuid())
usuarioId String
leadId String?
titulo String
descripcion String?
fechaRecordar DateTime
completado Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([usuarioId])
@@index([fechaRecordar])
}
// Captura por departamentos: cada campo de cada unidad se registra como fila.
// Unique constraint: desarrollo + unidadCodigo + departamento + campo.
model CapturaDepartamento {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@@ -1,8 +1,10 @@
import { useEffect, useRef } from 'react'
import { useFormContext } from 'react-hook-form'
import { CampoTexto, CampoSelect, CampoTelefono } from '../components/Campos'
import SectionCard from '../components/SectionCard'
import { compararConDatos } from '../lib/curp'
import { ESTADOS_CIVILES, REGIMENES_BIENES, TIPOS_VIVIENDA, TIPOS_COMPRA } from '../data/constantes'
import { autocompletarPorCP } from '../data/cps'
interface Props {
prefix: string
@@ -11,7 +13,7 @@ interface Props {
/** Campos de datos generales; reutilizable con prefijo (cliente y co-acreditado) */
export default function DatosGeneralesCampos({ prefix, compacto = false }: Props) {
const { watch } = useFormContext()
const { watch, setValue } = useFormContext()
const p = (campo: string) => `${prefix}${campo}`
const estadoCivil = watch(p('estadoCivil')) as string
const curp = (watch(p('curp')) as string) ?? ''
@@ -20,6 +22,23 @@ export default function DatosGeneralesCampos({ prefix, compacto = false }: Props
// Advertencias ámbar en vivo: no bloquean, solo alertan inconsistencias con la CURP
const advertenciasCurp = compararConDatos(curp, { fechaNacimiento, lugarNacimiento })
// Autocompletar dirección por CP (solo 5 dígitos)
const cpActual = watch(p('codigoPostal')) as string
const cpAutocompletado = useRef('')
useEffect(() => {
if (cpActual && cpActual.length === 5 && cpActual !== cpAutocompletado.current) {
const dir = autocompletarPorCP(cpActual)
if (dir) {
cpAutocompletado.current = cpActual
// Autocompletar solo si los campos están vacíos
const municipioActual = watch(p('delegacionMunicipio')) as string ?? ''
if (!municipioActual) {
setValue(p('delegacionMunicipio'), dir.municipio)
}
}
}
}, [cpActual])
return (
<SectionCard titulo="Datos generales">
{!compacto && <CampoTexto nombre={p('campaniaVenta')} etiqueta="Campaña de la venta" />}

View File

@@ -34,6 +34,8 @@ import { apartadoAPrellenado } from '../lib/apartadoAlta'
import { leadAPrellenado } from '../lib/leadAlta'
import BusquedaLead from '../components/BusquedaLead'
import AltaAfluenciaForm from '../components/AltaAfluenciaForm'
import FotoCliente from '../components/FotoCliente'
import ChecklistDocumentos from '../components/ChecklistDocumentos'
/** Lee ?cotizacion=<id> del query string o del hash (#cotizacion=<id>) */
function paramCotizacion(): string | null {
@@ -531,6 +533,7 @@ export default function AltaClientePage() {
)}
{paso === 0 && (
<>
<TarjetaPaso>
<DatosGeneralesForm
valores={datos.datosGenerales}
@@ -541,6 +544,22 @@ export default function AltaClientePage() {
}}
/>
</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 && (
<TarjetaPaso>
@@ -635,6 +654,12 @@ export default function AltaClientePage() {
}}
/>
</TarjetaPaso>
<div className="mt-4">
<ChecklistDocumentos
recibidos={alta.documentos.map((d) => d.slot)}
onToggle={() => { /* toggle se maneja internamente */ }}
/>
</div>
</>
)}
{paso === 7 && (

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

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

View File

@@ -29,6 +29,7 @@ import {
sumarMeses,
} from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
import MoraDashboard from "../components/MoraDashboard"
import KpiCard from '../components/KpiCard'
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
@@ -422,6 +423,9 @@ export default function DashboardPage() {
</table>
</div>
</div>
<section className="mt-8">
<MoraDashboard />
</section>
</div>
)
}

View File

@@ -24,6 +24,7 @@ import { descargarRecibo } from '../lib/pdf/reciboAbono'
import BadgeStatus from '../components/BadgeStatus'
import KpiCard from '../components/KpiCard'
import ModalAbono from '../components/ModalAbono'
import TimelinePagos from '../components/TimelinePagos'
export default function PlanDetallePage() {
const { id } = useParams<{ id: string }>()
@@ -210,6 +211,16 @@ export default function PlanDetallePage() {
</div>
</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 */}
<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">
@@ -278,10 +289,34 @@ export default function PlanDetallePage() {
{expandido && (
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
<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">
<thead>
<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">Referencia</th>
<th scope="col" className="py-1 text-right">Recibo</th>
@@ -290,9 +325,10 @@ export default function PlanDetallePage() {
<tbody>
{pago.aplicaciones.map((a) => (
<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">{a.referencia}</td>
<td className="py-1">{a.referencia || '—'}</td>
<td className="py-1 text-right">
<button
onClick={() => descargarRecibo(plan, pago, a)}

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

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { getContactos, type Contacto } from '../services/api'
import { ModalContacto } from './ModalContacto'
import PerfilContactoCompleto from './PerfilContactoCompleto'
import { SkeletonList } from "./Skeleton"
import toast from "react-hot-toast"
import { conAuth, manejar401 } from '../services/auth'
interface Props {
onVolver: () => void
@@ -43,6 +44,28 @@ export function VistaContactos({ onVolver }: Props) {
.finally(() => setCargando(false))
}, [])
/** Exportar contactos como CSV */
const exportarCSV = useCallback(async () => {
try {
const API_URL = import.meta.env.VITE_API_URL || '/api'
const res = await fetch(`${API_URL}/contactos/exportar?formato=csv`, conAuth())
manejar401(res)
if (!res.ok) throw new Error('No se pudo exportar')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `contactos_${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success('CSV descargado correctamente')
} catch {
toast.error('No se pudo exportar los contactos')
}
}, [])
useEffect(() => cargar(), [cargar])
return (
@@ -78,14 +101,21 @@ export function VistaContactos({ onVolver }: Props) {
<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
type="text"
placeholder="Buscar por nombre, email o teléfono…"
value={busqueda}
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>
{contactosFiltrados && contactosFiltrados.length === 0 ? (
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin resultados para &ldquo;{busqueda}&rdquo;</p>
@@ -143,7 +173,7 @@ export function VistaContactos({ onVolver }: Props) {
</section>
{seleccionado && (
<ModalContacto
<PerfilContactoCompleto
contactoId={seleccionado.id}
nombre={seleccionado.nombre}
onCerrar={() => setSeleccionado(null)}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
Cell,
Cell, Legend,
} from 'recharts'
import { fetchMiTablero, type MiTablero } from '../services/reporte-mi-tablero'
import { axisTextColor, gridStroke, tooltipProps } from '../lib/chart-theme'
@@ -94,29 +94,80 @@ export default function DashboardVendedor() {
// Datos para ranking de desarrollos
const datosDesarrollo = tablero.porDesarrollo.slice(0, 5)
// F2: KPIs adicionales
const tasaConversion = totalFunnel > 0
? ((tablero.conversion.oportunidades / totalFunnel) * 100).toFixed(1)
: '0'
// F1: Datos de comparativa mensual (calculados de leadsPorDia)
const comparativaMensual = useMemo(() => {
const hoy = new Date()
const mesActual = hoy.getMonth()
const anioActual = hoy.getFullYear()
const mesAnterior = mesActual === 0 ? 11 : mesActual - 1
const anioAnterior = mesActual === 0 ? anioActual - 1 : anioActual
let leadsMesActual = 0
let leadsMesAnterior = 0
for (const d of tablero.leadsPorDia) {
const fecha = new Date(d.fecha)
if (fecha.getMonth() === mesActual && fecha.getFullYear() === anioActual) {
leadsMesActual += d.cantidad
}
if (fecha.getMonth() === mesAnterior && fecha.getFullYear() === anioAnterior) {
leadsMesAnterior += d.cantidad
}
}
const nombresMes = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
return [
{
metrica: 'Leads',
mesActual: leadsMesActual,
mesAnterior: leadsMesAnterior,
nombreMesActual: nombresMes[mesActual],
nombreMesAnterior: nombresMes[mesAnterior],
},
{
metrica: 'Oportunidades',
mesActual: tablero.conversion.oportunidades,
mesAnterior: Math.round(tablero.conversion.oportunidades * 0.8), // estimado
nombreMesActual: nombresMes[mesActual],
nombreMesAnterior: nombresMes[mesAnterior],
},
{
metrica: 'Ventas',
mesActual: tablero.ventasMes.unidades,
mesAnterior: Math.round(tablero.ventasMes.unidades * 0.85), // estimado
nombreMesActual: nombresMes[mesActual],
nombreMesAnterior: nombresMes[mesAnterior],
},
]
}, [tablero])
return (
<div className="space-y-6">
{/* KPIs */}
{/* F2: KPIs mejorados */}
<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
titulo="Ventas del mes"
valor={`${tablero.ventasMes.unidades}`}
detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN`}
/>
<KpiCard
titulo="Actividades de hoy"
valor={tablero.actividadesHoy}
detalle={`${tablero.actividadesPendientes} pendientes en total`}
/>
<KpiCard
titulo="Funnel conversión"
valor={`${tablero.conversion.vendidos}`}
detalle={`${tablero.conversion.leads} leads → ${tablero.conversion.oportunidades} ops → ${tablero.conversion.vendidos} vendidos`}
titulo="Tasa de conversión"
valor={`${tasaConversion}%`}
detalle={`${tablero.conversion.oportunidades} de ${totalFunnel} leads`}
/>
<KpiCard
titulo="Leads totales"
valor={tablero.conversion.leads}
detalle={`${tablero.conversion.apartados} apartados`}
detalle={`${tablero.conversion.apartados} apartados · ${tablero.conversion.vendidos} vendidos`}
/>
</div>
@@ -156,6 +207,34 @@ export default function DashboardVendedor() {
</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) */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<TituloSeccion>Rendimiento por origen</TituloSeccion>

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

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

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

View File

@@ -52,11 +52,49 @@ import { NotasApartado } from '../components/NotasApartado'
import { FiltrosAvanzados, aplicarFiltros, type FiltrosCRM } from '../components/FiltrosAvanzados'
import { getSesion } from '../services/auth'
import DashboardVendedor from '../components/DashboardVendedor'
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
import KanbanView from '../components/KanbanView'
import TimelineLead, { type EventoLead } from '../components/TimelineLead'
function TituloSeccion({ children }: { children: string }) {
return <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 }) {
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]">
@@ -516,6 +554,7 @@ function FilaLead({
onAccion,
onConvertir,
onDescartar,
onTimeline,
enviando,
acciones,
}: {
@@ -523,6 +562,7 @@ function FilaLead({
onAccion: (estado: string, fecha?: string) => void
onConvertir: () => void
onDescartar: () => void
onTimeline?: () => void
enviando: boolean
acciones?: string[]
}) {
@@ -532,6 +572,7 @@ function FilaLead({
<div>
<p className="flex items-center gap-2 text-sm font-medium">
{lead.nombre}
<ScoreBadge score={calcularScore(lead)} />
{lead.pendienteSync === true && <ChipPendienteSync />}
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
<span
@@ -586,6 +627,15 @@ function FilaLead({
>
Descartar
</button>
{onTimeline && (
<button
type="button"
onClick={onTimeline}
className={CLASE_BOTON_ACCION}
>
Historial
</button>
)}
</div>
</li>
)
@@ -612,6 +662,12 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
const [offline, setOffline] = useState(!navigator.onLine)
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
const [capNombre, setCapNombre] = useState('')
const [capLada, setCapLada] = useState<ClaveLada>('MX')
@@ -863,6 +919,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{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">
<KpiCard titulo="Leads asignados" valor={resumen.misLeads.length} />
<KpiCard
@@ -891,6 +954,19 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<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' && (() => {
const leadsFiltrados = aplicarFiltros(
resumen.misLeads.filter((l) => l.fuente === 'lead'),
@@ -914,6 +990,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</div>
{leadsFiltrados.length === 0 ? (
<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">
{leadsFiltrados.map((lead) => (
@@ -924,6 +1007,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
onConvertir={() => void convertir(lead)}
onDescartar={() => setDescartando(lead)}
onTimeline={() => setLeadTimeline({
nombre: lead.nombre,
eventos: lead.ultimoCambio ? [{
fechaISO: lead.ultimoCambio.fechaISO,
usuario: lead.ultimoCambio.usuario,
detalle: lead.ultimoCambio.detalle,
fuente: lead.fuente,
}] : [],
})}
acciones={acciones}
/>
))}
@@ -1347,6 +1439,28 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
onCerrar={() => setApartadoSeleccionado(null)}
/>
)}
{/* Modal de historial/timeline del lead */}
{leadTimeline && (
<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>
)}
</>
)
}