diff --git a/api-gateway/prisma/migrations/20260911120000_aplicacion_capturado_por/migration.sql b/api-gateway/prisma/migrations/20260911120000_aplicacion_capturado_por/migration.sql new file mode 100644 index 0000000..6bf204d --- /dev/null +++ b/api-gateway/prisma/migrations/20260911120000_aplicacion_capturado_por/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "AplicacionPago" ADD COLUMN "capturadoPorId" TEXT; + +-- CreateIndex +CREATE INDEX "AplicacionPago_capturadoPorId_idx" ON "AplicacionPago"("capturadoPorId"); diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index d017e9c..a497aa7 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -136,14 +136,18 @@ model Pago { } model AplicacionPago { - id String @id @default(uuid()) - pagoId String - pago Pago @relation(fields: [pagoId], references: [id], onDelete: Cascade) - fechaPago DateTime - monto Float - referencia String + id String @id @default(uuid()) + pagoId String + pago Pago @relation(fields: [pagoId], references: [id], onDelete: Cascade) + fechaPago DateTime + monto Float + referencia String + // Usuario que capturó el abono (sub del JWT); null en abonos históricos o + // hechos sin auth (AUTH_ENABLED=false) + capturadoPorId String? @@index([pagoId]) + @@index([capturadoPorId]) } // Vinculación lead ↔ oportunidad del funnel: cada lead capturado entra al diff --git a/api-gateway/scripts/seed-config.js b/api-gateway/scripts/seed-config.js index 4b60383..594fe6f 100644 --- a/api-gateway/scripts/seed-config.js +++ b/api-gateway/scripts/seed-config.js @@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url' import dotenv from 'dotenv' import { PrismaClient } from '@prisma/client' import { ACCIONES_POR_ROL_DEFAULT } from '../src/config/acciones.ts' -import { MODULOS } from '../src/config/roles.ts' +import { MATRIZ, MODULOS } from '../src/config/roles.ts' const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] }) @@ -31,19 +31,8 @@ const CONFIGS_DEFAULT = [ { clave: 'round_robin_habilitado', valor: 'false' }, ] -// ── RolConfiguracion: módulos por rol (matriz de config/roles.ts) ────── -const MODULOS_VENDEDOR = [ - 'mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', -] - -const MATRIZ_MODULOS = { - vendedor: MODULOS_VENDEDOR, - administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'], - director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'], - escrituracion: [...MODULOS_VENDEDOR, 'cobranza'], - direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat'], - admin: [...MODULOS], -} +// ── RolConfiguracion: módulos por rol (la MATRIZ de config/roles.ts es la +// fuente de verdad; aquí solo se persiste para que /auth/me la lea de DB) ── async function main() { console.log('⚙️ Sembrando ConfiguracionSistema...') @@ -57,7 +46,7 @@ async function main() { } console.log('\n⚙️ Sembrando RolConfiguracion...') - for (const [rol, modulos] of Object.entries(MATRIZ_MODULOS)) { + for (const [rol, modulos] of Object.entries(MATRIZ)) { const acciones = ACCIONES_POR_ROL_DEFAULT[rol] ?? ACCIONES_POR_ROL_DEFAULT.vendedor await prisma.rolConfiguracion.upsert({ where: { rol }, diff --git a/api-gateway/src/config/vocabulario.ts b/api-gateway/src/config/vocabulario.ts new file mode 100644 index 0000000..d0a1e49 --- /dev/null +++ b/api-gateway/src/config/vocabulario.ts @@ -0,0 +1,68 @@ +/** + * VOCABULARIO canónico FRASA del backend: fases, estados, etapas, motivos, + * subcategorías, orígenes y desarrollos en un solo catálogo. + * + * Espejo de apps/mi-dia/src/lib/mi-dia.ts (mantener sincronizados). Los + * controllers siguen exportando sus constantes de validación zod derivadas de + * este mismo vocabulario: + * - leads.controller.ts → ESTADOS_LEAD_PERMITIDOS (FASES_LEAD sin NUEVO/DESCARTADO) + * - oportunidades.controller.ts → ETAPAS_OPORTUNIDAD_PERMITIDAS (= FASES_OPORTUNIDAD), + * MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA + * - apartados.controller.ts → FASES_APARTADO_PERMITIDAS (= FASES_APARTADO) + */ +export const VOCABULARIO = { + // Estados de la columna status del board Leads: ESTADOS_LEAD_PERMITIDOS del + // API + NUEVO (captura) y DESCARTADO (terminal) + FASES_LEAD: ['NUEVO', 'CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'], + // Etapas del funnel de Oportunidades (labels REALES de la columna status 'Estado') + FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'], + // Fases del pipeline Enkontrol del tablero Apartado (labels REALES de la + // columna status 'Estado'); incluye las fases de escrituración + FASES_APARTADO: [ + 'Pago Apartado', + 'Alta Enkontrol', + 'Entrego Documentacion', + 'Cita Clientes', + 'Contrato Enkontrol', + 'Firma Cliente', + 'Cancelacion Apartado', + 'Venta', + 'Seguimiento', + 'Integracion', + 'Llamada Escrituras', + 'X Escriturar', + 'Banco/Infonavit', + 'X Autorizar', + 'Autorizado', + 'Escriturado', + 'Titulado', + 'Cancelo', + ], + // Fases de escrituración (pipeline posterior a 'Firma Cliente') + FASES_ESCRITURACION: ['Venta', 'Seguimiento', 'Integracion', 'X Autorizar', 'Autorizado', 'X Escriturar', 'Escriturado', 'Titulado', 'Cancelo'], + // Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA) + MOTIVOS_NO_COMPRA: ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'], + // Subcategorías por categoría (catálogo del tablero Motivos No Compra, + // sembrado en Frasa-CAS el 2026-09-04; 'Mesualidad' y 'Cambo de Planes' + // conservan las faltas de ortografía originales del tablero del cliente) + SUBCATEGORIAS: { + Precio: ['Precio Total', 'Enganche', 'Mesualidad'], + Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'], + Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'], + Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'], + Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'], + Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'], + Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'], + }, + // Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' + // (Oportunidades), verificados y sembrados en Monday el 2026-09-04 + ORIGENES: ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'], + // Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday) + DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'], +} as const + +export type FaseLead = (typeof VOCABULARIO.FASES_LEAD)[number] +export type FaseOportunidad = (typeof VOCABULARIO.FASES_OPORTUNIDAD)[number] +export type FaseApartado = (typeof VOCABULARIO.FASES_APARTADO)[number] +export type FaseEscrituracion = (typeof VOCABULARIO.FASES_ESCRITURACION)[number] +export type MotivoNoCompra = (typeof VOCABULARIO.MOTIVOS_NO_COMPRA)[number] diff --git a/api-gateway/src/controllers/cliente-maestro.controller.ts b/api-gateway/src/controllers/cliente-maestro.controller.ts index 5ba004c..6f3f020 100644 --- a/api-gateway/src/controllers/cliente-maestro.controller.ts +++ b/api-gateway/src/controllers/cliente-maestro.controller.ts @@ -12,7 +12,13 @@ export async function listarEndpoint(req: Request, res: Response): Promise const pagina = req.query.pagina ? Number(req.query.pagina) : undefined const limite = req.query.limite ? Number(req.query.limite) : undefined - const resultado = await clienteMaestroService.listar({ busqueda, desarrollo, estatus, pagina, limite }) + const resultado = await clienteMaestroService.listar({ + busqueda, + desarrollo, + estatus, + ...(pagina !== undefined ? { pagina } : {}), + ...(limite !== undefined ? { limite } : {}), + }) res.json(resultado) } diff --git a/api-gateway/src/controllers/contactos-fallback.test.ts b/api-gateway/src/controllers/contactos-fallback.test.ts index b64eb91..d33410c 100644 --- a/api-gateway/src/controllers/contactos-fallback.test.ts +++ b/api-gateway/src/controllers/contactos-fallback.test.ts @@ -110,7 +110,7 @@ describe('detalleContactoEndpoint (fallback local)', () => { expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled() const detalle = json.mock.calls[0]?.[0] as Record - expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877' }) + expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877' }) expect(detalle['propiedad']).toBeNull() expect((detalle['relaciones'] as Record)['oportunidades']).toHaveLength(1) expect(Array.isArray(detalle['historial'])).toBe(true) diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index 3dd07d7..8beb1d5 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -185,6 +185,7 @@ type MondayLeads = Pick< | 'buscarLeadPorTelefono' | 'buscarOportunidadPorTelefono' | 'getLeads' + | 'getOportunidades' | 'labelsDeColumna' | 'obtenerNombreItem' | 'columnaIdPorTitulo' diff --git a/api-gateway/src/controllers/mi-dia.controller.test.ts b/api-gateway/src/controllers/mi-dia.controller.test.ts index 59d20a6..9971190 100644 --- a/api-gateway/src/controllers/mi-dia.controller.test.ts +++ b/api-gateway/src/controllers/mi-dia.controller.test.ts @@ -12,9 +12,6 @@ const prismaMock = vi.hoisted(() => ({ leadCaptura: { findMany: vi.fn(), }, - leadOportunidad: { - findMany: vi.fn(), - }, cliente: { findMany: vi.fn(), }, @@ -36,7 +33,6 @@ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) import { resumenMiDiaEndpoint } from './mi-dia.controller.js' -import { saludMonday } from '../services/salud-monday.service.js' const usuarioFake = { id: 'u-1', @@ -80,12 +76,9 @@ const reqFake = { usuarioToken: { sub: 'u-1' } } as unknown as Request beforeEach(() => { vi.clearAllMocks() - vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible') - saludMonday.restablecer() prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake) prismaMock.usuario.findMany.mockResolvedValue([]) prismaMock.leadCaptura.findMany.mockResolvedValue([]) - prismaMock.leadOportunidad.findMany.mockResolvedValue([]) prismaMock.cliente.findMany.mockResolvedValue([]) prismaMock.actividadLocal.findMany.mockResolvedValue([]) mondayMock.getUsuariosWorkspace.mockResolvedValue([]) @@ -96,9 +89,8 @@ beforeEach(() => { mondayMock.getUltimosCambios.mockResolvedValue(new Map()) }) -describe('resumenMiDiaEndpoint (modo local y unión)', () => { - it('Monday caído (circuit breaker): resumen 100% local, sin llamadas a Monday', async () => { - vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false) +describe('resumenMiDiaEndpoint (siempre local, Monday purgado)', () => { + it('responde 100% desde LeadCaptura: Monday no se llama nunca', async () => { prismaMock.leadCaptura.findMany.mockResolvedValue([ capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }), capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', pendienteSync: true }), @@ -109,15 +101,16 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => { expect(mondayMock.getLeads).not.toHaveBeenCalled() expect(mondayMock.getOportunidades).not.toHaveBeenCalled() + expect(mondayMock.getApartados).not.toHaveBeenCalled() + expect(mondayMock.getUsuariosWorkspace).not.toHaveBeenCalled() const resumen = json.mock.calls[0]?.[0] as Record - expect((resumen["misLeads"] as unknown[]).length).toBe(1) // solo leads, no oportunidades + expect((resumen['misLeads'] as unknown[]).length).toBe(1) // solo leads, no oportunidades expect((resumen['misOportunidades'] as { items: unknown[] }).items).toHaveLength(1) expect(resumen['misActividadesHoy']).toEqual([]) expect(resumen['conteos']).toMatchObject({ actividadesAbiertas: 0 }) }) - it('Monday se cae a media lectura (502): cae al resumen local', async () => { - mondayMock.getLeads.mockRejectedValue(new AppError(502, 'Monday no respondió')) + it('las capturas locales llegan con pendienteSync (badge ⏳ del front)', async () => { prismaMock.leadCaptura.findMany.mockResolvedValue([ capturaFake({ leadId: 'loc_l1', board: 'LEADS', pendienteSync: true }), ]) @@ -126,41 +119,11 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => { await resumenMiDiaEndpoint(reqFake, res) const resumen = json.mock.calls[0]?.[0] as Record - expect((resumen['misLeads'] as { id: string }[]).map((l) => l.id)).toEqual(['loc_l1']) + expect((resumen['misLeads'] as { id: string; pendienteSync?: boolean }[]).map((l) => l.id)).toEqual(['loc_l1']) + expect((resumen['misLeads'] as { pendienteSync?: boolean }[])[0]?.pendienteSync).toBe(true) }) - it('Monday vivo: unión — las capturas pendientes se agregan con pendienteSync (badge ⏳)', async () => { - mondayMock.getLeads.mockResolvedValue([ - { - id: '9001', - nombre: 'Lead Monday', - createdAt: '2026-09-01T10:00:00.000Z', - updatedAt: '2026-09-02T10:00:00.000Z', - estado: 'Nuevo', - origen: 'Web', - telefono: '', - email: '', - vendedorIds: [123], - }, - ]) - prismaMock.leadCaptura.findMany.mockResolvedValue([ - capturaFake({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true, updatedAt: new Date('2026-09-05T10:00:00') }), - ]) - const { res, json } = resFake() - - await resumenMiDiaEndpoint(reqFake, res) - - const resumen = json.mock.calls[0]?.[0] as Record - const leads = resumen['misLeads'] as { id: string; pendienteSync?: boolean }[] - expect(leads.map((l) => l.id)).toEqual(['loc_n1', '9001']) // el local, más reciente, primero - expect(leads[0]?.pendienteSync).toBe(true) - expect(leads[1]?.pendienteSync).toBeUndefined() // el de Monday no lleva flag - // Y la captura quedó marcada en Mis capturas - const capturas = resumen['misCapturas'] as { leadId: string; pendienteSync?: boolean; estado: string }[] - expect(capturas.find((c) => c.leadId === 'loc_n1')).toMatchObject({ pendienteSync: true, estado: 'NUEVO' }) - }) - - it('Monday vivo y sin pendientes: respuesta idéntica a la de hoy (sin flags)', async () => { + it('sin capturas: listas vacías y sin flags de sync', async () => { const { res, json } = resFake() await resumenMiDiaEndpoint(reqFake, res) @@ -170,8 +133,20 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => { expect(JSON.stringify(resumen)).not.toContain('pendienteSync') }) - it('un error no-Monday se propaga (no se traga nada)', async () => { - mondayMock.getLeads.mockRejectedValue(new AppError(500, 'bug')) + it('los usuarios del equipo solo se consultan para roles coordinador/admin/corporativo', async () => { + await resumenMiDiaEndpoint(reqFake, resFake().res) + expect(prismaMock.usuario.findMany).not.toHaveBeenCalled() + + prismaMock.usuario.findUnique.mockResolvedValue({ ...usuarioFake, rol: 'coordinador' }) + await resumenMiDiaEndpoint(reqFake, resFake().res) + expect(prismaMock.usuario.findMany).toHaveBeenCalledWith({ + where: { activo: true }, + select: { id: true, nombre: true, rol: true, activo: true }, + }) + }) + + it('un error de BD se propaga (no se traga nada)', async () => { + prismaMock.leadCaptura.findMany.mockRejectedValue(new AppError(500, 'bug')) await expect(resumenMiDiaEndpoint(reqFake, resFake().res)).rejects.toThrow('bug') }) }) diff --git a/api-gateway/src/controllers/mi-dia.controller.ts b/api-gateway/src/controllers/mi-dia.controller.ts index 8d56851..656fa03 100644 --- a/api-gateway/src/controllers/mi-dia.controller.ts +++ b/api-gateway/src/controllers/mi-dia.controller.ts @@ -1,18 +1,15 @@ import type { Request, Response } from 'express' import { prisma } from '../services/prisma.js' -import { mondayService } from '../services/monday.service.js' -import { resumenMiDia, resumenMiDiaLocal, unirPendientesLocales, type ActividadLocalMiDia, type UsuarioLocal } from '../services/mi-dia.service.js' -import { saludMonday } from '../services/salud-monday.service.js' -import { esFalloMonday } from '../services/sync-monday.service.js' -import { config } from '../config/index.js' +import { resumenMiDiaLocal, type ActividadLocalMiDia, type UsuarioLocal } from '../services/mi-dia.service.js' import { AppError } from '../utils/errors.js' const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin'] /** * GET /api/mi-dia/resumen — agenda del vendedor logueado. - * El "mío" se resuelve por Usuario.mondayUserId (mapeo usuario↔Monday); - * sin mapeo, las listas personales van vacías con sinMapeoMonday: true. + * SIEMPRE responde desde datos locales (LeadCaptura / Cliente / ActividadLocal): + * Monday ya no participa en este endpoint. El "mío" se resuelve por el + * usuarioId de cada captura; admin/dirección ven todas. */ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise { // Con AUTH_ENABLED=false (arranque temprano/tests) no hay token: se usa el admin @@ -22,27 +19,6 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise : await prisma.usuario.findUnique({ where: { username: 'admin' } }) if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas') - // Nombres de los usuarios ya mapeados a Monday (para la sección equipo) - const mapeados = await prisma.usuario.findMany({ - where: { mondayUserId: { not: null }, activo: true }, - select: { nombre: true, mondayUserId: true }, - }) - const nombresPorMondayId = new Map( - mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]), - ) - - // TODOS los usuarios del workspace de Monday (el "último cambio" de un lead - // puede ser de alguien sin cuenta en el ecosistema); si ya estaba mapeado - // gana el nombre del ecosistema. Si Monday falla, el resumen sale igual. - try { - for (const u of await mondayService.getUsuariosWorkspace()) { - if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre) - } - } catch (err) { - // eslint-disable-next-line no-console - console.error('No se pudieron listar los usuarios del workspace de Monday:', err) - } - // Capturas de afluencia: admin ve todas, otros solo las suyas const esAdmin = usuario.rol === 'admin' || usuario.rol === 'direccion_general' || usuario.rol === 'director_ventas' const capturas = await prisma.leadCaptura.findMany({ @@ -50,92 +26,40 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise orderBy: { createdAt: 'desc' }, }) - // "Lo mío" incluye las oportunidades ligadas a mis capturas (mapping - // LeadOportunidad: conversión con "Agendo" y afluencias históricas) - const mappings = await prisma.leadOportunidad.findMany({ - where: { leadId: { in: capturas.map((c) => c.leadId) } }, - }) - const capturadosIds = new Set([ - ...capturas.map((c) => c.leadId), - ...capturas.map((c) => c.oportunidadId).filter((id): id is string => id !== null), - ...mappings.map((m) => m.oportunidadId), - ]) - // Altas de clientes: admin ve todas, otros solo las suyas const clientes = await prisma.cliente.findMany({ where: esAdmin ? {} : { capturadoPorId: usuario.id }, orderBy: { createdAt: 'desc' }, }) + // Actividades locales del usuario + const actividadesRaw = await prisma.actividadLocal.findMany({ + where: { usuarioId: usuario.id }, + orderBy: { createdAt: 'desc' }, + }) + const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({ + id: a.id, + titulo: a.titulo, + descripcion: a.descripcion, + usuarioId: a.usuarioId, + leadCapturaId: a.leadCapturaId, + fechaVencimiento: a.fechaVencimiento, + fechaCierre: a.fechaCierre, + estado: a.estado, + tipo: a.tipo, + })) + + // Usuarios activos (para la sección equipo, solo coordinador/admin) + let usuarios: UsuarioLocal[] = [] + if (ROLES_CON_EQUIPO.includes(usuario.rol)) { + const usuariosRaw = await prisma.usuario.findMany({ + where: { activo: true }, + select: { id: true, nombre: true, rol: true, activo: true }, + }) + usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo })) + } + const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId } - // Monday caído (circuit breaker / MODO_LOCAL_FORZADO): todo desde local - if (!saludMonday.estaDisponible()) { - // Actividades locales del usuario - const actividadesRaw = await prisma.actividadLocal.findMany({ - where: { usuarioId: usuario.id }, - orderBy: { createdAt: 'desc' }, - }) - const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({ - id: a.id, - titulo: a.titulo, - descripcion: a.descripcion, - usuarioId: a.usuarioId, - leadCapturaId: a.leadCapturaId, - fechaVencimiento: a.fechaVencimiento, - fechaCierre: a.fechaCierre, - estado: a.estado, - tipo: a.tipo, - })) - - // Usuarios activos (para la sección equipo, solo coordinador/admin) - let usuarios: UsuarioLocal[] = [] - if (ROLES_CON_EQUIPO.includes(usuario.rol)) { - const usuariosRaw = await prisma.usuario.findMany({ - where: { activo: true }, - select: { id: true, nombre: true, rol: true, activo: true }, - }) - usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo })) - } - - res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios)) - return - } - - try { - const resumen = await resumenMiDia( - mondayService, - usuarioMiDia, - nombresPorMondayId, - capturas, - clientes, - new Date(), - config.automatizaciones.vendedorCompartidoId > 0 ? config.automatizaciones.vendedorCompartidoId : null, - capturadosIds, - ) - // Unión: capturas locales aún no sincronizadas se agregan con badge ⏳ - res.json(unirPendientesLocales(resumen, capturas.filter((c) => c.pendienteSync), usuario.nombre)) - } catch (err) { - // Monday se cayó a media lectura: mismo resumen, pero todo local - if (!esFalloMonday(err)) throw err - const actividadesRaw = await prisma.actividadLocal.findMany({ - where: { usuarioId: usuario.id }, - orderBy: { createdAt: 'desc' }, - }) - const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({ - id: a.id, titulo: a.titulo, descripcion: a.descripcion, - usuarioId: a.usuarioId, leadCapturaId: a.leadCapturaId, - fechaVencimiento: a.fechaVencimiento, fechaCierre: a.fechaCierre, - estado: a.estado, tipo: a.tipo, - })) - let usuarios: UsuarioLocal[] = [] - if (ROLES_CON_EQUIPO.includes(usuario.rol)) { - const usuariosRaw = await prisma.usuario.findMany({ - where: { activo: true }, - select: { id: true, nombre: true, rol: true, activo: true }, - }) - usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo })) - } - res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios)) - } + res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios)) } diff --git a/api-gateway/src/controllers/planes.controller.ts b/api-gateway/src/controllers/planes.controller.ts index d186e5d..50617dd 100644 --- a/api-gateway/src/controllers/planes.controller.ts +++ b/api-gateway/src/controllers/planes.controller.ts @@ -259,6 +259,8 @@ export async function registrarAbonoEndpoint(req: Request, res: Response): Promi fechaPago: new Date(body.fechaPago), monto: aplicacion.monto, referencia: aplicacion.referencia, + // Quien capturó el abono: sub del JWT (null si AUTH_ENABLED=false o token sin sub) + capturadoPorId: req.usuarioToken?.sub ?? null, }, }), prisma.pago.update({ diff --git a/api-gateway/src/controllers/recordatorio-vendedor.controller.ts b/api-gateway/src/controllers/recordatorio-vendedor.controller.ts index b449748..76a00c3 100644 --- a/api-gateway/src/controllers/recordatorio-vendedor.controller.ts +++ b/api-gateway/src/controllers/recordatorio-vendedor.controller.ts @@ -90,7 +90,12 @@ export function crearController(svc: RecordatorioVendedorService) { 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 } = { + const opts: { + completados?: boolean + desde?: string | null + hasta?: string | null + clienteClave?: string | null + } = { desde: (req.query['desde'] as string) ?? null, hasta: (req.query['hasta'] as string) ?? null, } diff --git a/api-gateway/src/controllers/reportes.controller.ts b/api-gateway/src/controllers/reportes.controller.ts index 2a592b4..064ee82 100644 --- a/api-gateway/src/controllers/reportes.controller.ts +++ b/api-gateway/src/controllers/reportes.controller.ts @@ -8,9 +8,11 @@ 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) -/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta */ -export async function resumenReportes(_req: Request, res: Response): Promise { - res.json(await reportesService.resumen()) +/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta + * ?dias=7|30|90 acota leads/oportunidades al rango (default: todo el histórico) */ +export async function resumenReportes(req: Request, res: Response): Promise { + const dias = Number(req.query['dias']) + res.json(await reportesService.resumen(Number.isFinite(dias) && dias > 0 ? Math.floor(dias) : undefined)) } /** GET /api/reportes/scoring — scores de todos los leads activos */ diff --git a/api-gateway/src/controllers/usuarios.controller.test.ts b/api-gateway/src/controllers/usuarios.controller.test.ts index 056239d..c70efa7 100644 --- a/api-gateway/src/controllers/usuarios.controller.test.ts +++ b/api-gateway/src/controllers/usuarios.controller.test.ts @@ -92,7 +92,7 @@ describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () = rol: 'vendedor', asignacion: 'MONTEBELLO', mondayUserId: 13676, - modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'], + modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'], acciones: [], }) }) diff --git a/api-gateway/src/services/auth.service.test.ts b/api-gateway/src/services/auth.service.test.ts index 6a17f2f..fa64f18 100644 --- a/api-gateway/src/services/auth.service.test.ts +++ b/api-gateway/src/services/auth.service.test.ts @@ -54,7 +54,7 @@ describe('login', () => { rol: 'vendedor', asignacion: 'MONTEBELLO', mondayUserId: null, - modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'], + modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'], acciones: [], }) expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos') @@ -103,7 +103,7 @@ describe('me', () => { it('incluye los módulos visibles del rol (matriz de config/roles)', async () => { prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ rol: 'direccion_general' })) const resultado = await me({ sub: 'u-1', username: 'x', rol: 'direccion_general' }) - expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat']) + expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro']) }) it('rechaza si el usuario ya no existe o está inactivo', async () => { diff --git a/api-gateway/src/services/automatizaciones.service.ts b/api-gateway/src/services/automatizaciones.service.ts index ad52dad..1a2a00a 100644 --- a/api-gateway/src/services/automatizaciones.service.ts +++ b/api-gateway/src/services/automatizaciones.service.ts @@ -602,7 +602,7 @@ export class AutomatizacionesService { pendienteSync: false, }, }) - } catch (e) { console.error('[APARTADO-ERR]', e.message?.slice(0,150)) } + } catch (e) { console.error('[APARTADO-ERR]', e instanceof Error ? e.message.slice(0, 150) : e) } } catch (err) { resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) }) } diff --git a/api-gateway/src/services/contactos-detalle.service.test.ts b/api-gateway/src/services/contactos-detalle.service.test.ts index 6d915cd..3d6fefd 100644 --- a/api-gateway/src/services/contactos-detalle.service.test.ts +++ b/api-gateway/src/services/contactos-detalle.service.test.ts @@ -68,7 +68,8 @@ describe('detalleContacto (GET /api/contactos/:id/detalle)', () => { }, ) expect(d).not.toBeNull() - expect(d!.contacto['CURP']).toBe('XAXX010101XXXXXXX0') + // El contacto del detalle se devuelve con campos nombrados del modelo local + expect(d!.contacto['curp']).toBe('XAXX010101XXXXXXX0') // Propiedad comprada (apartado relacionado, enriquecido con el desarrollo) expect(d!.propiedad).toMatchObject({ unidadCodigo: 'SENDA-409-1-2', diff --git a/api-gateway/src/services/contactos-detalle.service.ts b/api-gateway/src/services/contactos-detalle.service.ts index 5776838..d510aeb 100644 --- a/api-gateway/src/services/contactos-detalle.service.ts +++ b/api-gateway/src/services/contactos-detalle.service.ts @@ -31,7 +31,7 @@ export interface EventoHistorialContacto { } export interface DetalleContacto { - contacto: Record // columnas del board por título ('nombre' incluido) + contacto: Record // campos nombrados del modelo local (nombre, telefono, email, …) /** Apartado relacionado más reciente (la propiedad comprada o en proceso) */ propiedad: { id: string @@ -57,6 +57,32 @@ export function claveTelefono(telefono: string): string { return digitos.length >= 10 ? digitos.slice(-10) : '' } +/** + * Mapeo título de columna del board Contactos (Monday) → campo nombrado del + * modelo local. Los títulos sin entrada se conservan tal cual. + */ +const CAMPOS_CONTACTO: Record = { + nombre: 'nombre', + 'Teléfono': 'telefono', + Email: 'email', + Estado: 'estado', + CURP: 'curp', + RFC: 'rfc', + 'Estado Civil': 'estadoCivil', + 'Fecha Nacimiento': 'fechaNacimiento', + 'Actividad Economica': 'actividadEconomica', + 'Dirección': 'direccion', +} + +/** Normaliza un record por título de columna a campos nombrados del modelo local */ +export function normalizarCamposContacto(contacto: Record): Record { + const normalizado: Record = {} + for (const [titulo, valor] of Object.entries(contacto)) { + normalizado[CAMPOS_CONTACTO[titulo] ?? titulo] = valor + } + return normalizado +} + type MondayDetalle = Pick< MondayService, | 'getContactoDetalle' @@ -169,7 +195,7 @@ export async function detalleContacto( ].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO)) return { - contacto, + contacto: normalizarCamposContacto(contacto), propiedad, relaciones: { leads: relLeads.map((l) => ({ diff --git a/api-gateway/src/services/contactos-local.service.test.ts b/api-gateway/src/services/contactos-local.service.test.ts index 594937b..bb305cd 100644 --- a/api-gateway/src/services/contactos-local.service.test.ts +++ b/api-gateway/src/services/contactos-local.service.test.ts @@ -162,10 +162,11 @@ describe('detalleContactoLocal', () => { expect(detalle.contacto).toMatchObject({ nombre: 'JUAN PÉREZ', - Teléfono: '4771234567', - Email: 'juan@correo.mx', - CURP: 'CURP123', - RFC: 'RFC123', + telefono: '4771234567', + email: 'juan@correo.mx', + estado: 'Cliente', + curp: 'CURP123', + rfc: 'RFC123', }) expect(detalle.propiedad).toMatchObject({ id: 'loc_a1', @@ -203,7 +204,7 @@ describe('detalleContactoLocal', () => { const detalle = await detalleContactoLocal('loc_1') expect(detalle).not.toBeNull() if (detalle === null) return - expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877', Estado: 'Afluencia' }) + expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877', estado: 'Afluencia' }) expect(detalle.propiedad).toBeNull() expect(detalle.relaciones.oportunidades).toHaveLength(1) expect(detalle.historial.length).toBeGreaterThan(0) diff --git a/api-gateway/src/services/contactos-local.service.ts b/api-gateway/src/services/contactos-local.service.ts index aaaaf59..6d44fdd 100644 --- a/api-gateway/src/services/contactos-local.service.ts +++ b/api-gateway/src/services/contactos-local.service.ts @@ -202,17 +202,17 @@ export async function detalleContactoLocal(contactoId: string): Promise nombre para mostrar (usuarios del ecosistema ya mapeados) */ nombresPorMondayId: Map, /** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */ - capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [], + capturas: { + leadId: string + nombreLead: string + telefono: string | null + createdAt: Date + /** Oportunidad ligada (LeadOportunidad); null si la captura aún no convertió */ + oportunidadId?: string | null + }[] = [], /** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */ clientes: { id: string @@ -332,7 +339,10 @@ export async function resumenMiDia( ) // Set de oportunidades mías (por LeadCaptura o por vendedorIds) const miosOportunidadesIds = new Set( - capturas.filter((c) => c.oportunidadId).map((c) => c.oportunidadId), + capturas + .filter((c) => c.oportunidadId) + .map((c) => c.oportunidadId) + .filter((id): id is string => id != null), ) const miosApartadosTodos = apartados.filter((a) => esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id), @@ -387,7 +397,8 @@ export async function resumenMiDia( estado: a.estado, fechaLimite: a.fechaLimite, ultimoCambio: null as { fechaISO: string; usuario: string | null; detalle: string } | null, - pendienteSync: a.pendienteSync, + // Los apartados leídos de Monday ya están sincronizados por definición + pendienteSync: false, })) const misApartados: ApartadoMiDia[] = miosApartados @@ -874,9 +885,9 @@ export function resumenMiDiaLocal( .map((c) => ({ id: c.leadId, nombre: c.nombreLead, - unidadCodigo: '', + unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '', estado: c.etapa, - fechaLimite: '', + fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '', ultimoCambio: null, pendienteSync: c.pendienteSync, })), diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index 82908f9..b36b0ad 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -1672,6 +1672,8 @@ export interface DatosOportunidadAfluencia { tipoEconomiaLabel?: string | undefined // status 'Tipo de Econimia' (SIC: typo real) calificaLabel?: string | undefined // dropdown 'Lead Califica' loQuierePara?: string | undefined // dropdown 'Proposito de Compra' + /** text '# Dependientes' (si el board aún no tiene la columna, Monday lo ignora) */ + numDependientes?: string | undefined notas?: string | undefined // long_text 'COMENTARIOS' } diff --git a/api-gateway/src/services/reporte-mi-tablero.service.test.ts b/api-gateway/src/services/reporte-mi-tablero.service.test.ts index 023d754..4f25f72 100644 --- a/api-gateway/src/services/reporte-mi-tablero.service.test.ts +++ b/api-gateway/src/services/reporte-mi-tablero.service.test.ts @@ -75,12 +75,14 @@ describe('construirMiTablero', () => { captura({ board: 'OPORTUNIDADES' }), captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }), captura({ board: 'APARTADO', etapa: 'CANCELADO' }), + captura({ board: 'LEADS', etapa: 'DESCARTADO' }), ] const tablero = construirMiTablero(capturas, [], [], AHORA) - expect(tablero.conversion.leads).toBe(2) + expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado expect(tablero.conversion.oportunidades).toBe(1) expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta expect(tablero.conversion.vendidos).toBe(1) + expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO }) it('calcula ventas del mes correctamente', () => { diff --git a/api-gateway/src/services/reporte-mi-tablero.service.ts b/api-gateway/src/services/reporte-mi-tablero.service.ts index ad9318e..67fa634 100644 --- a/api-gateway/src/services/reporte-mi-tablero.service.ts +++ b/api-gateway/src/services/reporte-mi-tablero.service.ts @@ -11,6 +11,8 @@ export interface MiTablero { oportunidades: number apartados: number vendidos: number + /** Leads descartados (etapa terminal DESCARTADO); etapa extra al final del funnel */ + descartados: number } ventasMes: { monto: number @@ -125,6 +127,7 @@ export function construirMiTablero( c.board === 'APARTADO' && ['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()), ).length + const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length // -- Ventas del mes -- const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1) @@ -183,7 +186,7 @@ export function construirMiTablero( return { generadoEn: ahora.toISOString(), leadsPorDia, - conversion: { leads, oportunidades, apartados, vendidos }, + conversion: { leads, oportunidades, apartados, vendidos, descartados }, ventasMes, porOrigen, porDesarrollo, @@ -203,6 +206,7 @@ export async function obtenerMiTablero(usuarioId: string): Promise { const capturas = await prisma.leadCaptura.findMany({ where: { usuarioId }, select: { + leadId: true, board: true, etapa: true, origen: true, @@ -246,11 +250,11 @@ export async function obtenerMetricasUso(usuarioId?: string): Promise 0 + const desdeRango = rangoValido + ? new Date(hoy.getTime() - (Math.floor(dias!) - 1) * 24 * 60 * 60 * 1000) + : null + const enRango = (createdAt: string): boolean => { + if (!desdeRango) return true + const t = Date.parse(createdAt) + return !Number.isNaN(t) && t >= desdeRango.getTime() + } + const leadsRango = datos.leads.filter((l) => enRango(l.createdAt)) + const oportunidadesRango = datos.oportunidades.filter((o) => enRango(o.createdAt)) const creadosEn = (desde: Date): number => - datos.leads.filter((l) => { + leadsRango.filter((l) => { const t = Date.parse(l.createdAt) return !Number.isNaN(t) && t >= desde.getTime() }).length - const porEtapa = contarPor(datos.oportunidades.map((o) => o.etapa)) - const totalOps = datos.oportunidades.length - const ganadas = datos.oportunidades.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length - const apartadosOMas = datos.oportunidades.filter((o) => + const porEtapa = contarPor(oportunidadesRango.map((o) => o.etapa)) + const totalOps = oportunidadesRango.length + const ganadas = oportunidadesRango.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length + const apartadosOMas = oportunidadesRango.filter((o) => ['APARTO', 'GANADO'].includes(o.etapa.trim().toUpperCase()), ).length - const activas = datos.oportunidades.filter( + const activas = oportunidadesRango.filter( (o) => !ETAPAS_CERRADAS.includes(o.etapa.trim().toUpperCase()) && o.etapa.trim().toUpperCase() !== 'GANADO', @@ -233,28 +248,28 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor // Sección "leads" = tablero Leads (captura ligera); el perfilamiento de la // afluencia se lee de Oportunidades (el Alta Afluencia vive ahí) leads: { - total: datos.leads.length, + total: leadsRango.length, nuevosHoy: creadosEn(hoy), nuevosSemana: creadosEn(hace7dias), - porEstado: contarPor(datos.leads.map((l) => l.estado)), - porOrigen: contarPor(datos.leads.map((l) => l.origen)), + porEstado: contarPor(leadsRango.map((l) => l.estado)), + porOrigen: contarPor(leadsRango.map((l) => l.origen)), perfil: { - porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')), - porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')), - porEstadoCivil: contarPorNoVacios(datos.oportunidades.map((o) => o.estadoCivil ?? '')), - porTipoEmpleo: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEmpleo ?? '')), - porTipoEconomia: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEconomia ?? '')), - porCalifica: contarPorNoVacios(datos.oportunidades.map((o) => o.califica ?? '')), - porCredito: contarPorNoVacios(datos.oportunidades.map((o) => o.credito ?? '')), + porDesarrollo: contarPorNoVacios(oportunidadesRango.map((o) => o.desarrollo ?? '')), + porModelo: contarPorNoVacios(oportunidadesRango.map((o) => o.modeloInteres ?? '')), + porEstadoCivil: contarPorNoVacios(oportunidadesRango.map((o) => o.estadoCivil ?? '')), + porTipoEmpleo: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEmpleo ?? '')), + porTipoEconomia: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEconomia ?? '')), + porCalifica: contarPorNoVacios(oportunidadesRango.map((o) => o.califica ?? '')), + porCredito: contarPorNoVacios(oportunidadesRango.map((o) => o.credito ?? '')), porRangoSalarial: contarPorNoVacios( - datos.oportunidades + oportunidadesRango .map((o) => bucketSalario(o.rangoSalarial ?? '')) .filter((b): b is string => b !== null), ), - porMedioContacto: contarPorNoVacios(datos.oportunidades.map((o) => o.medioContacto ?? '')), - porLoQuierePara: contarPorNoVacios(datos.oportunidades.map((o) => o.loQuierePara ?? '')), + porMedioContacto: contarPorNoVacios(oportunidadesRango.map((o) => o.medioContacto ?? '')), + porLoQuierePara: contarPorNoVacios(oportunidadesRango.map((o) => o.loQuierePara ?? '')), porEdad: contarPorNoVacios( - datos.oportunidades.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)), + oportunidadesRango.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)), ), }, }, @@ -391,7 +406,8 @@ export class ReportesService { private readonly cacheMs: number private readonly salud: Pick private readonly cargarDatosLocales: CargadorDatosLocales - private cache: { resumen: ResumenReportes; expira: number } | null = null + /** Cache por rango (clave '0' = todo el histórico) para no mezclar ventanas */ + private cache: Map = new Map() constructor( private readonly monday: MondayReportes, @@ -406,15 +422,18 @@ export class ReportesService { /** Invalida el cache (útil tras escrituras que cambian los números) */ invalidarCache(): void { - this.cache = null + this.cache.clear() } - async resumen(): Promise { + async resumen(dias?: number): Promise { + const rango = dias !== undefined && Number.isFinite(dias) && dias > 0 ? Math.floor(dias) : undefined + const clave = String(rango ?? 0) const ahoraMs = this.now() - if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen + const hit = this.cache.get(clave) + if (hit && hit.expira > ahoraMs) return hit.resumen - const resumen = await this.cargarResumen(ahoraMs) - this.cache = { resumen, expira: ahoraMs + this.cacheMs } + const resumen = await this.cargarResumen(ahoraMs, rango) + this.cache.set(clave, { resumen, expira: ahoraMs + this.cacheMs }) return resumen } @@ -423,7 +442,7 @@ export class ReportesService { * de siempre). Camino degradado: Monday caído (circuit breaker) o AppError * 502 a media lectura → resumen desde tablas locales con `parcial: true`. */ - private async cargarResumen(ahoraMs: number): Promise { + private async cargarResumen(ahoraMs: number, dias?: number): Promise { if (this.salud.estaDisponible()) { try { // Las fuentes de Monday se piden en paralelo; Prisma va por su lado @@ -434,7 +453,7 @@ export class ReportesService { this.monday.getActividadesAbiertas(), this.cargarPlanes(), ]) - return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs)) + return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs), dias) } catch (err) { if (!(err instanceof AppError && err.status === 502)) throw err // Monday se cayó a media lectura: cae al camino local @@ -444,6 +463,7 @@ export class ReportesService { const resumen = construirResumen( { ...locales, planes, actividadesAbiertas: locales.actividadesAbiertas ?? 0 }, new Date(ahoraMs), + dias, ) // En modo local, los datos son completos (no parciales): todas las métricas // vienen de tablas locales. `parcial` solo se marca true cuando Monday cayó diff --git a/apps/agente-ia/src/pages/ChatPage.tsx b/apps/agente-ia/src/pages/ChatPage.tsx index d66dae7..cb45fb2 100644 --- a/apps/agente-ia/src/pages/ChatPage.tsx +++ b/apps/agente-ia/src/pages/ChatPage.tsx @@ -2,7 +2,13 @@ import { useEffect, useRef, useState } from 'react' import Burbuja from '../components/Burbuja' import DarkModeToggle from '../components/DarkModeToggle' import toast from "react-hot-toast" -import { enviarMensaje, esErrorDeRed, obtenerHistorial } from '../services/api' +import { + enviarMensaje, + esErrorDeRed, + listarConversaciones, + obtenerHistorial, + type ConversacionResumen, +} from '../services/api' import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion' import type { Mensaje } from '../types' @@ -23,6 +29,12 @@ export default function ChatPage() { const [escribiendo, setEscribiendo] = useState(false) const [sinConexion, setSinConexion] = useState(false) const [error, setError] = useState(null) + // Texto del último envío fallido (habilita "Reintentar" junto al input) + const [pendiente, setPendiente] = useState(null) + // Panel de conversaciones previas + const [panelAbierto, setPanelAbierto] = useState(false) + const [conversaciones, setConversaciones] = useState(null) + const [cargandoConversaciones, setCargandoConversaciones] = useState(false) const finalRef = useRef(null) // Al cargar: si hay sesión en localStorage, recupera su historial del API @@ -41,18 +53,15 @@ export default function ChatPage() { finalRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [mensajes, escribiendo]) - async function enviar() { - const texto = entrada.trim() - if (!texto || escribiendo) return - setEntrada('') - setError(null) - setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }]) + /** Envía el texto y añade la respuesta; en fallo deja el texto en `pendiente` */ + async function transmitir(texto: string) { setEscribiendo(true) try { const r = await enviarMensaje(sessionId, texto) setSessionId(r.sessionId) guardarSesion(r.sessionId) setSinConexion(false) + setPendiente(null) setMensajes((prev) => [ ...prev, { id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes }, @@ -63,18 +72,63 @@ export default function ChatPage() { } else { toast.error('Error al enviar el mensaje'); setError(err instanceof Error ? err.message : 'Error al enviar el mensaje') } - // el mensaje del usuario se queda visible; puede reintentar + setPendiente(texto) // el mensaje del usuario se queda visible; puede reintentar } finally { setEscribiendo(false) } } + async function enviar() { + const texto = entrada.trim() + if (!texto || escribiendo) return + setEntrada('') + setError(null) + setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }]) + await transmitir(texto) + } + + /** Reenvía el último mensaje que falló (sin duplicar la burbuja del usuario) */ + async function reintentar() { + if (!pendiente || escribiendo) return + setError(null) + setSinConexion(false) + await transmitir(pendiente) + } + function nuevaConversacion() { limpiarSesion() setSessionId(null) setMensajes([]) setError(null) setSinConexion(false) + setPendiente(null) + } + + /** Abre el panel y carga (una vez) el listado de conversaciones del usuario */ + function abrirPanel() { + setPanelAbierto(true) + if (conversaciones !== null || cargandoConversaciones) return + setCargandoConversaciones(true) + listarConversaciones() + .then(setConversaciones) + .catch(() => setConversaciones([])) + .finally(() => setCargandoConversaciones(false)) + } + + /** Carga el historial de una conversación previa y la vuelve la activa */ + async function abrirConversacion(id: string) { + try { + const historial = await obtenerHistorial(id) + setSessionId(id) + guardarSesion(id) + setMensajes(historial) + setPendiente(null) + setError(null) + setSinConexion(false) + setPanelAbierto(false) + } catch { + toast.error('No se pudo cargar la conversación') + } } return ( @@ -96,6 +150,14 @@ export default function ChatPage() {
+
)} + {pendiente && !escribiendo && ( +
+

+ No se pudo enviar tu último mensaje. +

+ +
+ )}
{ @@ -192,6 +268,64 @@ export default function ChatPage() {
+ + {/* Panel lateral: conversaciones previas del usuario */} + {panelAbierto && ( +
setPanelAbierto(false)}> + +
+ )} ) } diff --git a/apps/agente-ia/src/services/api.ts b/apps/agente-ia/src/services/api.ts index 26e8387..17ccb86 100644 --- a/apps/agente-ia/src/services/api.ts +++ b/apps/agente-ia/src/services/api.ts @@ -73,3 +73,14 @@ export async function obtenerHistorial(sessionId: string): Promise { fuentes: m.fuentes ?? [], })) } + +export interface ConversacionResumen { + id: string + createdAt: string + ultimoMensaje: { rol: string; contenido: string } | null +} + +/** GET /ai/conversations — conversaciones previas del usuario (más reciente primero) */ +export function listarConversaciones(): Promise { + return request('/ai/conversations') +} diff --git a/apps/alta-clientes/src/data/constantes.ts b/apps/alta-clientes/src/data/constantes.ts index 6e9581c..9bc2f5e 100644 --- a/apps/alta-clientes/src/data/constantes.ts +++ b/apps/alta-clientes/src/data/constantes.ts @@ -9,6 +9,7 @@ import type { TipoCredito, SlotDocumento, } from '../types' +import { getSesion } from '../services/auth' export const PROYECTOS = [ 'RESIDENCIAL MONTEBELLO', @@ -102,5 +103,9 @@ export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOL // El modelo de interés sale de MODELOS_POR_PROYECTO según el desarrollo elegido. export const OPCIONES_CALIFICA = ['Sí', 'No', 'Por confirmar'] as const -export const ASESOR_DEFAULT = 'ESMERALDA BECERRA DOMINGUEZ' +/** + * Nombre del asesor para el campo de firma del alta: se toma de la sesión + * activa en el momento en que arranca el alta. Sin sesión queda vacío. + */ +export const asesorDeSesion = (): string => getSesion()?.usuario.nombre ?? '' export const AUTORIDAD_ID_DEFAULT = 'INSTITUTO NACIONAL ELECTORAL' diff --git a/apps/alta-clientes/src/forms/ReferenciasForm.tsx b/apps/alta-clientes/src/forms/ReferenciasForm.tsx index e842060..5df701d 100644 --- a/apps/alta-clientes/src/forms/ReferenciasForm.tsx +++ b/apps/alta-clientes/src/forms/ReferenciasForm.tsx @@ -1,5 +1,5 @@ import { z } from 'zod' -import { useForm, FormProvider } from 'react-hook-form' +import { useForm, FormProvider, useFieldArray } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import type { Referencia } from '../types' import { referenciasSchema } from '../lib/schemas' @@ -11,13 +11,18 @@ import type { PasoProps } from './DatosGeneralesForm' // RHF trabaja con objetos raíz; el array va envuelto const schemaFormulario = z.object({ referencias: referenciasSchema }) -/** Paso 5: referencias personales (4 filas, mínimo 1 con nombre y teléfono) */ +const MAX_REFERENCIAS = 4 + +const referenciaVacia = (): Referencia => ({ nombre: '', telefono: '', parentesco: '', direccion: '' }) + +/** Paso 5: referencias personales (1 a 4 bloques; al menos 1 con nombre y teléfono) */ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: PasoProps) { const metodos = useForm<{ referencias: Referencia[] }>({ resolver: zodResolver(schemaFormulario), defaultValues: { referencias: valores }, mode: 'onSubmit', }) + const { fields, append, remove } = useFieldArray({ control: metodos.control, name: 'referencias' }) const errorRaiz = metodos.formState.errors.referencias return ( @@ -30,9 +35,24 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa titulo="Referencias personales" descripcion="Captura al menos 1 referencia con nombre y teléfono." > - {[0, 1, 2, 3].map((i) => ( -
-

Referencia {i + 1}

+ {fields.map((field, i) => ( +
+
+

+ Referencia {i + 1} +

+ {fields.length > 1 && ( + + )} +
@@ -41,6 +61,17 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa
))} +
+ +
{errorRaiz && typeof errorRaiz.message === 'string' && (

{errorRaiz.message}

diff --git a/apps/alta-clientes/src/hooks/useAltaCliente.ts b/apps/alta-clientes/src/hooks/useAltaCliente.ts index 207c49b..54fa171 100644 --- a/apps/alta-clientes/src/hooks/useAltaCliente.ts +++ b/apps/alta-clientes/src/hooks/useAltaCliente.ts @@ -1,7 +1,7 @@ import { useState } from 'react' import type { AltaCliente } from '../types' import { fechaISOHoy } from '../lib/formatters' -import { ASESOR_DEFAULT, AUTORIDAD_ID_DEFAULT } from '../data/constantes' +import { asesorDeSesion, AUTORIDAD_ID_DEFAULT } from '../data/constantes' import { useDocumentos } from './useDocumentos' const datosGeneralesVacios = (): AltaCliente['datosGenerales'] => ({ @@ -56,10 +56,18 @@ const economicosVacios = (): AltaCliente['economicos'] => ({ dependientes: { personas: '', parentesco: '', edad: '' }, }) +const referenciaVacia = (): AltaCliente['referencias'][number] => ({ + nombre: '', + telefono: '', + parentesco: '', + direccion: '', +}) + const estadoInicial = (): Omit => ({ leadMondayId: null, apartadoMondayId: null, progresoId: null, + foto: null, datosGenerales: datosGeneralesVacios(), empleo: empleoVacio(), economicos: economicosVacios(), @@ -69,12 +77,7 @@ const estadoInicial = (): Omit => ({ empleo: empleoVacio(), economicos: economicosVacios(), }, - referencias: [ - { nombre: '', telefono: '', parentesco: '', direccion: '' }, - { nombre: '', telefono: '', parentesco: '', direccion: '' }, - { nombre: '', telefono: '', parentesco: '', direccion: '' }, - { nombre: '', telefono: '', parentesco: '', direccion: '' }, - ], + referencias: [referenciaVacia()], viviendaCredito: { proyecto: '', proyectoOtro: '', @@ -107,7 +110,7 @@ const estadoInicial = (): Omit => ({ pais: 'MEXICO', }, }, - firma: { firmaDataUrl: '', asesorNombre: ASESOR_DEFAULT }, + firma: { firmaDataUrl: '', asesorNombre: asesorDeSesion() }, }) export const PASOS = [ diff --git a/apps/alta-clientes/src/lib/fixture.ts b/apps/alta-clientes/src/lib/fixture.ts index 3c75bb1..796bbdd 100644 --- a/apps/alta-clientes/src/lib/fixture.ts +++ b/apps/alta-clientes/src/lib/fixture.ts @@ -12,6 +12,7 @@ export const fixtureAlta: AltaCliente = { leadMondayId: null, apartadoMondayId: null, progresoId: null, + foto: null, datosGenerales: { campaniaVenta: '', fecha: '2026-07-19', diff --git a/apps/alta-clientes/src/lib/schemas.test.ts b/apps/alta-clientes/src/lib/schemas.test.ts index 9b463aa..908eb50 100644 --- a/apps/alta-clientes/src/lib/schemas.test.ts +++ b/apps/alta-clientes/src/lib/schemas.test.ts @@ -170,4 +170,17 @@ describe('referenciasSchema', () => { ]) expect(r.success).toBe(true) }) + + it('acepta un solo bloque de referencia (arreglo dinámico, ya no exige 4)', () => { + const r = referenciasSchema.safeParse([ + { nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: 'AMIGO', direccion: 'ALTAMIRA' }, + ]) + expect(r.success).toBe(true) + }) + + it('rechaza más de 4 referencias', () => { + const valida = { nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: '', direccion: '' } + const r = referenciasSchema.safeParse([valida, valida, valida, valida, valida]) + expect(r.success).toBe(false) + }) }) diff --git a/apps/alta-clientes/src/lib/schemas.ts b/apps/alta-clientes/src/lib/schemas.ts index 162f54c..4787fd4 100644 --- a/apps/alta-clientes/src/lib/schemas.ts +++ b/apps/alta-clientes/src/lib/schemas.ts @@ -149,7 +149,8 @@ const referenciaSchema = z.object({ export const referenciasSchema = z .array(referenciaSchema) - .length(4, 'Se capturan 4 referencias') + .min(1, 'Captura al menos 1 referencia con nombre y teléfono') + .max(4, 'Máximo 4 referencias') .refine( (refs) => refs.some((r) => r.nombre.trim() !== '' && r.telefono.trim() !== ''), 'Captura al menos 1 referencia con nombre y teléfono', diff --git a/apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx b/apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx index c40e8cb..e2ed7aa 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx @@ -146,6 +146,11 @@ describe('AltaClientePage — guardado progresivo (etapa por etapa)', () => { ), ).toHaveLength(0) // El flujo posterior queda idéntico: alta registrada con el id del progreso - expect(await screen.findByText(/Alta registrada en el servidor: prog-1/)).toBeTruthy() + expect(await screen.findByText(/Alta registrada correctamente/)).toBeTruthy() + expect(screen.getByText(/Folio prog-1/)).toBeTruthy() + // Pantalla de éxito con las acciones post-registro (sin redirect automático) + expect(screen.getByRole('button', { name: 'Generar liga de firma' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Ir a Mi día' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Registrar otro cliente' })).toBeTruthy() }) }) diff --git a/apps/alta-clientes/src/pages/AltaClientePage.tsx b/apps/alta-clientes/src/pages/AltaClientePage.tsx index b919e10..8c9e820 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.tsx @@ -226,9 +226,9 @@ export default function AltaClientePage() { setPantalla('wizard') } - const guardar = async () => { + const guardar = async (pasoDestino: number = paso) => { try { - const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso, datos }) + const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso: pasoDestino, datos }) toast.success("Borrador guardado") setBorradorId(b.id) setAvisoBorrador( @@ -239,6 +239,49 @@ export default function AltaClientePage() { } } + // ---------- Guard de cambios sin guardar (saltos con el Stepper) ---------- + // El formulario del paso guarda sus capturas solo al avanzar con "Siguiente"; + // si el usuario salta a otro paso con el Stepper, se le ofrece guardar antes. + const [sucio, setSucio] = useState(false) + // Paso elegido en el Stepper mientras el confirm de cambios sin guardar está abierto + const [destinoPendiente, setDestinoPendiente] = useState(null) + + // Al cambiar de paso el formulario se desmonta y remonta: se parte de cero + useEffect(() => { + setSucio(false) + }, [paso]) + + /** Cualquier edición dentro del paso actual marca cambios sin guardar */ + const marcarSucio = () => setSucio(true) + + /** Cambio de paso vía Stepper: confirma antes de saltar si hay cambios sin guardar */ + const irAPasoConGuard = (destino: number) => { + if (destino === paso) return + if (sucio) { + setDestinoPendiente(destino) + return + } + irAPaso(destino) + } + + /** "Guardar": persiste el borrador apuntando al paso destino y continúa hacia él */ + const guardarYContinuar = async () => { + const destino = destinoPendiente + if (destino === null) return + setDestinoPendiente(null) + await guardar(destino) + irAPaso(destino) + } + + /** "Continuar sin guardar": descarta lo capturado en el paso y salta al destino */ + const continuarSinGuardar = () => { + const destino = destinoPendiente + if (destino === null) return + setDestinoPendiente(null) + setSucio(false) + irAPaso(destino) + } + // ---------- Guardado progresivo (borrador local + Contacto en Monday) ---------- // Guard in-flight: con clicks rápidos se omite el sync en curso; el siguiente // avance manda el estado completo (el PATCH es idempotente). @@ -309,14 +352,20 @@ export default function AltaClientePage() { } } - // Redirigir a Mi día después de completar el alta - useEffect(() => { - if (!registro) return - const timer = setTimeout(() => { - window.location.href = '/hoy/' - }, 2000) - return () => clearTimeout(timer) - }, [registro]) + // Pantalla de éxito tras registrar el alta (en lugar del redirect automático a Mi día) + const registrarOtro = () => { + setRegistro('') + setSinConexion(false) + setErrorValidacion('') + setLigaUrl('') + setLigaCopiada(false) + setErrorLiga('') + empezarNuevo() + } + + const irAMiDia = () => { + window.location.href = '/hoy/' + } // Genera la liga pública y copia al portapapeles el texto listo para WhatsApp const generarLiga = async () => { @@ -485,6 +534,77 @@ export default function AltaClientePage() { ) } + // ---------- Pantalla de éxito: el alta quedó registrada en el servidor ---------- + if (registro) { + return ( +
+ +
+
+
+
+ +
+

+ Alta registrada correctamente +

+

+ {alta.datosGenerales.nombreCompleto || 'Cliente'} · Folio {registro} +

+
+
+ +
+ + + +
+ +

+ Con la liga de firma, el cliente firma su expediente y sube documentos desde su + celular (vigencia 72 h, sin usuario ni contraseña). +

+ {ligaUrl && ( +

+ {ligaUrl} + {ligaCopiada && ( + + Liga y mensaje de WhatsApp copiados + + )} +

+ )} + {errorLiga &&

{errorLiga}

} +
+
+
+
+ ) + } + return (
- +
-
+
{errorCarga && (

{errorCarga} @@ -549,9 +669,10 @@ export default function AltaClientePage() {

{ - // Foto capturada (el componente gestiona el preview internamente) + foto={datos.foto} + onFoto={(foto) => { + actualizar('foto', foto || null) + marcarSucio() }} />
@@ -662,16 +783,6 @@ export default function AltaClientePage() { )} {paso === 7 && ( <> - {registro && ( -

- Alta registrada en el servidor: {registro} -

- )} - {registro && ( -

- Redirigiendo a Mi día en unos segundos… -

- )} {sinConexion && (

)} - {registro && ( -

-

- Firma en remoto -

-

- Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus - documentos desde su celular, sin usuario ni contraseña. -

- - {ligaUrl && ( -

- {ligaUrl} - {ligaCopiada && ( - - Liga y mensaje de WhatsApp copiados - - )} -

- )} - {errorLiga &&

{errorLiga}

} -
- )}
+ + {/* Confirmación de cambios sin guardar al saltar de paso con el Stepper */} + {destinoPendiente !== null && ( +
+
+

+ Cambios sin guardar +

+

+ ¿Deseas guardar antes de continuar? +

+
+ + +
+
+
+ )}
) } diff --git a/apps/alta-clientes/src/types/index.ts b/apps/alta-clientes/src/types/index.ts index f4be065..ab22e02 100644 --- a/apps/alta-clientes/src/types/index.ts +++ b/apps/alta-clientes/src/types/index.ts @@ -157,6 +157,9 @@ export interface AltaCliente { // ID del Cliente creado por el guardado progresivo (POST /clientes/progreso/ // iniciar); viaja con el borrador (datosJson) y no se captura en el formato. progresoId: string | null + // Foto del cliente (data URL base64) capturada en el paso de datos generales; + // null cuando aún no se toma. Viaja en el payload del alta y en los borradores. + foto: string | null datosGenerales: DatosGenerales empleo: DatosEmpleo economicos: DatosEconomicos diff --git a/apps/cobranza/src/components/MoraDashboard.tsx b/apps/cobranza/src/components/MoraDashboard.tsx index 3a1c30b..e49f8d4 100644 --- a/apps/cobranza/src/components/MoraDashboard.tsx +++ b/apps/cobranza/src/components/MoraDashboard.tsx @@ -1,36 +1,36 @@ import { useCallback, useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { request } from '../services/api' import { formatoMoneda } from '../lib/formatters' +import { telefonoParaEnlace } from '../lib/cobranza' import KpiCard from './KpiCard' -/** Registro de cliente moroso del endpoint GET /api/reportes/mora */ +/** Registro de cliente moroso del endpoint GET /api/reportes/mora (mora.service) */ export interface ClienteMora { - clienteId: string clienteNombre: string - clienteTelefono: string unidadCodigo: string proyecto: string - pagoVencido: number // ISO fecha + pagoVencido: number // número de pago vencido monto: number diasAtraso: number + telefono: string | null + planId: string } interface MoraResponse { - totalMorosos: number + clientesConMora: number montoTotalVencido: number - diasPromedio: number - clientes: ClienteMoria[] + diasPromedioAtraso: number + clientes: ClienteMora[] } -/** 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') + // Misma normalización +52 que mi-dia: 10 dígitos → México + window.open(`https://wa.me/${telefonoParaEnlace(telefono)}`, '_blank') } export default function MoraDashboard() { + const navigate = useNavigate() const [datos, setDatos] = useState(null) const [cargando, setCargando] = useState(true) const [error, setError] = useState(null) @@ -80,9 +80,9 @@ export default function MoraDashboard() { return (
- + - +
@@ -100,7 +100,7 @@ export default function MoraDashboard() { {datos.clientes.map((c) => ( - + {c.clienteNombre} {c.unidadCodigo} {c.proyecto} @@ -114,15 +114,25 @@ export default function MoraDashboard() { - {c.clienteTelefono && ( +
- )} + {c.telefono && ( + + )} +
))} diff --git a/apps/cobranza/src/lib/cobranza.ts b/apps/cobranza/src/lib/cobranza.ts index b2d7671..0b05683 100644 --- a/apps/cobranza/src/lib/cobranza.ts +++ b/apps/cobranza/src/lib/cobranza.ts @@ -183,6 +183,16 @@ export interface CobranzaItem { atraso: number } +/** + * Normaliza un teléfono a dígitos para enlaces wa.me. Si el número no trae + * lada internacional (10 dígitos), asume México (+52); si ya la trae, la respeta. + */ +export function telefonoParaEnlace(telefono: string): string { + const digitos = telefono.replace(/\D/g, '') + if (digitos.length === 10) return `52${digitos}` + return digitos +} + /** * Lista plana de "cobranza del día" a través de todos los planes ACTIVOS: * pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos diff --git a/apps/cobranza/src/pages/DashboardPage.tsx b/apps/cobranza/src/pages/DashboardPage.tsx index e8424b7..dce2519 100644 --- a/apps/cobranza/src/pages/DashboardPage.tsx +++ b/apps/cobranza/src/pages/DashboardPage.tsx @@ -370,6 +370,7 @@ export default function DashboardPage() { Cliente + Teléfono Unidad Proyecto {encabezadoOrdenable('avance', '% pagado')} @@ -387,6 +388,7 @@ export default function DashboardPage() { {d.plan.clienteNombre} + {d.plan.clienteTelefono || '—'} {d.plan.unidadCodigo} {d.plan.proyecto} @@ -414,7 +416,7 @@ export default function DashboardPage() { ))} {ordenados.length === 0 && ( - + No hay planes que coincidan con el filtro. diff --git a/apps/cobranza/src/pages/PlanDetallePage.tsx b/apps/cobranza/src/pages/PlanDetallePage.tsx index a972b62..8b0f46a 100644 --- a/apps/cobranza/src/pages/PlanDetallePage.tsx +++ b/apps/cobranza/src/pages/PlanDetallePage.tsx @@ -12,6 +12,7 @@ import { diasAtraso, moraDePago, pagadoTotal, + proximoVencimiento, round2, saldoPlan, statusPago, @@ -136,22 +137,43 @@ export default function PlanDetallePage() { const avance = avancePlan(plan) + /** Siguiente pago sin cubrir (futuro próximo o vencido más antiguo) */ + const proximoPago = status === 'ACTIVO' ? proximoVencimiento(plan, hoy) : null + + /** Abonar al próximo pago: hace scroll a su fila y abre el modal prellenado */ + const abonarProximoPago = (pago: Pago) => { + document.getElementById(`pago-${pago.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + setPagoAbono(pago) + } + return (
-
- - ← Volver al dashboard - -

{plan.clienteNombre}

-

- {plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '} - {status} - {atraso > 0 && · {atraso} días de atraso} -

-

- Tel. {plan.clienteTelefono || '—'} - {tasaMora > 0 && · Mora {tasaMora}% mensual} -

+
+
+ + ← Volver al dashboard + +

{plan.clienteNombre}

+

+ {plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '} + {status} + {atraso > 0 && · {atraso} días de atraso} +

+

+ Tel. {plan.clienteTelefono || '—'} + {tasaMora > 0 && · Mora {tasaMora}% mensual} +

+
+ {proximoPago && ( + + )}
{offline && ( @@ -245,7 +267,7 @@ export default function PlanDetallePage() { const expandido = expandidos.has(pago.id) return ( - + {pago.numPago} {formatoFechaCorta(pago.fechaVencimiento)} {formatoMoneda(pago.monto)} diff --git a/apps/cobranza/src/services/repository.api.test.ts b/apps/cobranza/src/services/repository.api.test.ts index 7774c61..290f535 100644 --- a/apps/cobranza/src/services/repository.api.test.ts +++ b/apps/cobranza/src/services/repository.api.test.ts @@ -13,6 +13,9 @@ import { type StorageLike, } from './repository' import { ErrorApi } from './api' + +// Misma resolución de base que api.ts: el test no depende de la VITE_API_URL local +const API_URL = import.meta.env.VITE_API_URL || '/api' import type { PlanPago } from '../types' /** Stub sencillo de localStorage para correr en entorno node */ @@ -120,7 +123,7 @@ describe('repository async (API primero, fallback localStorage)', () => { expect(plan.tasaMoraMensual).toBeUndefined() expect(plan).not.toHaveProperty('saldo') expect(fetchMock).toHaveBeenCalledWith( - 'http://192.168.10.123:3000/api/planes', + `${API_URL}/planes`, expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }), ) }) @@ -205,7 +208,7 @@ describe('repository async (API primero, fallback localStorage)', () => { expect(r.plan.id).toBe('api-nuevo') expect(storage.getItem(CLAVE_STORAGE)).toBeNull() const [url, init] = fetchMock.mock.calls[0] - expect(url).toBe('http://192.168.10.123:3000/api/planes') + expect(url).toBe(`${API_URL}/planes`) expect(init?.method).toBe('POST') const body = JSON.parse(String(init?.body)) as Record expect(body.clienteNombre).toBe('Cliente Prueba') @@ -239,7 +242,7 @@ describe('repository async (API primero, fallback localStorage)', () => { expect(r.origen).toBe('api') const [url, init] = fetchMock.mock.calls[0] - expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/abonos') + expect(url).toBe(`${API_URL}/planes/api-1/abonos`) const body = JSON.parse(String(init?.body)) as Record expect(body).toEqual({ pagoId: 'pago-1', fechaPago: '2026-09-02', monto: 10000, referencia: 'SPEI-1' }) }) @@ -291,7 +294,7 @@ describe('repository async (API primero, fallback localStorage)', () => { texto: 'Se habló con el cliente', }) const [url] = fetchMock.mock.calls[0] - expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/notas') + expect(url).toBe(`${API_URL}/planes/api-1/notas`) }) it('agregarNotaAsync cae a localStorage cuando falla la red (conserva la validación local)', async () => { @@ -317,7 +320,7 @@ describe('repository async (API primero, fallback localStorage)', () => { expect(r.origen).toBe('api') expect(r.plan.status).toBe('CANCELADO') const [url, init] = fetchMock.mock.calls[0] - expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/cancelar') + expect(url).toBe(`${API_URL}/planes/api-1/cancelar`) expect(init?.method).toBe('POST') }) @@ -361,7 +364,7 @@ describe('mis cobranzas (toggle Mis planes / Todos)', () => { expect(r.origen).toBe('api') expect(fetchMock).toHaveBeenCalledWith( - 'http://192.168.10.123:3000/api/planes?mios=1', + `${API_URL}/planes?mios=1`, expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }), ) }) diff --git a/apps/cotizador/src/components/MisCotizaciones.tsx b/apps/cotizador/src/components/MisCotizaciones.tsx index c867be2..a218289 100644 --- a/apps/cotizador/src/components/MisCotizaciones.tsx +++ b/apps/cotizador/src/components/MisCotizaciones.tsx @@ -107,12 +107,15 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr > Retomar - - Iniciar alta - + {/* El alta prellenada requiere la cotización en el servidor; las locales (CTE-LOCAL-*) no la tienen */} + {!c.id.startsWith('local-') && ( + + Iniciar alta + + )}
diff --git a/apps/cotizador/src/lib/pdf.test.ts b/apps/cotizador/src/lib/pdf.test.ts index ce98772..8de4cac 100644 --- a/apps/cotizador/src/lib/pdf.test.ts +++ b/apps/cotizador/src/lib/pdf.test.ts @@ -55,6 +55,30 @@ describe('crearDocumentoPdf', () => { }) expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1) }) + + it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => { + const unidad = UNIDADES_SEED[0] + const propuestas = calcularPropuestas({ + unidad, + acabados: true, + apartado: APARTADO_DEFAULT, + descuentos: { ...DESCUENTOS_DEFAULT }, + numMeses: 14, + }) + const { doc } = crearDocumentoPdf({ + unidad, + acabados: true, + apartado: APARTADO_DEFAULT, + precioLista: unidad.precioConAcabados, + propuestas, + cliente: { nombre: 'Juan Pérez' }, + folio: 'CTE-LOCAL-1757568000000', + }) + // El folio local viaja en el contenido del PDF aunque no se haya guardado en el API + const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer')) + expect(pdf).toContain('CTE-LOCAL-1757568000000') + expect(pdf).toContain('FOLIO') + }) }) describe('cuota de mantenimiento', () => { diff --git a/apps/cotizador/src/pages/CotizadorPage.smoke.test.tsx b/apps/cotizador/src/pages/CotizadorPage.smoke.test.tsx new file mode 100644 index 0000000..d057033 --- /dev/null +++ b/apps/cotizador/src/pages/CotizadorPage.smoke.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import CotizadorPage from './CotizadorPage' +import { INVENTARIO_SEED } from '../data/unidades' + +/** + * Smoke del cierre de la cotización: + * - barra sticky con propuesta elegida + Compartir/Guardar/Generar PDF + * - preselección por defecto de la propuesta de menor total + * - sin servidor: la cotización se guarda localmente con folio CTE-LOCAL-* + */ +const unidad = INVENTARIO_SEED.find((u) => u.status === 'DISPONIBLE' && u.desarrollo === 'DIAGONAL')! + +const respuesta = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }) + +// El entorno de test no trae localStorage: mock en memoria para el fallback local +const almacenamiento = new Map() +beforeEach(() => { + almacenamiento.clear() + vi.stubGlobal('localStorage', { + getItem: (k: string) => almacenamiento.get(k) ?? null, + setItem: (k: string, v: string) => void almacenamiento.set(k, v), + removeItem: (k: string) => void almacenamiento.delete(k), + clear: () => almacenamiento.clear(), + }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('CotizadorPage — barra sticky y guardado local', () => { + it('preselecciona la propuesta más barata y guarda local con folio CTE-LOCAL cuando el API no responde', async () => { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const metodo = init?.method ?? 'GET' + if (url.includes('/unidades')) return Promise.resolve(respuesta([unidad])) + if (url.includes('/cotizaciones/mias')) return Promise.resolve(respuesta([])) + if (url.includes('/cotizaciones') && metodo === 'POST') { + return Promise.reject(new TypeError('servidor caído')) + } + return Promise.reject(new TypeError(`fetch inesperado: ${metodo} ${url}`)) + }) + vi.stubGlobal('fetch', fetchMock) + + render( + + + , + ) + + // Barra sticky siempre presente, con las tres acciones + expect(await screen.findByRole('button', { name: 'Generar PDF' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Compartir' })).toBeTruthy() + expect(screen.getByText(/Selecciona una unidad para elegir propuesta/)).toBeTruthy() + + // Elegir una unidad del inventario + const inputUnidad = screen.getByLabelText('Unidad') as HTMLInputElement + fireEvent.focus(inputUnidad) + fireEvent.change(inputUnidad, { target: { value: unidad.codigo } }) + fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidad.codigo) })) + + // Preselección por defecto: la propuesta de menor total pagado + const selectPropuesta = (await screen.findByLabelText( + 'Cambiar propuesta elegida', + )) as HTMLSelectElement + await waitFor(() => expect(selectPropuesta.value).toBe('2')) // Contado es la más barata + expect(screen.getByText(/Propuesta elegida: #3/)).toBeTruthy() + + // Guardar con el servidor caído → folio local persistido en el dispositivo + fireEvent.change(screen.getByLabelText(/Nombre del cliente/), { + target: { value: 'Cliente Prueba Local' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Guardar' })) + + await waitFor(() => { + const locales = JSON.parse( + almacenamiento.get('frasa-cotizador:cotizaciones-locales') ?? '[]', + ) as { folio?: string; clienteNombre?: string }[] + expect(locales).toHaveLength(1) + expect(locales[0]?.folio ?? '').toMatch(/^CTE-LOCAL-\d+$/) + expect(locales[0]?.clienteNombre).toBe('Cliente Prueba Local') + }) + expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy() + }) +}) diff --git a/apps/cotizador/src/pages/CotizadorPage.tsx b/apps/cotizador/src/pages/CotizadorPage.tsx index e3c8b97..7677c3b 100644 --- a/apps/cotizador/src/pages/CotizadorPage.tsx +++ b/apps/cotizador/src/pages/CotizadorPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -21,6 +21,7 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters' import { calcularVigencia } from '../lib/vigencia' import { folioCotizacion } from '../lib/folio' import { textoCompartir, urlWhatsApp } from '../lib/compartir' +import { indiceMenorTotal, totalPagado } from '../lib/calculations' import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos' import { getMisCotizaciones, @@ -30,6 +31,7 @@ import { TIPO_DE_API, type CotizacionGuardada, } from '../services/api' +import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db' import type { Cliente } from '../types' const esquemaCliente = z.object({ @@ -87,6 +89,8 @@ export default function CotizadorPage() { const [errorMias, setErrorMias] = useState(null) const [ultimaGuardada, setUltimaGuardada] = useState(null) const [modalClienteAbierto, setModalClienteAbierto] = useState(false) + // true cuando el modal de cliente se abrió desde "Guardar" (sin generar PDF) + const [guardarSinPdf, setGuardarSinPdf] = useState(false) const [guardando, setGuardando] = useState(false) const [aviso, setAviso] = useState(null) // Prefill desde Mi día (botón "Cotizar" de una oportunidad): el id queda @@ -96,9 +100,22 @@ export default function CotizadorPage() { useEffect(() => { let vivo = true + /** Combina servidor + locales, sin duplicados, más recientes primero */ + const mezclar = (principal: CotizacionGuardada[], extra: CotizacionGuardada[]) => { + const ids = new Set(principal.map((c) => c.id)) + const combinadas = [...principal, ...extra.filter((c) => !ids.has(c.id))] + return combinadas + .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)) + .slice(0, 50) + } + // Cotizaciones guardadas localmente cuando el API no respondió + void cargarCotizacionesLocales().then((locales) => { + if (!vivo || locales.length === 0) return + setMisCotizaciones((prev) => mezclar(prev, locales as CotizacionGuardada[])) + }) getMisCotizaciones() .then((lista) => { - if (vivo) setMisCotizaciones(lista) + if (vivo) setMisCotizaciones((prev) => mezclar(lista, prev)) }) .catch((err) => { console.warn('[api] No se pudo cargar el historial de cotizaciones.', err) @@ -162,9 +179,63 @@ export default function CotizadorPage() { // La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))] + // Total pagado por el cliente con la propuesta elegida; null si no se puede conocer + const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !errorEnganche && !guardando - /** Persiste la cotización en el API; null si no hay conexión (el PDF sale igual, sin folio/QR) */ + // ---------- Preselección por defecto: la propuesta de menor total pagado ---------- + // Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta + // más barata; la elección manual del vendedor se respeta hasta el siguiente + // cambio de unidad. handleRetomar marca saltarAuto: manda SU propuesta guardada. + const unidadPrevia = useRef(undefined) + const saltarAuto = useRef(false) + + useEffect(() => { + const codigo = unidad?.codigo ?? null + const cambioUnidad = unidadPrevia.current !== undefined && unidadPrevia.current !== codigo + unidadPrevia.current = codigo + if (!cambioUnidad || saltarAuto.current) { + saltarAuto.current = false + return + } + const totales = propuestas.map((p) => totalPagado(p, apartadoEfectivo)) + const indiceMenor = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY)) + if (indiceMenor >= 0) setPropuestaIndex(indiceMenor) + }, [unidad?.codigo, propuestas, apartadoEfectivo]) + + /** true cuando la cotización vive solo en este dispositivo (el API no respondió) */ + const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-') + + /** Instantánea local de la cotización con folio CTE-LOCAL- */ + const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => { + const timestamp = Date.now() + return { + id: `local-${timestamp}`, + folio: `CTE-LOCAL-${timestamp}`, + unidadCodigo: unidad!.codigo, + desarrollo, + clienteNombre: cliente.nombre, + clienteTelefono: cliente.telefono ?? null, + clienteEmail: cliente.email ?? null, + tipoPropuesta: TIPO_A_API[propuestaActiva.tipo], + acabados, + apartado: apartadoEfectivo, + descuento: propuestaActiva.descuento, + precioLista, + precioFinal: propuestaActiva.precioFinal, + enganche: propuestaActiva.enganche, + numMensualidades: propuestaActiva.numMensualidades ?? 0, + mensualidad: propuestaActiva.mensualidad, + pagoFinal: propuestaActiva.pagoFinal, + createdAt: new Date(timestamp).toISOString(), + } + } + + /** + * Persiste la cotización en el API. Si no responde, la guarda localmente + * (IndexedDB/localStorage) con folio CTE-LOCAL-* y la devuelve igual: el PDF + * siempre sale con folio (sin QR, que solo existe para cotizaciones del servidor). + */ const guardarEnApi = async (cliente: Cliente): Promise => { if (!unidad || !propuestaActiva) return null try { @@ -186,9 +257,14 @@ export default function CotizadorPage() { setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50)) return conFolio } catch (err) { - toast.error('No se pudo guardar la cotización'); console.warn('[api] No se pudo guardar la cotización; el PDF sale sin folio/QR.', err) - setAviso('Sin conexión al servidor: la cotización no se guardó (PDF sin folio/QR).') - return null + console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err) + const local = cotizacionLocal(cliente) + void guardarCotizacionLocal(local) + setUltimaGuardada(local) + setMisCotizaciones((prev) => [local, ...prev].slice(0, 50)) + toast('Cotización guardada en este dispositivo (sin conexión al servidor)') + setAviso(`Sin conexión al servidor: cotización guardada localmente con folio ${local.folio}.`) + return local } } @@ -201,10 +277,13 @@ export default function CotizadorPage() { let qrDataUrl: string | undefined if (guardada) { folio = guardada.folio ?? folioCotizacion(guardada.id) - qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), { - margin: 1, - width: 200, - }) + // El QR apunta al resumen público en el servidor; una cotización local no lo tiene + if (!esCotizacionLocal(guardada)) { + qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), { + margin: 1, + width: 200, + }) + } } generarPdf({ unidad, @@ -227,6 +306,7 @@ export default function CotizadorPage() { if (!unidad) return // Sin nombre capturado, pide los datos del cliente en el modal if (nombreCliente.trim().length < 3) { + setGuardarSinPdf(false) setModalClienteAbierto(true) return } @@ -237,12 +317,42 @@ export default function CotizadorPage() { }) } + /** Guarda solo (sin PDF); con el flujo de modal si falta el nombre del cliente */ + const guardarSolo = async (cliente: Cliente) => { + if (!unidad) return + setGuardando(true) + try { + await guardarEnApi(cliente) + } finally { + setGuardando(false) + } + } + + const handleGuardar = () => { + if (!unidad) return + if (nombreCliente.trim().length < 3) { + setGuardarSinPdf(true) + setModalClienteAbierto(true) + return + } + void guardarSolo({ + nombre: nombreCliente.trim(), + email: watch('email') || undefined, + telefono: watch('telefono') || undefined, + }) + } + const handleModalAceptar = (cliente: Cliente) => { setModalClienteAbierto(false) setValue('nombre', cliente.nombre, { shouldValidate: true }) if (cliente.telefono) setValue('telefono', cliente.telefono, { shouldValidate: true }) if (cliente.email) setValue('email', cliente.email, { shouldValidate: true }) - void generarPdfConCliente(cliente) + if (guardarSinPdf) { + setGuardarSinPdf(false) + void guardarSolo(cliente) + } else { + void generarPdfConCliente(cliente) + } } const handleCompartir = async () => { @@ -256,7 +366,10 @@ export default function CotizadorPage() { ...(ultimaGuardada ? { folio: ultimaGuardada.folio ?? folioCotizacion(ultimaGuardada.id), - urlPublica: urlPublicaCotizacion(ultimaGuardada.id), + // La URL pública solo existe si la cotización vive en el servidor + ...(esCotizacionLocal(ultimaGuardada) + ? {} + : { urlPublica: urlPublicaCotizacion(ultimaGuardada.id) }), } : {}), }) @@ -281,6 +394,7 @@ export default function CotizadorPage() { } const tipo = TIPO_DE_API[c.tipoPropuesta] const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo) + saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada restaurar({ desarrollo: c.desarrollo, unidad: u, @@ -306,7 +420,7 @@ export default function CotizadorPage() { return (
-
+
{/* Encabezado de página */}
@@ -599,49 +713,6 @@ export default function CotizadorPage() { propuestas={propuestas} titulos={propuestas.map((p) => p.titulo)} /> -
-
-
- - -
- - -
- {aviso &&

{aviso}

} - {!puedeGenerarPdf && !guardando && ( -

- Selecciona una unidad para guardar y generar el PDF. -

- )} -
)} @@ -664,6 +735,71 @@ export default function CotizadorPage() {
+ {/* Barra fija de cierre: propuesta elegida + acciones, siempre visibles */} +
+
+
+ {propuestaActiva ? ( + <> + + + Propuesta elegida: #{propuestas.indexOf(propuestaActiva) + 1} {propuestaActiva.titulo} + {totalActivo !== null ? ` · Total ${formatoMoneda(totalActivo)}` : ''} + + + + + ) : ( +

+ Selecciona una unidad para elegir propuesta, guardar y generar el PDF. +

+ )} + {aviso && ( +

+ {aviso} +

+ )} +
+
+ + + +
+
+
+ { }) } +// Todas las operaciones son tolerantes a fallos: sin IndexedDB (navegación +// privada, tests) no lanzan, para no romper el flujo que las usa. export async function guardarUnidades(unidades: unknown[]) { - const db = await getDB() - await db.put('unidades', unidades, 'actual') + try { + const db = await getDB() + await db.put('unidades', unidades, 'actual') + } catch { + // Sin almacenamiento: el inventario se recarga del API en la próxima sesión + } } -export async function cargarUnidadesLocal() { - const db = await getDB() - return db.get('unidades', 'actual') +export async function cargarUnidadesLocal(): Promise { + try { + const db = await getDB() + return db.get('unidades', 'actual') + } catch { + return undefined + } } export async function guardarCotizaciones(cotizaciones: unknown[]) { - const db = await getDB() - await db.put('cotizaciones', cotizaciones, 'historial') + try { + const db = await getDB() + await db.put('cotizaciones', cotizaciones, 'historial') + } catch { + // Sin almacenamiento + } } -export async function cargarCotizacionesLocal() { - const db = await getDB() - return db.get('cotizaciones', 'historial') +export async function cargarCotizacionesLocal(): Promise { + try { + const db = await getDB() + return db.get('cotizaciones', 'historial') + } catch { + return undefined + } +} + +// ---------- Cotizaciones guardadas localmente (fallback sin servidor) ---------- + +const COTIZACIONES_LS = 'frasa-cotizador:cotizaciones-locales' +const MAX_LOCALES = 50 + +/** + * Guarda una cotización local (id con prefijo 'local-') en IndexedDB. + * Si IDB no está disponible (p. ej. navegación privada) cae a localStorage; + * si no hay almacenamiento alguno, no lanza: la sesión sigue. + */ +export async function guardarCotizacionLocal(cotizacion: unknown): Promise { + try { + const db = await getDB() + await db.put('cotizaciones', cotizacion, `local:${(cotizacion as { id: string }).id}`) + return + } catch { + // IndexedDB no disponible: intenta localStorage + } + try { + const prev = JSON.parse(localStorage.getItem(COTIZACIONES_LS) ?? '[]') as unknown[] + localStorage.setItem(COTIZACIONES_LS, JSON.stringify([cotizacion, ...prev].slice(0, MAX_LOCALES))) + } catch { + // Sin almacenamiento disponible + } +} + +/** Lee las cotizaciones guardadas localmente (IndexedDB o localStorage) */ +export async function cargarCotizacionesLocales(): Promise { + try { + const db = await getDB() + const claves = (await db.getAllKeys('cotizaciones')).filter((k) => String(k).startsWith('local:')) + return Promise.all(claves.map((k) => db.get('cotizaciones', k))) + } catch { + // IndexedDB no disponible: intenta localStorage + } + try { + return JSON.parse(localStorage.getItem(COTIZACIONES_LS) ?? '[]') as unknown[] + } catch { + return [] + } } diff --git a/apps/dashboards/package-lock.json b/apps/dashboards/package-lock.json index e9431a8..2204687 100644 --- a/apps/dashboards/package-lock.json +++ b/apps/dashboards/package-lock.json @@ -13,6 +13,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hot-toast": "^2.6.0", + "react-router-dom": "^7.18.3", "recharts": "^2.15.0" }, "devDependencies": { @@ -2128,6 +2129,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -3271,6 +3285,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -3424,6 +3476,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", diff --git a/apps/dashboards/package.json b/apps/dashboards/package.json index 773e47d..638fce2 100644 --- a/apps/dashboards/package.json +++ b/apps/dashboards/package.json @@ -15,6 +15,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hot-toast": "^2.6.0", + "react-router-dom": "^7.18.3", "recharts": "^2.15.0" }, "devDependencies": { diff --git a/apps/dashboards/src/components/KpiCard.tsx b/apps/dashboards/src/components/KpiCard.tsx index 4c98791..b0ae25c 100644 --- a/apps/dashboards/src/components/KpiCard.tsx +++ b/apps/dashboards/src/components/KpiCard.tsx @@ -1,11 +1,32 @@ import type { ReactNode } from 'react' -export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) { - return ( -
+const CLASE_BASE = + 'rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md' + +interface Props { + titulo: string + valor: ReactNode + detalle?: string + /** Presente = KPI clickeable (drill-down): se renderiza como botón */ + onClick?: () => void +} + +export default function KpiCard({ titulo, valor, detalle, onClick }: Props) { + const contenido = ( + <>

{titulo}

{valor}

{detalle &&

{detalle}

} -
+ + ) + if (!onClick) return
{contenido}
+ return ( + ) } diff --git a/apps/dashboards/src/components/MiTablero.tsx b/apps/dashboards/src/components/MiTablero.tsx index 9b78eff..4f30727 100644 --- a/apps/dashboards/src/components/MiTablero.tsx +++ b/apps/dashboards/src/components/MiTablero.tsx @@ -3,26 +3,28 @@ import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } fr import { tooltipProps } from '../lib/chart-theme' import KpiCard from './KpiCard' import { EstadoVacio, TarjetaGrafica, type AccionesTablero } from './comunes' -import { funnelMisLeads, type ResumenMiDia } from '../lib/mi-tablero' -import { fetchResumenMiDia } from '../services/mi-dia' +import { funnelMiTablero, type MiTablero } from '../lib/mi-tablero' +import { fetchMiTablero } from '../services/reportes' -// Misma paleta del funnel del tablero general (NovaSpark: azules + neutros) -const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB'] +// Misma paleta del funnel del tablero general (NovaSpark: azules + neutros); +// rojo para la etapa final "Descartados" +const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB', '#EF4444'] /** - * "Mi tablero": KPIs personales del vendedor logueado (mis leads, actividades, - * apartados por vencer) y un funnel simple de sus leads por estado. - * Fuente: GET /api/mi-dia/resumen (filtra por su mondayUserId en el API). + * "Mi tablero": KPIs personales del vendedor logueado y su funnel de conversión + * (Leads → Oportunidades → Apartados → Vendidos → Descartados). + * Fuente: GET /api/reportes/mi-tablero, el mismo endpoint que usa el panel + * "Mi día" (DashboardVendedor). */ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (acciones: AccionesTablero) => void }) { - const [resumen, setResumen] = useState(null) + const [tablero, setTablero] = useState(null) const [error, setError] = useState(null) const [cargando, setCargando] = useState(true) const cargar = useCallback(async () => { setCargando(true) try { - setResumen(await fetchResumenMiDia()) + setTablero(await fetchMiTablero()) setError(null) } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero') @@ -40,15 +42,20 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a alCambiarAcciones({ actualizar: () => void cargar(), cargando }) }, [alCambiarAcciones, cargar, cargando]) - const funnel = resumen ? funnelMisLeads(resumen.misLeads) : [] + const funnel = tablero ? funnelMiTablero(tablero) : [] + const conversion = tablero?.conversion + const tasaConversion = + conversion && conversion.leads > 0 + ? `${((conversion.oportunidades / conversion.leads) * 100).toFixed(1)}%` + : '—' return (

Mi tablero

- {resumen && ( + {tablero && (

- Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} + Actualizado: {new Date(tablero.generadoEn).toLocaleString('es-MX')}

)}
@@ -59,39 +66,43 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a

)} - {resumen?.sinMapeoMonday && ( -

- Tu usuario aún no está ligado a Monday: las listas personales aparecen vacías -

- )} + {!tablero && !error && cargando && } + {!tablero && !error && !cargando && } - {!resumen && !error && cargando && } - {!resumen && !error && !cargando && } - - {resumen && ( + {tablero && conversion && ( <>
+ +
-
- - {funnel.length === 0 ? ( - +
+ + {funnel.every((e) => e.valor === 0) ? ( + ) : (
diff --git a/apps/dashboards/src/components/TableroGeneral.tsx b/apps/dashboards/src/components/TableroGeneral.tsx index 9fd6591..9e5bec4 100644 --- a/apps/dashboards/src/components/TableroGeneral.tsx +++ b/apps/dashboards/src/components/TableroGeneral.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' import { Bar, BarChart, @@ -43,10 +44,28 @@ const COLORES_STATUS: Record = { const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB'] const COLORES_COBRANZA = ['#2563EB', '#F59E0B'] +/** Rangos de fecha disponibles para filtrar las gráficas (días hacia atrás) */ +const RANGOS_DIAS = [7, 30, 90] as const + +/** Rutas de drill-down a otras apps del ecosistema (NGINX las sirve como SPAs aparte) */ +const RUTA_PLANES_VENCIDOS = '/pay/?vista=todos&status=ACTIVO&sort=atraso&dir=desc' +const RUTA_MI_DIA = '/hoy/' + +/** + * Drill-down cruzado de apps: /pay (cobranza) y /hoy (mi-dia) son SPAs + * distintas detrás de NGINX; navigate() de react-router no puede salir del + * basename de esta app, así que el salto es a nivel document. + */ +function irAApp(ruta: string): void { + window.location.assign(ruta) +} + /** * Vista "General": el tablero comercial de todo el ecosistema (KPIs, inventario, * pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/ * admin) agrega la sección Equipo con leads por vendedor. + * El rango de fechas (?dias=7|30|90) vive en la URL y filtra leads, + * oportunidades y perfilamiento; inventario y cobranza son posición actual. */ export default function TableroGeneral({ mostrarEquipo, @@ -58,23 +77,39 @@ export default function TableroGeneral({ const [resumen, setResumen] = useState(null) const [error, setError] = useState(null) const [cargando, setCargando] = useState(true) + // Rango de fechas persistido en la URL: ?dias=7|30|90 (default 30) + const [searchParams, setSearchParams] = useSearchParams() + const paramDias = Number(searchParams.get('dias')) + const dias = (RANGOS_DIAS as readonly number[]).includes(paramDias) ? paramDias : 30 const cargar = useCallback(async () => { setCargando(true) try { - setResumen(await fetchResumen()) + setResumen(await fetchResumen(dias)) setError(null) } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero') } finally { setCargando(false) } - }, []) + }, [dias]) useEffect(() => { void cargar() }, [cargar]) + /** Cambia el rango y lo persiste en la URL (compartible) */ + const cambiarRango = (nuevo: number) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + next.set('dias', String(nuevo)) + return next + }, + { replace: true }, + ) + } + // Expone "Actualizar" al header sticky de DashboardPage useEffect(() => { alCambiarAcciones({ actualizar: () => void cargar(), cargando }) @@ -89,13 +124,33 @@ export default function TableroGeneral({ return (
-
-

Tablero comercial

- {resumen && ( -

- Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} -

- )} +
+
+

Tablero comercial

+ {resumen && ( +

+ Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} +

+ )} +
+
+ Rango: + {RANGOS_DIAS.map((r) => ( + + ))} +
{error && ( @@ -114,6 +169,7 @@ export default function TableroGeneral({ titulo="Leads totales" valor={resumen.leads.total} detalle={`${resumen.leads.nuevosHoy} hoy · ${resumen.leads.nuevosSemana} esta semana`} + onClick={() => irAApp(RUTA_MI_DIA)} /> irAApp(RUTA_PLANES_VENCIDOS)} /> ({ etapa, valor })) .sort((a, b) => b.valor - a.valor || a.etapa.localeCompare(b.etapa)) } + +// ---------- Tablero personal (GET /api/reportes/mi-tablero) ---------- +// Mismo endpoint que consume DashboardVendedor en la app de Mi día. + +export interface MiTablero { + generadoEn: string + leadsPorDia: { fecha: string; cantidad: number }[] + conversion: { + leads: number + oportunidades: number + apartados: number + vendidos: number + /** Leads con etapa terminal DESCARTADO; etapa extra al final del funnel */ + descartados: number + } + ventasMes: { monto: number; unidades: number } + porOrigen: { origen: string; total: number; convertidos: number }[] + porDesarrollo: { desarrollo: string; ventas: number }[] + actividadesHoy: number + actividadesPendientes: number +} + +/** + * Etapas del funnel personal desde el endpoint unificado: Leads → + * Oportunidades → Apartados → Vendidos, con "Descartados" al final (fuera del + * embudo: leads terminados sin conversión) para que no se omitan. + */ +export function funnelMiTablero(tablero: MiTablero): EtapaFunnelPersonal[] { + const c = tablero.conversion + return [ + { etapa: 'Leads', valor: c.leads }, + { etapa: 'Oportunidades', valor: c.oportunidades }, + { etapa: 'Apartados', valor: c.apartados }, + { etapa: 'Vendidos', valor: c.vendidos }, + { etapa: 'Descartados', valor: c.descartados }, + ] +} diff --git a/apps/dashboards/src/main.tsx b/apps/dashboards/src/main.tsx index b8868d6..f0a8e30 100644 --- a/apps/dashboards/src/main.tsx +++ b/apps/dashboards/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import { Toaster } from 'react-hot-toast' import '@fontsource/inter/300.css' import '@fontsource/inter/400.css' @@ -11,9 +12,13 @@ import DashboardPage from './pages/DashboardPage' createRoot(document.getElementById('root')!).render( - - - + {/* La app vive bajo el prefijo /dash/ de NGINX (vite base); el rango de + fechas del tablero general se persiste en la URL (?dias=7|30|90) */} + + + + + , ) diff --git a/apps/dashboards/src/services/reportes.ts b/apps/dashboards/src/services/reportes.ts index b2881ad..910df73 100644 --- a/apps/dashboards/src/services/reportes.ts +++ b/apps/dashboards/src/services/reportes.ts @@ -1,18 +1,32 @@ // Cliente del endpoint de reportes del API Gateway (misma red, same-origin tras NGINX) import { conAuth, manejar401 } from './auth' import type { ResumenReportes } from '../lib/reportes' +import type { MiTablero } from '../lib/mi-tablero' const API_URL = import.meta.env.VITE_API_URL || '/api' -/** GET /reportes/resumen; lanza Error con mensaje legible si el API falla */ -export async function fetchResumen(): Promise { +async function obtener(path: string): Promise { let res: Response try { - res = await fetch(`${API_URL}/reportes/resumen`, conAuth()) + res = await fetch(`${API_URL}${path}`, conAuth()) } catch { throw new Error('No se pudo conectar con el servidor') } manejar401(res) if (!res.ok) throw new Error(`El API respondió ${res.status}`) - return (await res.json()) as ResumenReportes + return (await res.json()) as T +} + +/** + * GET /reportes/resumen; `dias` (7/30/90) acota leads y oportunidades al rango. + * Lanza Error con mensaje legible si el API falla. + */ +export async function fetchResumen(dias?: number): Promise { + const query = dias !== undefined && dias > 0 ? `?dias=${Math.floor(dias)}` : '' + return obtener(`/reportes/resumen${query}`) +} + +/** GET /reportes/mi-tablero — tablero personal del vendedor (mismo que Mi día) */ +export async function fetchMiTablero(): Promise { + return obtener('/reportes/mi-tablero') } diff --git a/apps/landing/src/components/ModalContacto.tsx b/apps/landing/src/components/ModalContacto.tsx deleted file mode 100644 index 53dfe99..0000000 --- a/apps/landing/src/components/ModalContacto.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { useEffect, useState } from 'react' -import { getContactoDetalle, type DetalleContacto, type RelacionCrm } from '../services/api' - -interface Props { - contactoId: string - nombre: string - onCerrar: () => void -} - -const formatoMoneda = (n: number): string => - n > 0 ? n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }) : '—' - -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' }) - : '' - -function Dato({ etiqueta, valor }: { etiqueta: string; valor: string }) { - if (valor === '') return null - return ( -
-

{etiqueta}

-

{valor}

-
- ) -} - -function ListaRelaciones({ titulo, items }: { titulo: string; items: RelacionCrm[] }) { - if (items.length === 0) return null - return ( -
-

{titulo}

-
    - {items.map((r) => ( -
  • - - {r.estado || 'Sin estado'} - {r.detalle ? {r.detalle} : null} - - - {r.atendidoPor ?? 'Sin asignar'} - -
  • - ))} -
-
- ) -} - -/** - * Modal de contacto: toda su información (columnas del board), la propiedad - * comprada/en proceso, quién atiende cada item relacionado y el historial - * completo de atención (Monday + capturas de las apps). - */ -export function ModalContacto({ contactoId, nombre, onCerrar }: Props) { - const [detalle, setDetalle] = useState(null) - const [error, setError] = useState('') - - useEffect(() => { - let vivo = true - getContactoDetalle(contactoId) - .then((d) => { - if (vivo) setDetalle(d) - }) - .catch(() => { - if (vivo) setError('No se pudo cargar el detalle del contacto.') - }) - return () => { - vivo = false - } - }, [contactoId]) - - // 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 ?? {} - - return ( -
{ - if (e.target === e.currentTarget) onCerrar() - }} - > -
-
-
-

{c['nombre'] ?? nombre}

- {c['Estado'] && ( - {c['Estado']} - )} -
- -
- -
- {error && ( -

- {error} -

- )} - {!detalle && !error &&

Cargando detalle…

} - - {detalle && ( - <> -
-

Datos del contacto

-
- - - - - - - - - - - -
-
- - {detalle.propiedad && ( -
-

Propiedad

-
-
- - - - - - -
-
-
- )} - -
- - - -
- -
-

- Historial de atención -

- {detalle.historial.length === 0 ? ( -

Sin movimientos registrados.

- ) : ( -
    - {detalle.historial.map((h, i) => ( -
  • -
    -

    - {h.detalle} - - {h.fuente} - -

    -

    {h.usuario ?? 'Automatización'}

    -
    - {formatoFecha(h.fechaISO)} -
  • - ))} -
- )} -
- - )} -
-
-
- ) -} diff --git a/apps/landing/src/components/PerfilContactoCompleto.tsx b/apps/landing/src/components/PerfilContactoCompleto.tsx index 732b2cf..c0e5623 100644 --- a/apps/landing/src/components/PerfilContactoCompleto.tsx +++ b/apps/landing/src/components/PerfilContactoCompleto.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react' -import { getContactoDetalle, type DetalleContacto } from '../services/api' +import { getContactoDetalle, type DatosContacto, type DetalleContacto } from '../services/api' const formatoFecha = (iso: string): string => iso @@ -47,7 +47,7 @@ export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }: return () => window.removeEventListener('keydown', alTeclear) }, [onCerrar]) - const c = detalle?.contacto ?? {} + const c: Partial = detalle?.contacto ?? {} const prop = detalle?.propiedad ?? null const hist = detalle?.historial ?? [] const rels = detalle?.relaciones ?? { leads: [], oportunidades: [], apartados: [] } @@ -64,7 +64,7 @@ export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }:

Perfil del contacto

-

{c['nombre'] ?? nombre}

+

{c.nombre ?? nombre}

-
-

- {modoLocal ? 'Modo local (sin Monday)' : 'Vinculado a Monday'} -

-

- {modoLocal - ? 'Las capturas se guardan localmente y se sincronizarán cuando Monday esté disponible.' - : 'Los leads, actividades y contactos se sincronizan con Monday en tiempo real.'} -

-
-
- - {/* Botón reconectar */} -
- -
- - {/* Feedback */} - {aviso && ( -

- {aviso} -

- )} - {error && ( -

- {error} -

- )} -
- ) -} diff --git a/apps/landing/src/data/modulos.test.ts b/apps/landing/src/data/modulos.test.ts index a226a5a..2f92b3a 100644 --- a/apps/landing/src/data/modulos.test.ts +++ b/apps/landing/src/data/modulos.test.ts @@ -8,8 +8,8 @@ const porId = (id: string): Modulo => { } describe('MODULOS', () => { - it('expone los 8 módulos del ecosistema', () => { - expect(MODULOS).toHaveLength(8) + it('expone los 9 módulos del ecosistema', () => { + expect(MODULOS).toHaveLength(9) expect(MODULOS.map((m) => m.id)).toEqual([ 'cotizador', 'alta-clientes', @@ -19,6 +19,7 @@ describe('MODULOS', () => { 'mi-dia', 'contactos', 'captura', + 'clientes-maestro', ]) }) diff --git a/apps/landing/src/pages/ClientesMaestroPage.tsx b/apps/landing/src/pages/ClientesMaestroPage.tsx index a492444..85fb64b 100644 --- a/apps/landing/src/pages/ClientesMaestroPage.tsx +++ b/apps/landing/src/pages/ClientesMaestroPage.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react' import { listarClientesMaestro, exportarClientesMaestro, type ClienteMaestro } from '../services/clientes-maestro' import { ClientesMaestroTable } from '../components/ClientesMaestroTable' import ModalClienteMaestro from '../components/ModalClienteMaestro' -import { getSesion } from '../services/auth' import toast from 'react-hot-toast' interface Props { @@ -16,7 +15,6 @@ const ESTATUSES = ['', 'VENDIDO', 'APARTADO', 'ESCRITURADO', 'PENDIENTE', 'CANCE /** * Página / clientes-maestro: tabla paginada con filtros de búsqueda, * desarrollo y estatus. Botón exportar genera xlsx desde los datos filtrados. - * Botón importar (solo admin) sube Excel al backend. */ export function ClientesMaestroPage({ onVolver }: Props) { const [datos, setDatos] = useState([]) @@ -29,9 +27,6 @@ export function ClientesMaestroPage({ onVolver }: Props) { const [filtroEstatus, setFiltroEstatus] = useState('') const [busquedaDebounced, setBusquedaDebounced] = useState('') - const sesion = getSesion() - const esAdmin = sesion?.usuario.rol === 'admin' - // Debounce búsqueda useEffect(() => { const timer = setTimeout(() => { @@ -102,24 +97,6 @@ export function ClientesMaestroPage({ onVolver }: Props) { } } - const handleImportar = () => { - const input = document.createElement('input') - input.type = 'file' - input.accept = '.xlsx,.xls,.csv' - input.onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0] - if (!file) return - toast.loading('Importando...', { id: 'importar' }) - try { - // For CSV import we'd need a reader; for now show a message - toast.error('La importación se realiza desde el script del backend', { id: 'importar' }) - } catch { - toast.error('Error al importar', { id: 'importar' }) - } - } - input.click() - } - return ( <>
@@ -141,15 +118,6 @@ export function ClientesMaestroPage({ onVolver }: Props) {

)}
- {esAdmin && ( - - )}
{/* Filtros */} diff --git a/apps/landing/src/services/api.ts b/apps/landing/src/services/api.ts index 08698d4..b68123c 100644 --- a/apps/landing/src/services/api.ts +++ b/apps/landing/src/services/api.ts @@ -72,9 +72,24 @@ export interface EventoHistorialContacto { fuente: string // 'Contactos' | 'Leads' | 'Oportunidades' | 'Apartado' | 'App' } +export interface DatosContacto { + nombre: string + telefono: string + email: string + estado: string + curp?: string + rfc?: string + estadoCivil?: string + fechaNacimiento?: string + actividadEconomica?: string + direccion?: string + /** Cualquier otro campo que el backend incluya */ + [campo: string]: string | undefined +} + export interface DetalleContacto { - /** Columnas del board por título ('nombre' incluido); '' si vacía */ - contacto: Record + /** Campos nombrados del modelo local (antes: títulos de columna de Monday) */ + contacto: DatosContacto propiedad: { id: string unidadCodigo: string diff --git a/apps/mi-dia/src/components/DashboardVendedor.test.tsx b/apps/mi-dia/src/components/DashboardVendedor.test.tsx index 0e8dc0d..c59b5de 100644 --- a/apps/mi-dia/src/components/DashboardVendedor.test.tsx +++ b/apps/mi-dia/src/components/DashboardVendedor.test.tsx @@ -53,9 +53,9 @@ describe('DashboardVendedor', () => { await waitFor(() => { expect(screen.getByText('Ventas del mes')).toBeTruthy() }) - expect(screen.getByText('Actividades de hoy')).toBeTruthy() + expect(screen.getByText('Leads nuevos hoy')).toBeTruthy() expect(screen.getByText('Leads totales')).toBeTruthy() - expect(screen.getByText('Funnel conversión')).toBeTruthy() + expect(screen.getByText('Funnel de conversión')).toBeTruthy() expect(screen.getByText('Leads por día (últimos 30 días)')).toBeTruthy() expect(screen.getByText('Funnel de conversión')).toBeTruthy() expect(screen.getByText('Rendimiento por origen')).toBeTruthy() diff --git a/apps/mi-dia/src/components/DashboardVendedor.tsx b/apps/mi-dia/src/components/DashboardVendedor.tsx index 901b7ea..8b193e0 100644 --- a/apps/mi-dia/src/components/DashboardVendedor.tsx +++ b/apps/mi-dia/src/components/DashboardVendedor.tsx @@ -76,16 +76,14 @@ export default function DashboardVendedor() { const mesAnterior = mesActual === 0 ? 11 : mesActual - 1 const anioAnterior = mesActual === 0 ? anioActual - 1 : anioActual + // leadsPorDia es el único histórico mensual del tablero (últimos 30 días); + // se agrupa por mes parseando 'YYYY-MM-DD' sin Date (evita corrimientos UTC) 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 [anio, mes] = d.fecha.split('-').map(Number) + if (anio === anioActual && mes === mesActual + 1) leadsMesActual += d.cantidad + if (anio === anioAnterior && mes === mesAnterior + 1) leadsMesAnterior += d.cantidad } const nombresMes = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'] @@ -100,14 +98,16 @@ export default function DashboardVendedor() { { metrica: 'Oportunidades', mesActual: tablero.conversion.oportunidades, - mesAnterior: Math.round(tablero.conversion.oportunidades * 0.8), // estimado + // Sin histórico mensual: no se estima el mes anterior + mesAnterior: null, nombreMesActual: nombresMes[mesActual], nombreMesAnterior: nombresMes[mesAnterior], }, { metrica: 'Ventas', mesActual: tablero.ventasMes.unidades, - mesAnterior: Math.round(tablero.ventasMes.unidades * 0.85), // estimado + // Sin histórico mensual: no se estima el mes anterior + mesAnterior: null, nombreMesActual: nombresMes[mesActual], nombreMesAnterior: nombresMes[mesAnterior], }, @@ -211,6 +211,9 @@ export default function DashboardVendedor() { {/* F1: Comparativa mensual */}
Comparativa mensual (mes actual vs anterior) +

+ El mes anterior solo se compara en leads (único histórico disponible); oportunidades y ventas muestran el mes en curso. +

diff --git a/apps/mi-dia/src/components/DetalleLeadModal.tsx b/apps/mi-dia/src/components/DetalleLeadModal.tsx index 108104e..d3205e6 100644 --- a/apps/mi-dia/src/components/DetalleLeadModal.tsx +++ b/apps/mi-dia/src/components/DetalleLeadModal.tsx @@ -27,14 +27,23 @@ const CLASE_ACCION_RAPIDA = 'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10' const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [ - { id: 'info', titulo: 'Información del cliente potencial', icono: User }, - { id: 'cita', titulo: 'Reserve o actualice una cita', icono: Calendar }, + { id: 'info', titulo: 'Información', icono: User }, + { id: 'cita', titulo: 'Cita', icono: Calendar }, { id: 'tareas', titulo: 'Tareas', icono: CheckSquare }, { id: 'notas', titulo: 'Notas', icono: FileText }, - { id: 'miembros', titulo: 'Objetos de los miembros', icono: Users }, + { id: 'miembros', titulo: 'Miembros', icono: Users }, ] -export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; onCerrar: () => void }) { +export default function DetalleLeadModal({ + item, + onCerrar, + onGuardarCita, +}: { + item: LeadItem + onCerrar: () => void + /** Guarda la cita de una oportunidad (fechaHora 'YYYY-MM-DDTHH:mm'); la página maneja el error */ + onGuardarCita?: (fechaHora: string) => Promise | void +}) { const esOp = esOportunidad(item) const esLd = esLead(item) const vendedor = getSesion()?.usuario.nombre ?? '' @@ -49,6 +58,23 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o const [busquedaNota, setBusquedaNota] = useState('') // --- Tareas --- const [completandoTarea, setCompletandoTarea] = useState(null) + // --- Cita --- + const [fechaCita, setFechaCita] = useState('') + const [guardandoCita, setGuardandoCita] = useState(false) + + /** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */ + async function guardarCita(e: FormEvent) { + e.preventDefault() + if (!onGuardarCita || fechaCita === '') return + setGuardandoCita(true) + try { + await onGuardarCita(fechaCita) + } catch { + // el error ya se muestra en la página (toast/aviso del caller) + } finally { + setGuardandoCita(false) + } + } const cargarNotas = useCallback(async () => { setCargandoNotas(true) @@ -102,8 +128,8 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o {/* Header */}
-

Editar "{item.nombre}"

-

Añadir y editar oportunidad Detalles, Actividades, notas y Cita.

+

Bitácora de {item.nombre}

+

Seguimiento del cliente: información, citas, tareas y notas

@@ -168,14 +194,37 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o {tab === 'cita' && (
-

Reserve o actualice una cita

+

Cita

{esOp && item.fechaCita ? (

Cita programada

{item.fechaCita.replace('T', ' a las ')}

+ ) : esOp ? ( +
+
+ + setFechaCita(e.target.value)} + disabled={guardandoCita} + className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 disabled:opacity-50" + /> +
+ +
) : ( -

No hay cita programada. Use los botones de fase en la lista para agendar una.

+

No hay cita programada. Las citas se agendan desde Mis oportunidades.

)}
)} @@ -267,7 +316,7 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o {tab === 'miembros' && (
-

Objetos de los miembros

+

Miembros

Información de los miembros del equipo asignados a este lead.

Vendedor: {(item as any).vendedor ?? '—'}

diff --git a/apps/mi-dia/src/components/KanbanView.tsx b/apps/mi-dia/src/components/KanbanView.tsx index a0c8c0a..777e610 100644 --- a/apps/mi-dia/src/components/KanbanView.tsx +++ b/apps/mi-dia/src/components/KanbanView.tsx @@ -1,5 +1,4 @@ import { useState, useCallback } from 'react' -import ScoreBadge from './ScoreBadge' /** Columna genérica del kanban */ interface ColumnaDef { @@ -77,7 +76,6 @@ export default function KanbanView

{item.nombre}

- {'score' in item && ).score) || 0} />}
{item.telefono &&

{item.telefono}

} {item.unidadCodigo &&

{item.unidadCodigo}

} diff --git a/apps/mi-dia/src/components/ScoreBadge.tsx b/apps/mi-dia/src/components/ScoreBadge.tsx deleted file mode 100644 index fa84c02..0000000 --- a/apps/mi-dia/src/components/ScoreBadge.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** Badge circular con score 0-100 para lead scoring. */ - -interface ScoreBadgeProps { - score: number - size?: 'sm' | 'md' -} - -function colorForScore(score: number): string { - if (score > 70) return 'bg-green-500 text-white' - if (score > 40) return 'bg-yellow-400 text-yellow-900' - return 'bg-red-500 text-white' -} - -/** - * Calcula el score de un lead de forma local usando: - * etapa (20-90) + recencia + teléfono + email + origen. - * - * Para oportunidades usa la `etapa`; para leads usa `estado`. - */ -export function calcularScore(lead: { - estado?: string - etapa?: string - telefono?: string - email?: string - origen?: string - updatedAt?: string -}): number { - const etapa = lead.etapa ?? lead.estado ?? '' - const normalizada = etapa.toUpperCase() - - // Base por etapa: 20-90 - let base = 20 - if (normalizada.includes('AGENDO') || normalizada.includes('CITA')) base = 60 - else if (normalizada.includes('INTERES')) base = 50 - else if (normalizada.includes('CONTACTAD')) base = 40 - else if (normalizada.includes('COTIZ')) base = 70 - else if (normalizada.includes('APARTO') || normalizada.includes('APARTAD')) base = 90 - else if (normalizada.includes('DESCART') || normalizada.includes('NO ASIST')) base = 5 - - // Recencia - let recencia = 0 - if (lead.updatedAt) { - const diffMs = Date.now() - new Date(lead.updatedAt).getTime() - const diffHoras = diffMs / (1000 * 60 * 60) - if (diffHoras < 24) recencia = 10 - else if (diffHoras < 168) recencia = 5 - else if (diffHoras > 720) recencia = -20 // >30 días - } - - // Señales extra - const tel = lead.telefono && lead.telefono.trim() !== '' ? 5 : 0 - const em = lead.email && lead.email.trim() !== '' ? 5 : 0 - const ori = lead.origen && lead.origen.trim() !== '' ? 5 : 0 - - return Math.max(0, Math.min(100, base + recencia + tel + em + ori)) -} - -export default function ScoreBadge({ score, size = 'sm' }: ScoreBadgeProps) { - const dim = size === 'sm' ? 'h-6 w-6 text-[10px]' : 'h-8 w-8 text-xs' - return ( - - {score} - - ) -} diff --git a/apps/mi-dia/src/lib/mi-dia.ts b/apps/mi-dia/src/lib/mi-dia.ts index 53f6c1a..656cc53 100644 --- a/apps/mi-dia/src/lib/mi-dia.ts +++ b/apps/mi-dia/src/lib/mi-dia.ts @@ -112,50 +112,57 @@ export interface ResumenMiDia { } } -// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' (Oportunidades), -// vocabulario FRASA verificado y sembrado en Monday el 2026-09-04 -export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'] as const +/** + * VOCABULARIO canónico FRASA: única fuente de verdad de fases, estados, + * etapas, motivos, subcategorías, orígenes y desarrollos. Las constantes + * individuales (FASES_OPORTUNIDAD, MOTIVOS_NO_COMPRA…) se derivan de aquí. + * Espejo del backend en api-gateway/src/config/vocabulario.ts (mantener + * sincronizados). + */ +export const VOCABULARIO = { + // Estados de la columna status del board Leads (ESTADOS_LEAD_PERMITIDOS del + // API + NUEVO/DESCARTADO) + FASES_LEAD: ['NUEVO', 'CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'], + // Etapas del funnel de Oportunidades (labels REALES del board; el API las valida tal cual) + FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'], + // Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board) + FASES_APARTADO: ['Pago Apartado', 'Alta Enkontrol', 'Entrego Documentacion', 'Cita Clientes', 'Contrato Enkontrol', 'Firma Cliente', 'Cancelacion Apartado'], + // Fases de escrituración (pipeline posterior a 'Firma Cliente') + FASES_ESCRITURACION: ['Venta', 'Seguimiento', 'Integracion', 'X Autorizar', 'Autorizado', 'X Escriturar', 'Escriturado', 'Titulado', 'Cancelo'], + // Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA) + MOTIVOS_NO_COMPRA: ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'], + // Subcategorías por categoría (catálogo del tablero Motivos No Compra, + // sembrado en Frasa-CAS el 2026-09-04; 'Mesualidad' y 'Cambo de Planes' + // conservan las faltas de ortografía originales del tablero del cliente) + SUBCATEGORIAS: { + Precio: ['Precio Total', 'Enganche', 'Mesualidad'], + Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'], + Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'], + Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'], + Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'], + Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'], + Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'], + }, + // Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' + // (Oportunidades), verificados y sembrados en Monday el 2026-09-04 + ORIGENES: ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'], + // Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday) + DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'], +} as const -// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday) -export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const +// Constantes derivadas (nombres estables que consume el resto de la app) +export const ORIGENES_LEAD = VOCABULARIO.ORIGENES -// Estados de la columna status del board Leads (vocabulario real del API: -// ESTADOS_LEAD_PERMITIDOS + NUEVO/DESCARTADO); alimenta el filtro por estado -export const ESTADOS_LEAD = [ - 'NUEVO', - 'CONTACTADO', - 'Intento de contacto', - 'INTERESADO', - 'AGENDA CITA', - 'CALIFICADO', - 'DESCARTADO', -] as const +export const DESARROLLOS_LEAD = VOCABULARIO.DESARROLLOS -// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA, igual que el API) -export const MOTIVOS_NO_COMPRA = [ - 'Precio', - 'Producto', - 'Ubicacion', - 'Financiamiento', - 'Competencia', - 'Cliente', - 'Timing', -] as const +// Alimenta el filtro por estado de Mis leads +export const ESTADOS_LEAD = VOCABULARIO.FASES_LEAD + +export const MOTIVOS_NO_COMPRA = VOCABULARIO.MOTIVOS_NO_COMPRA export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number] -// Subcategorías por categoría del tablero Motivos No Compra (catálogo del -// workspace de referencia CRM FRASA, sembrado en Frasa-CAS el 2026-09-04). -// Espejo de SUBCATEGORIAS_NO_COMPRA del API (mantener sincronizados). -export const SUBCATEGORIAS_NO_COMPRA: Record = { - Precio: ['Precio Total', 'Enganche', 'Mesualidad'], - Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'], - Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'], - Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'], - Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'], - Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'], - Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'], -} +export const SUBCATEGORIAS_NO_COMPRA: Record = VOCABULARIO.SUBCATEGORIAS // LADAs soportadas en la captura de teléfonos (México default, USA opcional) export const LADAS = [ @@ -193,20 +200,11 @@ export function urlWhatsApp(nombre: string, telefono: string, vendedor: string): return `https://wa.me/${telefonoParaEnlace(telefono)}?text=${texto}` } -// Fases del funnel de Oportunidades (labels REALES del board; el API las valida tal cual) -export const FASES_OPORTUNIDAD = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'] as const +// Fases del funnel de Oportunidades y del pipeline Enkontrol (del VOCABULARIO) +export const FASES_OPORTUNIDAD = VOCABULARIO.FASES_OPORTUNIDAD export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number] -// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board) -export const FASES_APARTADO = [ - 'Pago Apartado', - 'Alta Enkontrol', - 'Entrego Documentacion', - 'Cita Clientes', - 'Contrato Enkontrol', - 'Firma Cliente', - 'Cancelacion Apartado', -] as const +export const FASES_APARTADO = VOCABULARIO.FASES_APARTADO export type FaseApartado = (typeof FASES_APARTADO)[number] // Texto bonito de los botones (el valor que se manda al API es el label real) @@ -220,17 +218,7 @@ export const ETIQUETA_FASE_APARTADO: Record = { 'Cancelacion Apartado': 'Cancelación', } -export const FASES_ESCRITURACION = [ - 'Venta', - 'Seguimiento', - 'Integracion', - 'X Autorizar', - 'Autorizado', - 'X Escriturar', - 'Escriturado', - 'Titulado', - 'Cancelo', -] as const +export const FASES_ESCRITURACION = VOCABULARIO.FASES_ESCRITURACION export type FaseEscrituracion = (typeof FASES_ESCRITURACION)[number] export const ETIQUETA_FASE_ESCRITURACION: Record = { diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 00e8f9c..54f4dfd 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -51,7 +51,6 @@ import { exportarLeads, exportarOportunidades, exportarApartados } from '../serv import { FiltrosAvanzados, aplicarFiltros, FILTROS_VACIOS, 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 CalendarioCitas from '../components/CalendarioCitas' import DetalleLeadModal from '../components/DetalleLeadModal' @@ -618,7 +617,6 @@ function FilaLead({ > {lead.nombre} - {/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */} setLeadSeleccionado(null)} + onGuardarCita={ + 'modeloInteres' in leadSeleccionado + ? (fechaHora) => { + const op = leadSeleccionado + return ejecutar( + () => cambiarEtapaOportunidad(op.id, 'Agendo Cita', fechaHora), + 'No se pudo guardar la cita', + ) + } + : undefined + } /> )}