fix(crm): re-captura tras limpieza, bitacora compartida, origen en oportunidades y privacidad del buscador
1. Re-captura: el dedup por telefono/email ya no considera etapas cerradas (Descartado, Perdido, Cancelo, Cancelacion Apartado). Los restos de la ultima limpieza (17 filas de APARTADO cancelados sin contraparte activa) bloqueaban a los vendedores al re-capturar; los registros activos siguen bloqueando con 409 como siempre. 2. Bitacora compartida: GET /recordatorios con leadId/clienteClave devuelve las notas de TODOS los usuarios de ese cliente (antes cada quien solo veia las suyas). Mi dia pide la bitacora con esos filtros; la lista personal sigue siendo propia. 3. Origen en oportunidades: el resumen de mi-dia ya devuelve origen y la card de cada oportunidad lo muestra (telefono · desarrollo · canal · modelo). 4. Privacidad del buscador: /leads/buscar y /buscar filtran por dueno cuando el rol es vendedor (leads por usuarioId, contactos por capturadoPorId, cotizaciones por vendedorId). Los roles de supervision siguen viendo todo. Ya no aparecen leads de otros asesores con nombre y telefono al teclear. Verificado en vivo contra el API: 10/10 comprobaciones (re-captura del telefono cancelado 201, duplicado activo 409, nota del admin visible para la vendedora, origen presente en 27/27 oportunidades, buscador del vendedor sin datos ajenos y del admin completo). 716 pruebas del API y 32 de mi-dia en verde. Los tests de roles ahora usan prisma mockeado (la BD viva cambia cuando el cliente edita permisos desde la UI).
This commit is contained in:
@@ -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<string, string[]> = {
|
||||
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
||||
administracion_venta: [
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -8,6 +8,13 @@ import { buscar } from '../services/buscar.service.js'
|
||||
export async function buscarEndpoint(req: Request, res: Response): Promise<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -363,5 +363,13 @@ export async function moverLeadEndpoint(req: Request, res: Response): Promise<vo
|
||||
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<void> => {
|
||||
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
|
||||
|
||||
@@ -13,25 +13,38 @@ 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<BuscadoItem[]> {
|
||||
export async function buscar(
|
||||
termino: string,
|
||||
limite = 20,
|
||||
alcance?: { usuarioId: string; soloPropio: boolean },
|
||||
): Promise<BuscadoItem[]> {
|
||||
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: {
|
||||
AND: [propioLead, {
|
||||
OR: [
|
||||
{ nombreLead: { contains: q } },
|
||||
{ telefono: { contains: q } },
|
||||
{ email: { contains: q } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: limite,
|
||||
}),
|
||||
prisma.cliente.findMany({
|
||||
where: {
|
||||
AND: [propioCliente, {
|
||||
OR: [
|
||||
{ nombreCompleto: { contains: q } },
|
||||
{ curp: { contains: q } },
|
||||
@@ -39,17 +52,20 @@ export async function buscar(termino: string, limite = 20): Promise<BuscadoItem[
|
||||
{ email: { contains: q } },
|
||||
{ celular: { contains: q } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limite,
|
||||
}),
|
||||
prisma.cotizacion.findMany({
|
||||
where: {
|
||||
AND: [propiaCotizacion, {
|
||||
OR: [
|
||||
{ clienteNombre: { contains: q } },
|
||||
{ clienteEmail: { contains: q } },
|
||||
{ unidadCodigo: { contains: q } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limite,
|
||||
|
||||
@@ -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' } }] }] },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<ResultadoBusquedaLocal[]> {
|
||||
async buscarLocales(
|
||||
termino: string,
|
||||
alcance?: { usuarioId: string; soloPropio: boolean },
|
||||
): Promise<ResultadoBusquedaLocal[]> {
|
||||
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' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>[] = []
|
||||
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')
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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') */
|
||||
|
||||
@@ -184,6 +184,7 @@ function FilaOportunidad({
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
<EnlacesContacto nombre={op.nombre} telefono={op.telefono} />
|
||||
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
|
||||
{op.origen ? ` · ${op.origen}` : ''}
|
||||
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
|
||||
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
|
||||
</p>
|
||||
|
||||
@@ -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' },
|
||||
}
|
||||
|
||||
@@ -13,9 +13,18 @@ export interface Recordatorio {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** GET /api/recordatorios — listar recordatorios del usuario actual */
|
||||
export async function listarRecordatorios(): Promise<Recordatorio[]> {
|
||||
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<Recordatorio[]> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user