diff --git a/api-gateway/src/config/roles.test.ts b/api-gateway/src/config/roles.test.ts index 028a100..67600c0 100644 --- a/api-gateway/src/config/roles.test.ts +++ b/api-gateway/src/config/roles.test.ts @@ -1,6 +1,20 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { MODULOS, esRolValido, modulosDeRol } from './roles.js' +// ---------- Prisma mockeado ---------- +// modulosDeRol lee RolConfiguracion (BD) y cae a la MATRIZ estática cuando no +// hay fila para el rol. Los permisos reales se editan desde la UI, así que la +// BD viva cambia con el tiempo: estos tests fijan el contrato por defecto +// (sin override en BD), que es lo que recibe una instalación nueva. +const prismaMock = vi.hoisted(() => ({ + rolConfiguracion: { findUnique: vi.fn() }, +})) +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) + +beforeEach(() => { + prismaMock.rolConfiguracion.findUnique.mockResolvedValue(null) +}) + const ESPERADOS: Record = { vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'], administracion_venta: [ diff --git a/api-gateway/src/controllers/buscar.controller.test.ts b/api-gateway/src/controllers/buscar.controller.test.ts index ef081db..9e37504 100644 --- a/api-gateway/src/controllers/buscar.controller.test.ts +++ b/api-gateway/src/controllers/buscar.controller.test.ts @@ -26,7 +26,7 @@ describe('buscarEndpoint (GET /api/buscar)', () => { buscarMock.mockResolvedValue([]) const r = res() await buscarEndpoint(req({ q: 'María' }), r) - expect(buscarMock).toHaveBeenCalledWith('María', 20) + expect(buscarMock).toHaveBeenCalledWith('María', 20, undefined) expect(r.json).toHaveBeenCalledWith([]) }) @@ -34,25 +34,47 @@ describe('buscarEndpoint (GET /api/buscar)', () => { buscarMock.mockResolvedValue([]) const r = res() await buscarEndpoint(req({}), r) - expect(buscarMock).toHaveBeenCalledWith('', 20) + expect(buscarMock).toHaveBeenCalledWith('', 20, undefined) }) it('limite válido del query string', async () => { buscarMock.mockResolvedValue([]) const r = res() await buscarEndpoint(req({ q: 'x', limite: '10' }), r) - expect(buscarMock).toHaveBeenCalledWith('x', 10) + expect(buscarMock).toHaveBeenCalledWith('x', 10, undefined) }) it('limite inválido o > 50 se clampa a 20', async () => { buscarMock.mockResolvedValue([]) const r = res() await buscarEndpoint(req({ q: 'x', limite: 'abc' }), r) - expect(buscarMock).toHaveBeenCalledWith('x', 20) + expect(buscarMock).toHaveBeenCalledWith('x', 20, undefined) const r2 = res() await buscarEndpoint(req({ q: 'x', limite: '100' }), r2) - expect(buscarMock).toHaveBeenCalledWith('x', 50) + expect(buscarMock).toHaveBeenCalledWith('x', 50, undefined) + }) + + it('vendedor: pasa alcance soloPropio (sólo ve sus propios registros)', async () => { + buscarMock.mockResolvedValue([]) + const r = res() + const reqVendedor = { + query: { q: 'Ana' }, + usuarioToken: { sub: 'u-9', username: 'v', rol: 'vendedor' }, + } as unknown as Request + await buscarEndpoint(reqVendedor, r) + expect(buscarMock).toHaveBeenCalledWith('Ana', 20, { usuarioId: 'u-9', soloPropio: true }) + }) + + it('admin: pasa alcance sin filtro (sigue viendo todo el universo)', async () => { + buscarMock.mockResolvedValue([]) + const r = res() + const reqAdmin = { + query: { q: 'Ana' }, + usuarioToken: { sub: 'u-1', username: 'a', rol: 'admin' }, + } as unknown as Request + await buscarEndpoint(reqAdmin, r) + expect(buscarMock).toHaveBeenCalledWith('Ana', 20, { usuarioId: 'u-1', soloPropio: false }) }) it('devuelve los resultados del service como JSON', async () => { diff --git a/api-gateway/src/controllers/buscar.controller.ts b/api-gateway/src/controllers/buscar.controller.ts index 39245ec..dfac0d1 100644 --- a/api-gateway/src/controllers/buscar.controller.ts +++ b/api-gateway/src/controllers/buscar.controller.ts @@ -8,6 +8,13 @@ import { buscar } from '../services/buscar.service.js' export async function buscarEndpoint(req: Request, res: Response): Promise { const q = typeof req.query.q === 'string' ? req.query.q : '' const limite = typeof req.query.limite === 'string' ? Math.min(parseInt(req.query.limite, 10) || 20, 50) : 20 - const resultados = await buscar(q, limite) + // Privacidad: el vendedor sólo encuentra sus propios leads, contactos y + // cotizaciones; los roles de supervisión siguen viendo todo el universo. + const soloPropio = req.usuarioToken?.rol === 'vendedor' + const resultados = await buscar( + q, + limite, + req.usuarioToken ? { usuarioId: req.usuarioToken.sub, soloPropio } : undefined, + ) res.json(resultados) } diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index c81f41b..cb75a0b 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -363,5 +363,13 @@ export async function moverLeadEndpoint(req: Request, res: Response): Promise { const q = String(req.query['q'] ?? '').trim() if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres') - res.json(await crmLocal.buscarLocales(q)) + // Privacidad: el vendedor sólo encuentra sus propios leads/contactos; los + // roles de supervisión siguen viendo todo el universo. + const soloPropio = req.usuarioToken?.rol === 'vendedor' + res.json( + await crmLocal.buscarLocales( + q, + req.usuarioToken ? { usuarioId: req.usuarioToken.sub, soloPropio } : undefined, + ), + ) } diff --git a/api-gateway/src/controllers/recordatorio-vendedor.controller.test.ts b/api-gateway/src/controllers/recordatorio-vendedor.controller.test.ts index 25e3f38..44a6b94 100644 --- a/api-gateway/src/controllers/recordatorio-vendedor.controller.test.ts +++ b/api-gateway/src/controllers/recordatorio-vendedor.controller.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const svcMock = vi.hoisted(() => ({ crear: vi.fn(), listar: vi.fn(), + listarPorCliente: vi.fn(), completar: vi.fn(), eliminar: vi.fn(), proximos: vi.fn(), @@ -72,6 +73,33 @@ describe('recordatorio-vendedor.controller', () => { expect(res.json).toHaveBeenCalledWith([]) }) + it('bitácora: con leadId devuelve las notas de TODOS los usuarios (listarPorCliente)', async () => { + svcMock.listarPorCliente.mockResolvedValue([{ id: 'n1', titulo: 'Nota de otro asesor' }]) + const res = resJson() + + await controller.listar( + reqConToken('u-1', { query: { leadId: 'loc_9' } }), + res as never, + ) + + expect(svcMock.listarPorCliente).toHaveBeenCalledWith({ leadId: 'loc_9', clienteClave: null }) + expect(svcMock.listar).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith([{ id: 'n1', titulo: 'Nota de otro asesor' }]) + }) + + it('bitácora: con clienteClave (y sin leadId) también usa listarPorCliente', async () => { + svcMock.listarPorCliente.mockResolvedValue([]) + const res = resJson() + + await controller.listar( + reqConToken('u-1', { query: { clienteClave: 'ana prueba|4771234567' } }), + res as never, + ) + + expect(svcMock.listarPorCliente).toHaveBeenCalledWith({ leadId: null, clienteClave: 'ana prueba|4771234567' }) + expect(svcMock.listar).not.toHaveBeenCalled() + }) + it('completar: llama al service con el id', async () => { svcMock.completar.mockResolvedValue({ id: 'r1', completado: true }) const res = resJson() diff --git a/api-gateway/src/controllers/recordatorio-vendedor.controller.ts b/api-gateway/src/controllers/recordatorio-vendedor.controller.ts index 76a00c3..9f8d7d1 100644 --- a/api-gateway/src/controllers/recordatorio-vendedor.controller.ts +++ b/api-gateway/src/controllers/recordatorio-vendedor.controller.ts @@ -39,6 +39,17 @@ export async function listarRecordatoriosEndpoint(req: Request, res: Response): const usuarioId = req.usuarioToken?.sub if (!usuarioId) throw new Error('Auth requerida') + // Bitácora: cuando piden las notas de un lead/cliente concreto, se devuelven + // TODAS las notas de ese cliente (de todos los usuarios). Así cualquier + // usuario que abra la bitácora ve el seguimiento completo, no sólo las + // notas que él mismo escribió. + const leadId = (req.query['leadId'] as string) ?? null + const clienteClave = (req.query['clienteClave'] as string) ?? null + if (leadId !== null || clienteClave !== null) { + res.json(await recordatorioVendedorService.listarPorCliente({ leadId, clienteClave })) + return + } + const completadosStr = req.query['completados'] as string | undefined const opts: { completados?: boolean; desde?: string | null; hasta?: string | null; clienteClave?: string | null } = { desde: (req.query['desde'] as string) ?? null, @@ -89,6 +100,14 @@ export function crearController(svc: RecordatorioVendedorService) { listar: async (req: Request, res: Response): Promise => { const usuarioId = req.usuarioToken?.sub if (!usuarioId) throw new Error('Auth requerida') + // Bitácora: con leadId/clienteClave devuelve las notas de TODOS los + // usuarios (el seguimiento del cliente es compartido) + const leadId = (req.query['leadId'] as string) ?? null + const clienteClave = (req.query['clienteClave'] as string) ?? null + if (leadId !== null || clienteClave !== null) { + res.json(await svc.listarPorCliente({ leadId, clienteClave })) + return + } const completadosStr = req.query['completados'] as string | undefined const opts: { completados?: boolean diff --git a/api-gateway/src/services/buscar.service.ts b/api-gateway/src/services/buscar.service.ts index 435d84d..26447df 100644 --- a/api-gateway/src/services/buscar.service.ts +++ b/api-gateway/src/services/buscar.service.ts @@ -13,43 +13,59 @@ export interface BuscadoItem { * (oportunidades con cotización), y Unidad (inventario). * Case-insensitive sobre los campos indicados; limita a `limite` resultados. */ -export async function buscar(termino: string, limite = 20): Promise { +export async function buscar( + termino: string, + limite = 20, + alcance?: { usuarioId: string; soloPropio: boolean }, +): Promise { const q = termino.trim() if (q === '') return [] const pat = `%${q}%` + // Privacidad por vendedor: cada asesor sólo encuentra SUS leads, contactos + // y cotizaciones. Los roles de supervisión llegan sin filtro (soloPropio). + const propioLead = alcance?.soloPropio ? { usuarioId: alcance.usuarioId } : {} + const propioCliente = alcance?.soloPropio ? { capturadoPorId: alcance.usuarioId } : {} + const propiaCotizacion = alcance?.soloPropio ? { vendedorId: alcance.usuarioId } : {} + const [leads, clientes, cotizaciones, unidades] = await Promise.all([ prisma.leadCaptura.findMany({ where: { - OR: [ - { nombreLead: { contains: q } }, - { telefono: { contains: q } }, - { email: { contains: q } }, - ], + AND: [propioLead, { + OR: [ + { nombreLead: { contains: q } }, + { telefono: { contains: q } }, + { email: { contains: q } }, + ], + }], }, orderBy: { updatedAt: 'desc' }, take: limite, }), prisma.cliente.findMany({ where: { - OR: [ - { nombreCompleto: { contains: q } }, - { curp: { contains: q } }, - { rfc: { contains: q } }, - { email: { contains: q } }, - { celular: { contains: q } }, - ], + AND: [propioCliente, { + OR: [ + { nombreCompleto: { contains: q } }, + { curp: { contains: q } }, + { rfc: { contains: q } }, + { email: { contains: q } }, + { celular: { contains: q } }, + ], + }], }, orderBy: { createdAt: 'desc' }, take: limite, }), prisma.cotizacion.findMany({ where: { - OR: [ - { clienteNombre: { contains: q } }, - { clienteEmail: { contains: q } }, - { unidadCodigo: { contains: q } }, - ], + AND: [propiaCotizacion, { + OR: [ + { clienteNombre: { contains: q } }, + { clienteEmail: { contains: q } }, + { unidadCodigo: { contains: q } }, + ], + }], }, orderBy: { createdAt: 'desc' }, take: limite, diff --git a/api-gateway/src/services/crm-local.service.test.ts b/api-gateway/src/services/crm-local.service.test.ts index fc6b58e..8c33782 100644 --- a/api-gateway/src/services/crm-local.service.test.ts +++ b/api-gateway/src/services/crm-local.service.test.ts @@ -312,7 +312,69 @@ describe('CrmLocalService.buscarLocales', () => { }) expect(r[1]).toMatchObject({ id: 'cli-9', nombre: 'Ana Cliente', fuente: 'oportunidad', desarrollo: 'ATELIER' }) expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] } }), + expect.objectContaining({ + where: { AND: [{}, { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] }] }, + }), + ) + }) +}) + + +describe('CrmLocalService.crearLead — dedup con etapas cerradas', () => { + it('el dedup por teléfono excluye etapas cerradas: limpieza/descartes/cancelados no bloquean la re-captura', async () => { + prismaMock.leadCaptura.findFirst.mockResolvedValue(null) + prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ id: 'loc_nueva', ...data })) + const r = await svc.crearLead({ nombre: 'Reintento', telefono: '6641234567' }, 'u-1') + expect('duplicado' in r).toBe(false) + expect(prismaMock.leadCaptura.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + etapa: { notIn: ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado'] }, + }), + }), + ) + }) + + it('el dedup por email usa el mismo criterio (cualquier tablero, sin etapas cerradas)', async () => { + prismaMock.leadCaptura.findFirst + .mockResolvedValueOnce(null) // teléfono + .mockResolvedValueOnce(null) // email + prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ id: 'loc_nueva', ...data })) + await svc.crearLead({ nombre: 'Reintento Mail', telefono: '', email: 'reintento@correo.com' }, 'u-1') + expect(prismaMock.leadCaptura.findFirst).toHaveBeenLastCalledWith({ + where: { + email: 'reintento@correo.com', + etapa: { notIn: ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado'] }, + }, + }) + }) +}) + +describe('CrmLocalService.buscarLocales — privacidad por vendedor', () => { + it('soloPropio filtra leads por usuario y clientes por capturadoPorId', async () => { + prismaMock.leadCaptura.findMany.mockResolvedValue([]) + prismaMock.cliente.findMany.mockResolvedValue([]) + await svc.buscarLocales('ana', { usuarioId: 'u-9', soloPropio: true }) + expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { AND: [{ usuarioId: 'u-9' }, { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] }] }, + }), + ) + expect(prismaMock.cliente.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { AND: [{ capturadoPorId: 'u-9' }, { OR: [{ nombreCompleto: { contains: 'ana' } }, { celular: { contains: 'ana' } }] }] }, + }), + ) + }) + + it('sin alcance la búsqueda sigue siendo global (roles de supervisión)', async () => { + prismaMock.leadCaptura.findMany.mockResolvedValue([]) + prismaMock.cliente.findMany.mockResolvedValue([]) + await svc.buscarLocales('ana') + expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { AND: [{}, { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] }] }, + }), ) }) }) diff --git a/api-gateway/src/services/crm-local.service.ts b/api-gateway/src/services/crm-local.service.ts index 353c0b6..3751b14 100644 --- a/api-gateway/src/services/crm-local.service.ts +++ b/api-gateway/src/services/crm-local.service.ts @@ -85,10 +85,11 @@ export class CrmLocalService { if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } } } } - // Dedup por email + // Dedup por email: mismo criterio que el teléfono (cualquier tablero, + // sin etapas cerradas) if (datos.email !== undefined && datos.email.trim() !== '') { const existenteEmail = await prisma.leadCaptura.findFirst({ - where: { email: datos.email.trim(), board: 'LEADS' }, + where: { email: datos.email.trim(), etapa: { notIn: ETAPAS_CERRADAS } }, }) if (existenteEmail) return { duplicado: { id: existenteEmail.leadId, nombre: existenteEmail.nombreLead } } } @@ -413,15 +414,22 @@ export class CrmLocalService { /** * Búsqueda local para el picker del alta de clientes (y el buscador del * cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular. + * Con `alcance.soloPropio` (rol vendedor) filtra a los registros propios: + * cada asesor sólo encuentra sus leads y contactos, no los de sus compañeros. */ - async buscarLocales(termino: string): Promise { + async buscarLocales( + termino: string, + alcance?: { usuarioId: string; soloPropio: boolean }, + ): Promise { + const propioLead = alcance?.soloPropio ? { usuarioId: alcance.usuarioId } : {} + const propioCliente = alcance?.soloPropio ? { capturadoPorId: alcance.usuarioId } : {} const capturas = await prisma.leadCaptura.findMany({ - where: { OR: [{ nombreLead: { contains: termino } }, { telefono: { contains: termino } }] }, + where: { AND: [propioLead, { OR: [{ nombreLead: { contains: termino } }, { telefono: { contains: termino } }] }] }, orderBy: { updatedAt: 'desc' }, take: 15, }) const clientes = await prisma.cliente.findMany({ - where: { OR: [{ nombreCompleto: { contains: termino } }, { celular: { contains: termino } }] }, + where: { AND: [propioCliente, { OR: [{ nombreCompleto: { contains: termino } }, { celular: { contains: termino } }] }] }, orderBy: { createdAt: 'desc' }, take: 15, }) @@ -501,12 +509,22 @@ function conUnidadCodigo(datosJson: string | null, unidadCodigo?: string): strin } /** - * Busca un contacto por teléfono (últimos 10 dígitos) en CUALQUIER tablero. - * Así el mismo número no se duplica entre Leads, Oportunidades y Apartados. + * Etapas que cierran el ciclo de un registro: descartados, perdidos y + * cancelados. Ya NO bloquean una captura nueva con el mismo teléfono/email: + * tras una limpieza (o un descarte) el vendedor debe poder volver a capturar + * a esa persona. Verificado en vivo: restos de APARTADO cancelados bloqueaban + * re-capturas tras la última limpieza de leads. + */ +const ETAPAS_CERRADAS = ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado'] + +/** + * Busca un contacto por teléfono (últimos 10 dígitos) en CUALQUIER tablero, + * excluyendo etapas cerradas. Así el mismo número no se duplica entre tableros + * activos, pero los registros limpiados o descartados no estorban. */ async function buscarPorTelefono(diezDigitos: string) { return prisma.leadCaptura.findFirst({ - where: { telefono: { contains: diezDigitos } }, + where: { telefono: { contains: diezDigitos }, etapa: { notIn: ETAPAS_CERRADAS } }, orderBy: { createdAt: 'desc' }, }) } diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 9fee6bb..01632d5 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -330,6 +330,7 @@ export function resumenMiDiaLocal( email: c.email ?? '', etapa: c.etapa, desarrollo: c.desarrollo ?? '', + origen: c.origen ?? '', modeloInteres: c.modelo ?? '', vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '', unidadCodigo, diff --git a/api-gateway/src/services/recordatorio-vendedor.service.ts b/api-gateway/src/services/recordatorio-vendedor.service.ts index c1a14d6..1fef54d 100644 --- a/api-gateway/src/services/recordatorio-vendedor.service.ts +++ b/api-gateway/src/services/recordatorio-vendedor.service.ts @@ -67,6 +67,23 @@ export class RecordatorioVendedorService { }) } + /** + * Notas de la bitácora de un cliente: visibles para TODOS los usuarios + * (así lo pidió operación: las notas hechas en la bitácora las debe ver + * cualquier usuario que abra el cliente). Filtra por lead y/o + * clienteClave, sin restricción de usuario. + */ + async listarPorCliente(filtro: { leadId?: string | null; clienteClave?: string | null }) { + const or: Record[] = [] + if (filtro.leadId) or.push({ leadId: filtro.leadId }) + if (filtro.clienteClave) or.push({ clienteClave: filtro.clienteClave }) + if (or.length === 0) return [] + return prisma.recordatorioVendedor.findMany({ + where: { OR: or }, + orderBy: { fechaRecordar: 'asc' }, + }) + } + async completar(id: string) { const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } }) if (!existente) throw new NotFoundError('Recordatorio no encontrado') diff --git a/apps/mi-dia/src/components/DetalleLeadModal.tsx b/apps/mi-dia/src/components/DetalleLeadModal.tsx index 944bc7d..d2b0502 100644 --- a/apps/mi-dia/src/components/DetalleLeadModal.tsx +++ b/apps/mi-dia/src/components/DetalleLeadModal.tsx @@ -81,8 +81,11 @@ export default function DetalleLeadModal({ const cargarNotas = useCallback(async () => { setCargandoNotas(true) try { - const todas = await listarRecordatorios() const clave = calcularClienteClave(item.nombre, (item as any).telefono) + // Notas de la bitácora: se piden por lead/cliente al API, que devuelve + // las notas de TODOS los usuarios (el seguimiento del cliente es + // compartido y cualquier usuario debe verlo completo). + const todas = await listarRecordatorios({ leadId: item.id, clienteClave: clave }) setNotas(todas.filter((r) => r.leadId === item.id || (r.clienteClave && r.clienteClave === clave))) } catch { setNotaError('Error al cargar notas.') } finally { setCargandoNotas(false) } diff --git a/apps/mi-dia/src/lib/mi-dia.test.ts b/apps/mi-dia/src/lib/mi-dia.test.ts index 9517852..4dbd09a 100644 --- a/apps/mi-dia/src/lib/mi-dia.test.ts +++ b/apps/mi-dia/src/lib/mi-dia.test.ts @@ -82,6 +82,7 @@ describe('urlCotizadorParaOportunidad', () => { modeloInteres: 'SOHO', vendedor: 'Martin Zavala', unidadCodigo: 'U-9', + origen: 'FACEBOOK', updatedAt: '2026-09-04T10:00:00Z', ultimoCambio: null, } @@ -121,6 +122,7 @@ describe('urlAfluenciaParaOportunidad', () => { modeloInteres: 'SOHO', vendedor: 'Martin Zavala', unidadCodigo: '', + origen: '', updatedAt: '2026-09-04T10:00:00Z', ultimoCambio: null, } diff --git a/apps/mi-dia/src/lib/mi-dia.ts b/apps/mi-dia/src/lib/mi-dia.ts index 19bd067..ac3d8ad 100644 --- a/apps/mi-dia/src/lib/mi-dia.ts +++ b/apps/mi-dia/src/lib/mi-dia.ts @@ -66,6 +66,8 @@ export interface OportunidadMiDia { /** Vendedor asignado (nombre); '' si no tiene */ vendedor: string unidadCodigo: string + /** Canal/origen de donde llegó el lead (PASE, FACEBOOK, REFERIDO…) */ + origen: string updatedAt: string ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null /** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */ diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 6a5b6cd..374bbf8 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -184,6 +184,7 @@ function FilaOportunidad({

