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

@@ -0,0 +1,24 @@
-- Tabla del módulo Soporte: solicitudes de los departamentos.
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
-- CreateTable SolicitudSoporte
CREATE TABLE IF NOT EXISTS "SolicitudSoporte" (
"id" TEXT NOT NULL PRIMARY KEY,
"usuarioId" TEXT NOT NULL,
"titulo" TEXT NOT NULL,
"descripcion" TEXT NOT NULL,
"departamento" TEXT NOT NULL,
"prioridad" TEXT NOT NULL,
"responsable" TEXT,
"fechaSolicitud" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"fechaLimite" DATETIME,
"notas" TEXT,
"archivos" TEXT,
"solicitante" TEXT NOT NULL DEFAULT '',
"solicitanteEmail" TEXT,
"estado" TEXT NOT NULL DEFAULT 'ABIERTA',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS "SolicitudSoporte_usuarioId_idx" ON "SolicitudSoporte"("usuarioId");
CREATE INDEX IF NOT EXISTS "SolicitudSoporte_departamento_idx" ON "SolicitudSoporte"("departamento");

View File

@@ -504,3 +504,29 @@ model ClienteMaestro {
@@index([estatus])
@@index([vendedor])
}
// ── Módulo Soporte: solicitudes de los departamentos (Sistemas, Admin, ...) ──
model SolicitudSoporte {
id String @id @default(uuid())
usuarioId String
titulo String
descripcion String
departamento String
prioridad String
responsable String?
fechaSolicitud DateTime @default(now())
fechaLimite DateTime?
notas String?
archivos String? // JSON array de rutas
// Quién pide la solicitud (nombre y email; el front los manda o el API usa
// el usuario del token)
solicitante String @default("")
solicitanteEmail String?
estado String @default("ABIERTA") // ABIERTA, EN_PROCESO, CERRADA
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([usuarioId])
@@index([departamento])
}

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]
}