feat(7-mejoras): notas persisten + soporte + captura visible

1. Notas propagan entre etapas (convertirLead/apartar updateMany)
2. Afluencia: conexión → aviso amarillo
3. Escrituración: click nombre abre bitácora
4. Co-acreditado: opción 'no tiene'
5. Nota del alta de lead → bitácora
6. Módulo Soporte: formulario + listado solicitudes
7. Captura visible para todos
This commit is contained in:
2026-09-11 23:17:25 +00:00
parent 1ba7aa49da
commit c8357fe582
22 changed files with 693 additions and 19 deletions

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { MODULOS, esRolValido, modulosDeRol } from './roles.js'
const ESPERADOS: Record<string, string[]> = {
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'],
administracion_venta: [
'mi-dia',
'alta-cliente',
@@ -12,6 +12,8 @@ const ESPERADOS: Record<string, string[]> = {
'contactos',
'chat',
'clientes-maestro',
'captura',
'soporte',
'cobranza',
],
director_ventas: [
@@ -23,6 +25,8 @@ const ESPERADOS: Record<string, string[]> = {
'contactos',
'chat',
'clientes-maestro',
'captura',
'soporte',
'cobranza',
'tablero-comercial',
],
@@ -35,9 +39,11 @@ const ESPERADOS: Record<string, string[]> = {
'contactos',
'chat',
'clientes-maestro',
'captura',
'soporte',
'cobranza',
],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
admin: [...MODULOS],
}

View File

@@ -13,6 +13,8 @@ export const MODULOS = [
'contactos',
'chat',
'clientes-maestro',
'captura',
'soporte',
] as const
export type Modulo = (typeof MODULOS)[number]
@@ -26,6 +28,7 @@ export const ROLES = [
] as const
export type Rol = (typeof ROLES)[number]
// 'captura' y 'soporte' son transversales: todos los roles los ven
const MODULOS_VENDEDOR: readonly Modulo[] = [
'mi-dia',
'alta-cliente',
@@ -35,6 +38,8 @@ const MODULOS_VENDEDOR: readonly Modulo[] = [
'contactos',
'chat',
'clientes-maestro',
'captura',
'soporte',
]
export const MATRIZ: Record<Rol, readonly Modulo[]> = {
@@ -42,7 +47,7 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
admin: MODULOS,
}

View File

@@ -25,6 +25,9 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(),
upsert: vi.fn(),
},
recordatorioVendedor: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},

View File

@@ -37,6 +37,9 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(),
upsert: vi.fn(),
},
recordatorioVendedor: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
cliente: {
findMany: vi.fn(),
},

View File