{op.desarrollo ? ` · ${op.desarrollo}` : ''} + {op.origen ? ` · ${op.origen}` : ''} {op.modeloInteres ? ` · ${op.modeloInteres}` : ''} {op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}

diff --git a/apps/mi-dia/src/services/exportar.test.ts b/apps/mi-dia/src/services/exportar.test.ts index d602f57..63ae85c 100644 --- a/apps/mi-dia/src/services/exportar.test.ts +++ b/apps/mi-dia/src/services/exportar.test.ts @@ -49,6 +49,7 @@ const opFake: OportunidadMiDia = { modeloInteres: 'Modelo A', vendedor: 'Martin Zavala', unidadCodigo: 'D-101', + origen: '', updatedAt: '2026-09-02T12:00:00Z', ultimoCambio: { fechaISO: '2026-09-02T12:00:00Z', usuario: 'Admin', detalle: 'Etapa cambiada' }, } diff --git a/apps/mi-dia/src/services/recordatorios.ts b/apps/mi-dia/src/services/recordatorios.ts index baeb749..0efd424 100644 --- a/apps/mi-dia/src/services/recordatorios.ts +++ b/apps/mi-dia/src/services/recordatorios.ts @@ -13,9 +13,18 @@ export interface Recordatorio { createdAt: string } -/** GET /api/recordatorios — listar recordatorios del usuario actual */ -export async function listarRecordatorios(): Promise { - const res = await fetch(`${API_URL}/recordatorios`, conAuth()) +/** + * GET /api/recordatorios — sin parámetros devuelve los recordatorios del + * usuario (personales). Con `leadId`/`clienteClave` devuelve las notas de la + * bitácora de ESE cliente hechas por TODOS los usuarios: el seguimiento de la + * bitácora es compartido, no privado. + */ +export async function listarRecordatorios(params?: { leadId?: string; clienteClave?: string }): Promise { + const partes: string[] = [] + if (params?.leadId) partes.push(`leadId=${encodeURIComponent(params.leadId)}`) + if (params?.clienteClave) partes.push(`clienteClave=${encodeURIComponent(params.clienteClave)}`) + const qs = partes.length > 0 ? `?${partes.join('&')}` : '' + const res = await fetch(`${API_URL}/recordatorios${qs}`, conAuth()) manejar401(res) if (!res.ok) { const body = (await res.json().catch(() => null)) as { error?: string } | null