feat(9-mejoras): limpieza leads + admin CRUD + calendario + CRM360 FRASA

1. Leads limpiados (207 eliminados, 18 apartados conservados)
2. Round-robin desactivado (config round_robin_habilitado=false)
3. Admin puede borrar/reasignar/mover leads (DELETE/PATCH/POST)
4. Calendario: fechaCita guardada y pasada correctamente
5. Avisos Monday eliminados (BannerModoLocal, ChipPendienteSync, vinculación)
6. Contactos filtrados por usuario asignado
7. Modal bitácora: notas persisten entre fases
8. Fases oportunidades: Agendo→Afluencia→Cotizar→Aparto
9. Toast de nota eliminado en cambios de status
10. Branding: CRM360 FRASA + logo en todas las apps
11. 7 apps construidas
This commit is contained in:
2026-09-10 07:05:40 +00:00
parent 356bf46216
commit 16cacd5f31
43 changed files with 246 additions and 190 deletions

View File

@@ -0,0 +1,60 @@
/**
* Elimina todos los registros de LeadCaptura donde board = 'LEADS' o
* board = 'OPORTUNIDADES'. Muestra conteo antes y después.
*
* Uso: npx tsx scripts/limpiar-leads.js
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import dotenv from 'dotenv'
import { PrismaClient } from '@prisma/client'
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
const prisma = new PrismaClient()
async function main() {
// ── Conteo antes ──
const total = await prisma.leadCaptura.count()
const leads = await prisma.leadCaptura.count({ where: { board: 'LEADS' } })
const oportunidades = await prisma.leadCaptura.count({ where: { board: 'OPORTUNIDADES' } })
const otros = total - leads - oportunidades
console.log('── Antes de limpiar ──')
console.log(` Total LeadCaptura: ${total}`)
console.log(` board = LEADS: ${leads}`)
console.log(` board = OPORTUNIDADES: ${oportunidades}`)
console.log(` Otros (APARTADO, etc.): ${otros}`)
if (leads + oportunidades === 0) {
console.log('\nNo hay leads ni oportunidades que borrar.')
return
}
// ── Eliminar leads y oportunidades ──
const deleted = await prisma.leadCaptura.deleteMany({
where: { board: { in: ['LEADS', 'OPORTUNIDADES'] } },
})
console.log(`\nEliminados: ${deleted.count} registros`)
// ── Conteo después ──
const totalDespues = await prisma.leadCaptura.count()
const leadsDespues = await prisma.leadCaptura.count({ where: { board: 'LEADS' } })
const opDespues = await prisma.leadCaptura.count({ where: { board: 'OPORTUNIDADES' } })
const otrosDespues = totalDespues - leadsDespues - opDespues
console.log('\n── Después de limpiar ──')
console.log(` Total LeadCaptura: ${totalDespues}`)
console.log(` board = LEADS: ${leadsDespues}`)
console.log(` board = OPORTUNIDADES: ${opDespues}`)
console.log(` Otros (APARTADO, etc.): ${otrosDespues}`)
console.log('\n✅ Limpieza completada.')
}
main()
.catch((err) => {
console.error('❌ Error:', err)
process.exit(1)
})
.finally(() => prisma.$disconnect())

View File

@@ -28,6 +28,7 @@ const CONFIGS_DEFAULT = [
{ clave: 'auto_logout', valor: 'true' },
{ clave: 'mostrar_precios_lista', valor: 'true' },
{ clave: 'max_upload_mb', valor: '10' },
{ clave: 'round_robin_habilitado', valor: 'false' },
]
// ── RolConfiguracion: módulos por rol (matriz de config/roles.ts) ──────

View File

@@ -13,16 +13,17 @@ import { NotFoundError } from '../utils/errors.js'
* (nombre, teléfono, email, estado), lo más reciente primero, máx 100.
* Monday caído (o 502) → lista local (Cliente + LeadCaptura), mismo shape.
*/
export async function listarContactosEndpoint(_req: Request, res: Response): Promise<void> {
export async function listarContactosEndpoint(req: Request, res: Response): Promise<void> {
const usuarioId = req.usuarioToken?.sub
if (!saludMonday.estaDisponible()) {
res.json(await listarContactosLocal())
res.json(await listarContactosLocal(usuarioId))
return
}
try {
res.json(await mondayService.getContactos())
} catch (err) {
if (!esFalloMonday(err)) throw err
res.json(await listarContactosLocal())
res.json(await listarContactosLocal(usuarioId))
}
}

View File

@@ -798,6 +798,81 @@ export async function convertirLeadEndpoint(req: Request, res: Response): Promis
}
}
// ── Admin CRUD de leads ────────────────────────────────────────────────
/** DELETE /api/leads/:itemId — eliminar lead (solo admin) */
export async function eliminarLeadEndpoint(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
// Eliminar de Monday si es item remoto y Monday está disponible
if (row && row.mondayItemId && !esIdLocal(itemId) && saludMonday.estaDisponible()) {
try {
await mondayService.eliminarItem(row.mondayItemId)
} catch (err) {
if (!esFalloMonday(err)) throw err
}
}
// Eliminar la fila local (si existe)
if (row) {
await prisma.leadCaptura.delete({ where: { leadId: itemId } }).catch(() => {})
}
res.json({ eliminado: true, itemId })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ELIMINAR_LEAD', 'lead', itemId)
}
const asignarLeadSchema = z.object({
usuarioId: z.string().trim().min(1, 'usuarioId es obligatorio'),
})
/** PATCH /api/leads/:itemId/asignar — reasignar lead a otro vendedor (solo admin) */
export async function asignarLeadEndpoint(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
const { usuarioId } = asignarLeadSchema.parse(req.body)
const usuario = await prisma.usuario.findUnique({
where: { id: usuarioId },
select: { mondayUserId: true, nombre: true },
})
if (!usuario) throw new NotFoundError('Usuario no encontrado')
// Actualizar LeadCaptura
await prisma.leadCaptura.updateMany({
where: { leadId: itemId },
data: { usuarioId },
})
// En Monday: determinar si es lead u oportunidad
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
const board: 'leads' | 'oportunidades' = row?.board === 'OPORTUNIDADES' ? 'oportunidades' : 'leads'
if (usuario.mondayUserId != null && saludMonday.estaDisponible() && !esIdLocal(itemId)) {
try {
await automatizacionesService.asignarACapturista(itemId, row?.nombreLead ?? '', usuario.mondayUserId, board)
} catch (err) {
if (!esFalloMonday(err)) throw err
await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: usuario.mondayUserId }, itemId)
}
} else if (usuario.mondayUserId != null) {
await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: usuario.mondayUserId }, itemId)
}
res.json({ itemId, usuarioId, nombre: usuario.nombre })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, usuarioId)
}
const moverLeadSchema = z.object({
board: z.enum(['LEADS', 'OPORTUNIDADES']),
})
/** POST /api/leads/:itemId/mover — mover lead a otro tablero (solo admin) */
export async function moverLeadEndpoint(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
const { board } = moverLeadSchema.parse(req.body)
const row = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
if (!row) throw new NotFoundError('Lead no encontrado')
await prisma.leadCaptura.update({
where: { leadId: itemId },
data: { board },
})
res.json({ itemId, board })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'MOVER_LEAD', 'lead', itemId, board)
}
/**
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
* clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2

View File

@@ -58,6 +58,9 @@ import {
buscarLeadsEndpoint,
convertirLeadEndpoint,
descartarLeadEndpoint,
eliminarLeadEndpoint,
asignarLeadEndpoint,
moverLeadEndpoint,
} from '../controllers/leads.controller.js'
import {
actualizarRolEndpoint,
@@ -230,6 +233,10 @@ apiRouter.post('/leads/:itemId/estado', cambiarEstadoLeadEndpoint)
apiRouter.post('/leads/:itemId/descartar', descartarLeadEndpoint)
// Botón "Agendo" de Mis leads: convierte el lead en oportunidad (Agendo Cita)
apiRouter.post('/leads/:itemId/convertir', convertirLeadEndpoint)
// Admin CRUD de leads
apiRouter.delete('/leads/:itemId', requireAdmin, eliminarLeadEndpoint)
apiRouter.patch('/leads/:itemId/asignar', requireAdmin, asignarLeadEndpoint)
apiRouter.post('/leads/:itemId/mover', requireAdmin, moverLeadEndpoint)
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
apiRouter.post('/afluencias', crearAfluenciaEndpoint)

View File

@@ -1,4 +1,5 @@
import { config } from '../config/index.js'
import * as configService from './config.service.js'
import { AppError } from '../utils/errors.js'
import { actividadLocalService } from './actividad-local.service.js'
import {
@@ -325,6 +326,10 @@ export class AutomatizacionesService {
* pasada a un tablero (rutas de los webhooks create_item).
*/
async runRoundRobin(soloItemId?: string, board?: TableroRoundRobin): Promise<ResultadoRoundRobin> {
// Round-robin deshabilitado vía config (default false en seed)
if (soloItemId === undefined && configService.obtenerConfigSync('round_robin_habilitado') !== 'true') {
return { asignados: 0, detalle: [] }
}
// Modo local: Monday caído → round-robin sobre tablas locales
if (!saludMonday.estaDisponible() && soloItemId === undefined) {
return this.runRoundRobinLocal()
@@ -653,6 +658,10 @@ export class AutomatizacionesService {
* Crea ActividadLocal de SEGUIMIENTO y actualiza LeadCaptura.usuarioId.
*/
async runRoundRobinLocal(): Promise<ResultadoRoundRobin> {
// Round-robin deshabilitado vía config (default false en seed)
if (configService.obtenerConfigSync('round_robin_habilitado') !== 'true') {
return { asignados: 0, detalle: [] }
}
// Leads locales sin asignar
const leadsSinAsignar = await prisma.leadCaptura.findMany({
where: {

View File

@@ -17,11 +17,19 @@ export type ContactoListaLocal = ContactoMonday & { fuente?: 'local' }
* Lista local: Clientes (alta de clientes) + capturas del embudo en boards
* OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la
* ficha completa), más reciente primero, máx 100 (como getContactos).
* Filtra por usuario actual: clientes capturados por él + leads asignados a él.
*/
export async function listarContactosLocal(): Promise<ContactoListaLocal[]> {
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' }, take: 100 })
export async function listarContactosLocal(usuarioId?: string): Promise<ContactoListaLocal[]> {
const clientes = await prisma.cliente.findMany({
where: usuarioId ? { capturadoPorId: usuarioId } : {},
orderBy: { createdAt: 'desc' },
take: 100,
})
const capturas = await prisma.leadCaptura.findMany({
where: { board: { in: ['OPORTUNIDADES', 'APARTADO'] } },
where: {
board: { in: ['OPORTUNIDADES', 'APARTADO'] },
...(usuarioId ? { usuarioId } : {}),
},
orderBy: { updatedAt: 'desc' },
take: 100,
})

View File

@@ -350,10 +350,21 @@ export class CrmLocalService {
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
const payload: Record<string, unknown> = { board: BOARD_PAYLOAD[board], label }
if (opciones.fecha !== undefined) payload['fecha'] = opciones.fecha
// Guardar fechaCita en datosJson para que la oportunidad la muestre en el calendario
const updateData: Record<string, unknown> = { etapa: label, pendienteSync: true }
if (opciones.fecha !== undefined && board === 'OPORTUNIDADES') {
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: itemId }, select: { datosJson: true } })
let datos: Record<string, unknown> = {}
if (existente?.datosJson) {
try { datos = JSON.parse(existente.datosJson) as Record<string, unknown> } catch { /* ignorar */ }
}
datos['fechaCita'] = opciones.fecha
updateData['datosJson'] = JSON.stringify(datos)
}
await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: itemId },
data: { etapa: label, pendienteSync: true },
data: updateData,
}),
prisma.syncPendiente.create({
data: { tipo: 'CAMBIO_ETAPA', payloadJson: JSON.stringify(payload), referenciaLocal: itemId },

View File

@@ -117,6 +117,8 @@ export interface OportunidadMiDia {
updatedAt: string
/** Último evento del activity log del board; null si el item no tiene eventos */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */
fechaCita?: string | undefined
/** true = captura local aún no sincronizada a Monday (badge ⏳ del front; aditivo) */
pendienteSync?: boolean
}
@@ -319,6 +321,7 @@ export async function resumenMiDia(
unidadCodigo: o.unidadCodigo,
updatedAt: o.updatedAt,
ultimoCambio: null,
fechaCita: o.fechaCita,
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
@@ -681,11 +684,13 @@ export function resumenMiDiaLocal(
.filter((c) => !esEtapaTerminal(c.etapa))
.map((c) => {
let unidadCodigo = ''
let fechaCita: string | undefined
if (c.datosJson) {
try {
const datos = JSON.parse(c.datosJson) as Record<string, unknown>
if (typeof datos.unidadCodigo === 'string' && datos.unidadCodigo !== '') unidadCodigo = datos.unidadCodigo
else if (typeof datos.desarrollo === 'string' && datos.desarrollo !== '') unidadCodigo = datos.desarrollo
if (typeof datos.fechaCita === 'string') fechaCita = datos.fechaCita
} catch { /* datosJson corrupto: ignorar */ }
}
return {
@@ -699,6 +704,7 @@ export function resumenMiDiaLocal(
unidadCodigo,
updatedAt: c.updatedAt.toISOString(),
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
fechaCita,
pendienteSync: c.pendienteSync,
}
})
@@ -962,6 +968,7 @@ export function unirPendientesLocales(
unidadCodigo: '',
updatedAt: p.updatedAt.toISOString(),
ultimoCambio: ultimoCambioLocal(p, nombreUsuario),
fechaCita: (() => { try { const d = JSON.parse(p.datosJson ?? '{}') as Record<string, unknown>; return typeof d.fechaCita === 'string' ? d.fechaCita : undefined } catch { return undefined } })(),
pendienteSync: true,
})
}

View File

@@ -128,6 +128,7 @@ export interface OportunidadMonday {
califica?: string // dropdown 'Lead Califica'
rangoSalarial?: string // text 'Rango Salarial'
loQuierePara?: string // dropdown 'Proposito de Compra'
fechaCita?: string | undefined // text 'Fecha' — fecha/hora de la cita agendada
}
/** Usuario del workspace (para la lista rotativa de vendedores) */
@@ -1116,9 +1117,11 @@ export class MondayService {
califica: textoDe('Lead Califica'),
rangoSalarial: textoDe('Rango Salarial'),
loQuierePara: textoDe('Proposito de Compra'),
fechaCita: textoDe('Fecha') || undefined,
}
}
/**
* Oportunidad con el mismo teléfono (dedup de la captura de afluencia, que
* ahora vive en Oportunidades). Compara solo dígitos y los últimos 10