fix(auditoria completa): bloqueos de Mi Dia, cobranza (saldos y fechas), captura (departamento y rol), contactos (privacidad), cotizador (QR) y validaciones de API
This commit is contained in:
@@ -249,13 +249,12 @@ async function escribir(planesLeidos) {
|
||||
p.montoPagado = Math.min(p.monto, p.montoPagado + ap.monto)
|
||||
}
|
||||
|
||||
// Si el maestro registra pagos por encima del plan declarado (filas con
|
||||
// MESES/MENSUALIDAD vacíos pero grid lleno), el total del plan es lo pagado.
|
||||
const totalPagadoAcumulado = pagosProg.reduce((a, p) => a + p.montoPagado, 0) +
|
||||
// Invariante del módulo: Σ montos de TODOS los pagos del plan == engancheTotal
|
||||
// (incluidos los standalone, que antes quedaban fuera y generaban saldo
|
||||
// fantasma: el plan mostraba 100% pagado y saldo pendiente a la vez).
|
||||
const totalProgramado = pagosProg.reduce((a, p) => a + p.monto, 0) +
|
||||
standalone.reduce((a, s) => a + s.monto, 0)
|
||||
if (totalPagadoAcumulado > plan.engancheTotal) {
|
||||
plan.engancheTotal = Math.round(totalPagadoAcumulado * 100) / 100
|
||||
}
|
||||
plan.engancheTotal = Math.round(totalProgramado * 100) / 100
|
||||
|
||||
const creado = await prisma.planPago.create({ data: plan })
|
||||
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import * as capturaService from '../services/captura-departamento.service.js'
|
||||
import { DEPARTAMENTOS } from '../config/campos-departamento.js'
|
||||
import { CAMPOS_POR_DEPARTAMENTO, DEPARTAMENTOS } from '../config/campos-departamento.js'
|
||||
import { AppError, BadRequestError } from '../utils/errors.js'
|
||||
import { esAdminOManager } from '../utils/roles.js'
|
||||
|
||||
// ── Validación de campos por departamento ────────────────────────────
|
||||
|
||||
/**
|
||||
* Cada campo debe pertenecer al departamento que se está guardando: si el
|
||||
* cliente manda un campo de otro departamento (p. ej. una captura de Obra
|
||||
* enviada como Ventas) se rechaza en vez de crear una fila basura.
|
||||
*/
|
||||
function validarCamposDelDepartamento(
|
||||
departamento: string,
|
||||
registros: { unidad: string; campo: string; valor: string | null }[],
|
||||
): void {
|
||||
const permitidos = new Set((CAMPOS_POR_DEPARTAMENTO[departamento] ?? []).map((c) => c.campo))
|
||||
const invalidos = registros.filter((r) => !permitidos.has(r.campo))
|
||||
if (invalidos.length > 0) {
|
||||
const lista = invalidos.slice(0, 3).map((r) => `${r.campo} (unidad ${r.unidad})`).join(', ')
|
||||
throw new BadRequestError(
|
||||
`El campo no pertenece al departamento ${departamento}: ${lista}${invalidos.length > 3 ? '…' : ''}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Middleware admin ─────────────────────────────────────────────────
|
||||
|
||||
function requireAdmin(req: Request, _res: Response, next: NextFunction): void {
|
||||
@@ -68,6 +89,7 @@ export async function guardarEndpoint(req: Request, res: Response): Promise<void
|
||||
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
|
||||
throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
|
||||
}
|
||||
validarCamposDelDepartamento(body.departamento, [{ unidad: body.unidadCodigo, campo: body.campo, valor: body.valor ?? null }])
|
||||
|
||||
await capturaService.guardarCampo(
|
||||
body.desarrollo,
|
||||
@@ -102,6 +124,10 @@ export async function bulkEndpoint(req: Request, res: Response): Promise<void> {
|
||||
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
|
||||
throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
|
||||
}
|
||||
validarCamposDelDepartamento(
|
||||
body.departamento,
|
||||
body.registros.map((r) => ({ unidad: r.unidad, campo: r.campo, valor: r.valor ?? null })),
|
||||
)
|
||||
|
||||
await capturaService.guardarBulk(
|
||||
body.desarrollo,
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { detalleContactoLocal, listarContactosLocal, exportarCSV } from '../services/contactos-local.service.js'
|
||||
import {
|
||||
detalleContactoLocal,
|
||||
esContactoDelUsuario,
|
||||
listarContactosLocal,
|
||||
exportarCSV,
|
||||
} from '../services/contactos-local.service.js'
|
||||
import { perfilCompleto } from '../services/contactos-detalle.service.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
import { esAdminOManager } from '../utils/roles.js'
|
||||
|
||||
/** Roles que ven el directorio completo de contactos (no sólo los suyos) */
|
||||
const ROLES_VEN_TODO = ['admin', 'manager', 'direccion_general', 'director_ventas', 'administracion_venta']
|
||||
|
||||
/**
|
||||
* ¿Este token ve todos los contactos? Sin token (AUTH_ENABLED=false, modo
|
||||
* local) se permite todo, igual que hace /mi-dia/resumen; con token, sólo los
|
||||
* roles de dirección ven el directorio completo (un vendedor ve lo suyo).
|
||||
*/
|
||||
function veTodoElDirectorio(token: { sub?: string; rol?: string } | undefined): boolean {
|
||||
if (token === undefined) return true
|
||||
return esAdminOManager(token.rol) || (token.rol !== undefined && ROLES_VEN_TODO.includes(token.rol))
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/contactos (Bearer) → contactos locales (tabla Cliente + LeadCaptura:
|
||||
@@ -12,7 +31,9 @@ import { NotFoundError } from '../utils/errors.js'
|
||||
export async function listarContactosEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const paginaParam = Number(req.query['pagina'])
|
||||
const pagina = Number.isFinite(paginaParam) && paginaParam >= 1 ? Math.floor(paginaParam) : 1
|
||||
res.json(await listarContactosLocal(req.usuarioToken?.sub, pagina))
|
||||
// Dirección/administración ven el directorio completo; un vendedor sólo lo suyo
|
||||
const usuarioId = veTodoElDirectorio(req.usuarioToken) ? undefined : req.usuarioToken?.sub
|
||||
res.json(await listarContactosLocal(usuarioId, pagina))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,7 +44,13 @@ export async function listarContactosEndpoint(req: Request, res: Response): Prom
|
||||
* cotizaciones locales).
|
||||
*/
|
||||
export async function detalleContactoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const detalle = await detalleContactoLocal(String(req.params['id']))
|
||||
const contactoId = String(req.params['id'])
|
||||
// Sin permiso de directorio, el contacto debe ser suyo
|
||||
const sub = req.usuarioToken?.sub
|
||||
if (!veTodoElDirectorio(req.usuarioToken) && (!sub || !(await esContactoDelUsuario(contactoId, sub)))) {
|
||||
throw new NotFoundError('Contacto no encontrado')
|
||||
}
|
||||
const detalle = await detalleContactoLocal(contactoId)
|
||||
if (detalle === null) throw new NotFoundError('Contacto no encontrado')
|
||||
res.json(detalle)
|
||||
}
|
||||
@@ -34,6 +61,10 @@ export async function detalleContactoEndpoint(req: Request, res: Response): Prom
|
||||
*/
|
||||
export async function perfilCompletoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const contactoId = String(req.params['id'])
|
||||
const sub = req.usuarioToken?.sub
|
||||
if (!veTodoElDirectorio(req.usuarioToken) && (!sub || !(await esContactoDelUsuario(contactoId, sub)))) {
|
||||
throw new NotFoundError('Contacto no encontrado')
|
||||
}
|
||||
const perfil = await perfilCompleto(contactoId)
|
||||
if (perfil === null) throw new NotFoundError('Contacto no encontrado')
|
||||
res.json(perfil)
|
||||
@@ -47,6 +78,7 @@ export async function exportarContactosEndpoint(req: Request, res: Response): Pr
|
||||
const csv = await exportarCSV({
|
||||
desarrollo: (req.query['desarrollo'] as string) ?? undefined,
|
||||
estatus: (req.query['estatus'] as string) ?? undefined,
|
||||
...(veTodoElDirectorio(req.usuarioToken) ? {} : { usuarioId: req.usuarioToken?.sub }),
|
||||
})
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="contactos.csv"')
|
||||
|
||||
@@ -113,16 +113,15 @@ describe('resumenMiDiaEndpoint (100% local)', () => {
|
||||
expect(JSON.stringify(resumen)).not.toContain('pendienteSync')
|
||||
})
|
||||
|
||||
it('los usuarios del equipo solo se consultan para roles coordinador/admin/corporativo', async () => {
|
||||
await resumenMiDiaEndpoint(reqFake, resFake().res)
|
||||
expect(prismaMock.usuario.findMany).not.toHaveBeenCalled()
|
||||
|
||||
prismaMock.usuario.findUnique.mockResolvedValue({ ...usuarioFake, rol: 'coordinador' })
|
||||
it('los usuarios se consultan SIEMPRE (resuelven el vendedor asignado de cada fila)', async () => {
|
||||
// Aunque el rol no tenga sección de equipo, los nombres son necesarios para
|
||||
// mostrar "vendedor asignado" y el capturista en las tarjetas y la bitácora.
|
||||
await resumenMiDiaEndpoint(reqFake, resFake().res)
|
||||
expect(prismaMock.usuario.findMany).toHaveBeenCalledWith({
|
||||
where: { activo: true },
|
||||
select: { id: true, nombre: true, rol: true, activo: true },
|
||||
})
|
||||
expect(prismaMock.usuario.findUnique).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('un error de BD se propaga (no se traga nada)', async () => {
|
||||
|
||||
@@ -53,15 +53,15 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
||||
tipo: a.tipo,
|
||||
}))
|
||||
|
||||
// Usuarios activos (para la sección equipo, solo coordinador/admin)
|
||||
let usuarios: UsuarioLocal[] = []
|
||||
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
|
||||
const usuariosRaw = await prisma.usuario.findMany({
|
||||
where: { activo: true },
|
||||
select: { id: true, nombre: true, rol: true, activo: true },
|
||||
})
|
||||
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
|
||||
}
|
||||
// Usuarios activos: se cargan SIEMPRE. La sección Equipo sólo se arma para
|
||||
// los roles con equipo, pero el nombre del vendedor asignado y el capturista
|
||||
// de cada fila se resuelven con esta lista (sin ella la bitácora mostraba
|
||||
// "sin asignar" a los propios vendedores).
|
||||
const usuariosRaw = await prisma.usuario.findMany({
|
||||
where: { activo: true },
|
||||
select: { id: true, nombre: true, rol: true, activo: true },
|
||||
})
|
||||
const usuarios: UsuarioLocal[] = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
|
||||
|
||||
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol }
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppError } from '../utils/errors.js'
|
||||
import { Router } from 'express'
|
||||
import { health } from '../controllers/health.controller.js'
|
||||
import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js'
|
||||
@@ -205,14 +206,29 @@ apiRouter.post('/borradores', guardarBorrador)
|
||||
apiRouter.get('/borradores/:id', obtenerBorrador)
|
||||
apiRouter.delete('/borradores/:id', eliminarBorrador)
|
||||
|
||||
apiRouter.post('/planes', crearPlan)
|
||||
apiRouter.get('/planes', listarPlanes)
|
||||
apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy)
|
||||
apiRouter.get('/planes/:id', obtenerPlan)
|
||||
apiRouter.get('/planes/:id/timeline', timelinePlan)
|
||||
apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint)
|
||||
apiRouter.post('/planes/:id/notas', agregarNota)
|
||||
apiRouter.post('/planes/:id/cancelar', cancelarPlan)
|
||||
// Cobranza: sólo roles con el módulo (admin/manager/dirección y los que
|
||||
// gestionan venta). Un vendedor no ve los planes de todo el mundo.
|
||||
/**
|
||||
* Middleware: permite los roles cuyo perfil incluye la cobranza. El vendedor
|
||||
* queda fuera (no ve planes de otros clientes ni puede registrar abonos).
|
||||
*/
|
||||
function requireModuloCobranza(req: import('express').Request, _res: import('express').Response, next: import('express').NextFunction): void {
|
||||
const rol = req.usuarioToken?.rol
|
||||
const permitidos = ['admin', 'manager', 'administracion_venta', 'direccion_general', 'director_ventas', 'escrituracion', 'coordinador', 'corporativo']
|
||||
if (!rol || !permitidos.includes(rol)) {
|
||||
throw new AppError(403, 'Tu rol no tiene acceso a cobranza')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
apiRouter.post('/planes', requireModuloCobranza, crearPlan)
|
||||
apiRouter.get('/planes', requireModuloCobranza, listarPlanes)
|
||||
apiRouter.get('/planes/cobranza-del-dia', requireModuloCobranza, cobranzaHoy)
|
||||
apiRouter.get('/planes/:id', requireModuloCobranza, obtenerPlan)
|
||||
apiRouter.get('/planes/:id/timeline', requireModuloCobranza, timelinePlan)
|
||||
apiRouter.post('/planes/:id/abonos', requireModuloCobranza, registrarAbonoEndpoint)
|
||||
apiRouter.post('/planes/:id/notas', requireModuloCobranza, agregarNota)
|
||||
apiRouter.post('/planes/:id/cancelar', requireModuloCobranza, cancelarPlan)
|
||||
|
||||
apiRouter.get('/cobranza/recordatorios', listarRecordatorios)
|
||||
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)
|
||||
|
||||
@@ -35,6 +35,21 @@ export interface ListaContactos {
|
||||
hayMas: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* ¿El contacto pertenece a este vendedor? Se comprueba directo en la BD
|
||||
* (cliente capturado por él o captura suya), sin depender de la paginación.
|
||||
*/
|
||||
export async function esContactoDelUsuario(contactoId: string, usuarioId: string): Promise<boolean> {
|
||||
const [cliente, captura] = await Promise.all([
|
||||
prisma.cliente.findFirst({ where: { id: contactoId, capturadoPorId: usuarioId }, select: { id: true } }),
|
||||
prisma.leadCaptura.findFirst({
|
||||
where: { OR: [{ leadId: contactoId }, { id: contactoId }], usuarioId },
|
||||
select: { id: true },
|
||||
}),
|
||||
])
|
||||
return cliente !== null || captura !== null
|
||||
}
|
||||
|
||||
export async function listarContactosLocal(usuarioId?: string, pagina = 1): Promise<ListaContactos> {
|
||||
const porPagina = CONTACTOS_POR_PAGINA
|
||||
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
|
||||
@@ -59,11 +74,14 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
||||
const vistos = new Set<string>()
|
||||
const lista: ContactoListaLocal[] = []
|
||||
const agregar = (entrada: ContactoListaLocal): void => {
|
||||
const clave = claveTelefono(entrada.telefono)
|
||||
if (clave !== '') {
|
||||
if (vistos.has(clave)) return
|
||||
vistos.add(clave)
|
||||
}
|
||||
// Clave por teléfono; si no hay, por nombre + email (antes los contactos sin
|
||||
// teléfono se duplicaban siempre)
|
||||
const tel = claveTelefono(entrada.telefono)
|
||||
const clave = tel !== ''
|
||||
? tel
|
||||
: `nombre:${entrada.nombre.trim().toLowerCase()}|${(entrada.email ?? '').trim().toLowerCase()}`
|
||||
if (vistos.has(clave)) return
|
||||
vistos.add(clave)
|
||||
lista.push(entrada)
|
||||
}
|
||||
|
||||
@@ -253,11 +271,18 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
||||
* Genera CSV de todos los contactos (Cliente + LeadCaptura).
|
||||
* Columnas: Nombre, Teléfono, Email, Desarrollo, Estatus, Última actividad.
|
||||
*/
|
||||
export async function exportarCSV(filtros: { desarrollo?: string; estatus?: string } = {}): Promise<string> {
|
||||
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
export async function exportarCSV(
|
||||
filtros: { desarrollo?: string; estatus?: string; usuarioId?: string | undefined } = {},
|
||||
): Promise<string> {
|
||||
// El vendedor sólo exporta SUS contactos (dirección exporta todo)
|
||||
const clientes = await prisma.cliente.findMany({
|
||||
where: filtros.usuarioId ? { capturadoPorId: filtros.usuarioId } : {},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const capturas = await prisma.leadCaptura.findMany({
|
||||
where: {
|
||||
board: { in: ['OPORTUNIDADES', 'APARTADO', 'LEADS'] },
|
||||
...(filtros.usuarioId ? { usuarioId: filtros.usuarioId } : {}),
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
@@ -374,11 +374,16 @@ export class CrmLocalService {
|
||||
nombreLead: oportunidad.nombreLead,
|
||||
telefono: oportunidad.telefono,
|
||||
email: oportunidad.email,
|
||||
origen: oportunidad.origen,
|
||||
desarrollo: oportunidad.desarrollo,
|
||||
modelo: oportunidad.modelo,
|
||||
board: 'APARTADO',
|
||||
etapa: 'Pago Apartado',
|
||||
datosJson: oportunidad.datosJson,
|
||||
// Fecha límite del apartado en la COLUMNA: la leen el KPI "Apartados
|
||||
// por vencer", el cron de avisos y la lista de Mi día (antes sólo se
|
||||
// calculaba en el JSON del alta y la columna quedaba vacía).
|
||||
fechaLimite: fechaLimiteApartado(oportunidad.datosJson),
|
||||
pendienteSync: true,
|
||||
},
|
||||
}),
|
||||
@@ -445,7 +450,24 @@ export class CrmLocalService {
|
||||
}
|
||||
|
||||
/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */
|
||||
export const crmLocal = new CrmLocalService()/** Sólo dígitos de un teléfono (quita +52, espacios, guiones, paréntesis) */
|
||||
export const crmLocal = new CrmLocalService()/** Fecha límite de un apartado: la del JSON si existe, si no hoy + 15 días */
|
||||
function fechaLimiteApartado(datosJson: string | null): Date {
|
||||
try {
|
||||
const datos = datosJson ? (JSON.parse(datosJson) as Record<string, unknown>) : {}
|
||||
const fecha = datos['fechaLimite']
|
||||
if (typeof fecha === 'string' && fecha.trim() !== '') {
|
||||
const d = new Date(fecha.length === 10 ? `${fecha}T00:00:00.000Z` : fecha)
|
||||
if (!Number.isNaN(d.getTime())) return d
|
||||
}
|
||||
} catch {
|
||||
/* JSON corrupto: se calcula por defecto */
|
||||
}
|
||||
const limite = new Date()
|
||||
limite.setDate(limite.getDate() + 15)
|
||||
return limite
|
||||
}
|
||||
|
||||
/** Sólo dígitos de un teléfono (quita +52, espacios, guiones, paréntesis) */
|
||||
function soloDigitos(telefono: string): string {
|
||||
return telefono.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
@@ -72,7 +72,10 @@ describe('resumenMiDiaLocal', () => {
|
||||
expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['loc_o1'])
|
||||
expect(r.misOportunidades.items[0]).toMatchObject({ etapa: 'Afluencia', desarrollo: 'SENDA', modeloInteres: '1R' })
|
||||
expect(r.misApartados.map((a) => a.id)).toEqual(['loc_a1'])
|
||||
expect(r.misCapturas).toHaveLength(4) // loc_l1 y loc_o1 se excluyen (ya en misLeads/misOportunidades)
|
||||
// "Mis capturas" son sólo altas del tablero Leads que no aparezcan ya en
|
||||
// otro tab: loc_l1 ya está en misLeads y loc_l2 (Descartado) es la única
|
||||
// que queda; las filas de OPORTUNIDADES y APARTADO ya no se repiten aquí.
|
||||
expect(r.misCapturas.map((c) => c.leadId)).toEqual(['loc_l2'])
|
||||
// Lo que no existe en local va vacío/cero sin romper el front
|
||||
expect(r.misActividadesHoy).toEqual([])
|
||||
expect(r.apartadosPorVencer).toEqual([])
|
||||
|
||||
@@ -364,7 +364,29 @@ export function resumenMiDiaLocal(
|
||||
pendienteSync: c.pendienteSync,
|
||||
}))
|
||||
|
||||
// "Mis capturas" son sólo las altas del tablero Leads (antes listaba TODAS las
|
||||
// filas del usuario, así que el mismo cliente aparecía 2-3 veces con etapas
|
||||
// de oportunidad/apartado).
|
||||
// Todos los apartados, incluidos los terminales (Escrituración y cancelados)
|
||||
const todosApartadosLocal: ApartadoMiDia[] = apartadosLocales
|
||||
.filter((c) => c.board === 'APARTADO')
|
||||
.map((c) => ({
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
email: c.email ?? '',
|
||||
origen: c.origen ?? '',
|
||||
desarrollo: c.desarrollo ?? '',
|
||||
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||
estado: c.etapa,
|
||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||
ultimoCambio: null,
|
||||
pendienteSync: c.pendienteSync,
|
||||
}))
|
||||
|
||||
const misCapturas: CapturaMiDia[] = capturas
|
||||
.filter((c) => c.board === 'LEADS')
|
||||
.map((c) => ({
|
||||
leadId: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
@@ -375,13 +397,26 @@ export function resumenMiDiaLocal(
|
||||
pendienteSync: c.pendienteSync,
|
||||
}))
|
||||
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
|
||||
// Excluir de "Mis capturas" los que ya aparecen en "Mis leads" u "Mis
|
||||
// oportunidades" para no duplicar visualmente.
|
||||
// Excluir de "Mis capturas" los que ya aparecen en cualquiera de los otros
|
||||
// tabs (por id y por cliente, para no repetir a la misma persona cuando su
|
||||
// lead se convirtió en oportunidad o apartado).
|
||||
const claveCliente = (nombre: string, telefono: string): string =>
|
||||
`${nombre.trim().toLowerCase()}|${(telefono ?? '').replace(/\D/g, '').slice(-10)}`
|
||||
const idsEnOtrosTabsLocal = new Set([
|
||||
...misLeads.map((l) => l.id),
|
||||
...itemsOportunidades.map((o) => o.id),
|
||||
...misApartados.map((a) => a.id),
|
||||
...todosApartadosLocal.map((a) => a.id),
|
||||
])
|
||||
const misCapturasSinDuplicadosLocal = misCapturas.filter((c) => !idsEnOtrosTabsLocal.has(c.leadId))
|
||||
const clavesEnOtrosTabs = new Set([
|
||||
...misLeads.map((l) => claveCliente(l.nombre, l.telefono)),
|
||||
...itemsOportunidades.map((o) => claveCliente(o.nombre, o.telefono)),
|
||||
...misApartados.map((a) => claveCliente(a.nombre, a.telefono)),
|
||||
...todosApartadosLocal.map((a) => claveCliente(a.nombre, a.telefono)),
|
||||
])
|
||||
const misCapturasSinDuplicadosLocal = misCapturas.filter(
|
||||
(c) => !idsEnOtrosTabsLocal.has(c.leadId) && !clavesEnOtrosTabs.has(claveCliente(c.nombre, c.telefono)),
|
||||
)
|
||||
|
||||
const etapaApartadoPorId = new Map(apartadosLocales.map((a) => [a.leadId, a.etapa]))
|
||||
const misClientes: ClienteMiDia[] = clientes
|
||||
@@ -505,22 +540,7 @@ export function resumenMiDiaLocal(
|
||||
misOportunidades: { porEtapa, items: itemsOportunidades },
|
||||
misApartados,
|
||||
// Todos los apartados incluyendo terminales (para Escrituración)
|
||||
todosApartados: apartadosLocales
|
||||
.filter((c) => c.board === 'APARTADO')
|
||||
.map((c) => ({
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
email: c.email ?? '',
|
||||
origen: c.origen ?? '',
|
||||
desarrollo: c.desarrollo ?? '',
|
||||
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||
estado: c.etapa,
|
||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||
ultimoCambio: null,
|
||||
pendienteSync: c.pendienteSync,
|
||||
})),
|
||||
todosApartados: todosApartadosLocal,
|
||||
conteos: {
|
||||
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
|
||||
(c) => c.createdAt.getTime() >= hace7DiasMs,
|
||||
|
||||
Reference in New Issue
Block a user