@@ -6,6 +6,7 @@ import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import * as auditoria from '../services/auditoria.service.js'
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError, NotFoundError } from '../utils/errors.js'
import { recordatorioVendedorService } from '../services/recordatorio-vendedor.service.js'
/**
* Captura y acciones del CRM ligero, sobre DOS tableros locales (tabla
@@ -162,6 +163,14 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
})
return
}
// La nota del alta se persiste como recordatorio en la bitácora del lead
if (body.notas !== undefined && body.notas.trim() !== '') {
await recordatorioVendedorService.crear(usuarioSub ?? 'sistema', {
titulo: body.notas,
leadId: resultado.id,
fechaRecordar: new Date().toISOString(),
})
}
res.status(201).json({
id: resultado.id,
estado: 'NUEVO',

View File

@@ -21,6 +21,9 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(),
upsert: vi.fn(),
},
recordatorioVendedor: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},

View File

@@ -0,0 +1,111 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { prisma } from '../services/prisma.js'
import * as auditoria from '../services/auditoria.service.js'
/**
* Módulo Soporte: solicitudes dirigidas a los departamentos internos
* (Sistemas, Administración, Ventas, Cobranza, Escrituración, Dirección).
*
* - POST /api/soporte — crear solicitud (cualquier usuario autenticado).
* - GET /api/soporte — listar solicitudes: propias para cualquier usuario,
* TODAS para admin (con filtro opcional ?estado=ABIERTA|EN_PROCESO|CERRADA).
*/
const DEPARTAMENTOS_SOPORTE = ['Sistemas', 'Administración', 'Ventas', 'Cobranza', 'Escrituración', 'Dirección'] as const
const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const
const ESTADOS_SOPORTE = ['ABIERTA', 'EN_PROCESO', 'CERRADA'] as const
const crearSolicitudSchema = z.object({
titulo: z.string().trim().min(1, 'El título es obligatorio'),
descripcion: z.string().trim().min(1, 'La descripción es obligatoria'),
departamento: z.enum(DEPARTAMENTOS_SOPORTE),
prioridad: z.enum(PRIORIDADES_SOPORTE),
responsable: z.string().trim().min(1).optional(),
fechaSolicitud: z.coerce.date(),
fechaLimite: z.coerce.date().optional(),
notas: z.string().trim().min(1).optional(),
// Quién pide la solicitud; el front lo manda desde la sesión y el API cae al
// username del token si viene vacío
solicitante: z.string().trim().min(1).optional(),
email: z.string().trim().email('Email inválido').optional(),
// Rutas/nombres de archivos adjuntos (sin endpoint de upload: el front manda
// nombres; se guardan como JSON array)
archivos: z.array(z.string().trim().min(1)).optional(),
})
const listarSolicitudSchema = z.object({
estado: z.enum(ESTADOS_SOPORTE).optional(),
})
/** Serializa la fila para el front: archivos JSON → array (tolerante a basura) */
function aPublico(fila: {
id: string
usuarioId: string
titulo: string
descripcion: string
departamento: string
prioridad: string
responsable: string | null
fechaSolicitud: Date
fechaLimite: Date | null
notas: string | null
archivos: string | null
solicitante: string
solicitanteEmail: string | null
estado: string
createdAt: Date
updatedAt: Date
}) {
let archivos: string[] = []
if (fila.archivos) {
try {
const parseado: unknown = JSON.parse(fila.archivos)
if (Array.isArray(parseado)) archivos = parseado.filter((a): a is string => typeof a === 'string')
} catch {
archivos = []
}
}
return { ...fila, archivos }
}
/** POST /api/soporte — crear solicitud de soporte */
export async function crearSolicitudSoporteEndpoint(req: Request, res: Response): Promise<void> {
const body = crearSolicitudSchema.parse(req.body)
const usuarioId = req.usuarioToken?.sub ?? 'desconocido'
const fila = await prisma.solicitudSoporte.create({
data: {
usuarioId,
titulo: body.titulo,
descripcion: body.descripcion,
departamento: body.departamento,
prioridad: body.prioridad,
responsable: body.responsable ?? null,
fechaSolicitud: body.fechaSolicitud,
fechaLimite: body.fechaLimite ?? null,
notas: body.notas ?? null,
archivos: body.archivos !== undefined ? JSON.stringify(body.archivos) : null,
solicitante: body.solicitante ?? req.usuarioToken?.username ?? '',
solicitanteEmail: body.email ?? null,
},
})
res.status(201).json(aPublico(fila))
auditoria.registrar(usuarioId, 'CREAR_SOLICITUD_SOPORTE', 'soporte', fila.id, body.departamento)
}
/** GET /api/soporte — listar solicitudes (propias; admin ve todas) */
export async function listarSolicitudesSoporteEndpoint(req: Request, res: Response): Promise<void> {
const { estado } = listarSolicitudSchema.parse(req.query)
const where: Record<string, unknown> = {}
// Admin ve todas; el resto solo las suyas
if (req.usuarioToken?.rol !== 'admin') {
where['usuarioId'] = req.usuarioToken?.sub ?? 'desconocido'
}
if (estado !== undefined) where['estado'] = estado
const filas = await prisma.solicitudSoporte.findMany({
where,
orderBy: { createdAt: 'desc' },
})
res.json(filas.map(aPublico))
}

View File

