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:
2026-09-24 09:49:41 +00:00
parent d5276f4466
commit cfc0acb5d3
17 changed files with 269 additions and 39 deletions

View File

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

View File

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

View File

@@ -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') */

View File

@@ -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>

View File

@@ -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' },
}

View File

@@ -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