@@ -133,6 +133,10 @@ import {
importarEndpoint as importarMaestroEndpoint,
requireAdmin as requireAdminMaestro,
} from '../controllers/cliente-maestro.controller.js'
import {
crearSolicitudSoporteEndpoint,
listarSolicitudesSoporteEndpoint,
} from '../controllers/soporte.controller.js'
export const apiRouter = Router()
@@ -292,3 +296,7 @@ apiRouter.get('/clientes-maestro', listarMaestroEndpoint)
apiRouter.get('/clientes-maestro/exportar', exportarMaestroEndpoint)
apiRouter.get('/clientes-maestro/:numCliente', obtenerMaestroEndpoint)
apiRouter.post('/clientes-maestro/importar', requireAdminMaestro, importarMaestroEndpoint)
// ── Módulo Soporte: solicitudes a departamentos internos ──────────
apiRouter.post('/soporte', crearSolicitudSoporteEndpoint)
apiRouter.get('/soporte', listarSolicitudesSoporteEndpoint)

View File

@@ -54,7 +54,7 @@ describe('login', () => {
rol: 'vendedor',
asignacion: 'MONTEBELLO',
mondayUserId: null,
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'],
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', 'clientes-maestro'])
expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'])
})
it('rechaza si el usuario ya no existe o está inactivo', async () => {

View File

@@ -19,6 +19,9 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(),
upsert: vi.fn(),
},
recordatorioVendedor: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
cliente: {
findMany: vi.fn(),
},

View File

@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'
import { AppError, NotFoundError } from '../utils/errors.js'
import { claveApartadoCreado } from './automatizaciones.service.js'
import { calcularClienteClave } from './recordatorio-vendedor.service.js'
import { prisma } from './prisma.js'
/**
@@ -256,11 +257,13 @@ export class CrmLocalService {
})
}
/**
/**
* Conversión local lead → oportunidad (botón "Agendo" de Mis leads): el lead
* queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando
* los datos y se liga LeadOportunidad + `oportunidadId` en la captura. Si el
* lead ya fue convertido → 409. Devuelve la fila local de la oportunidad.
* los datos y se liga LeadOportunidad + `oportunidadId` en la captura. Las
* notas (recordatorios) del lead pasan a apuntar a la oportunidad para que
* sobrevivan al cambio de etapa. Si el lead ya fue convertido → 409. Devuelve
* la fila local de la oportunidad.
*/
async convertirLead(leadIdLocal: string, _usuarioId?: string) {
const lead = await prisma.leadCaptura.findUnique({ where: { leadId: leadIdLocal } })
@@ -291,6 +294,11 @@ export class CrmLocalService {
},
}),
prisma.leadOportunidad.create({ data: { leadId: leadIdLocal, oportunidadId } }),
// Las notas del lead viejo siguen a la nueva oportunidad (bitácora persiste)
prisma.recordatorioVendedor.updateMany({
where: { leadId: leadIdLocal },
data: { leadId: oportunidadId, clienteClave: calcularClienteClave(lead.nombreLead, lead.telefono) },
}),
])
return resultados[1]
}
@@ -298,8 +306,10 @@ export class CrmLocalService {
/**
* "Aparto" local: la oportunidad pasa a etapa 'Aparto' y se crea el apartado
* local (board=APARTADO, etapa 'Pago Apartado', primer paso del pipeline
* Enkontrol) con los datos conocidos. Idempotente con el mismo dedup
* `apartado-creado:<opId>` de las automatizaciones.
* Enkontrol) con los datos conocidos. Las notas (recordatorios) de la
* oportunidad pasan a apuntar al apartado para que sobrevivan al cambio de
* board. Idempotente con el mismo dedup `apartado-creado:<opId>` de las
* automatizaciones.
*/
async apartar(oportunidadIdLocal: string, usuarioId?: string) {
// Sombra para ids heredados sin fila local
@@ -343,6 +353,11 @@ export class CrmLocalService {
update: { valor: apartadoId },
create: { clave, valor: apartadoId },
}),
// Las notas de la oportunidad siguen al apartado (bitácora persiste)
prisma.recordatorioVendedor.updateMany({
where: { leadId: oportunidadIdLocal },
data: { leadId: apartadoId, clienteClave: calcularClienteClave(oportunidad.nombreLead, oportunidad.telefono) },
}),
])
return resultados[1]
}