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:
2026-09-21 07:18:39 +00:00
parent 75956a709f
commit 5e9b2ac456
16 changed files with 259 additions and 71 deletions

View File

@@ -249,13 +249,12 @@ async function escribir(planesLeidos) {
p.montoPagado = Math.min(p.monto, p.montoPagado + ap.monto) p.montoPagado = Math.min(p.monto, p.montoPagado + ap.monto)
} }
// Si el maestro registra pagos por encima del plan declarado (filas con // Invariante del módulo: Σ montos de TODOS los pagos del plan == engancheTotal
// MESES/MENSUALIDAD vacíos pero grid lleno), el total del plan es lo pagado. // (incluidos los standalone, que antes quedaban fuera y generaban saldo
const totalPagadoAcumulado = pagosProg.reduce((a, p) => a + p.montoPagado, 0) + // 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) standalone.reduce((a, s) => a + s.monto, 0)
if (totalPagadoAcumulado > plan.engancheTotal) { plan.engancheTotal = Math.round(totalProgramado * 100) / 100
plan.engancheTotal = Math.round(totalPagadoAcumulado * 100) / 100
}
const creado = await prisma.planPago.create({ data: plan }) const creado = await prisma.planPago.create({ data: plan })

View File

@@ -1,10 +1,31 @@
import type { NextFunction, Request, Response } from 'express' import type { NextFunction, Request, Response } from 'express'
import { z } from 'zod' import { z } from 'zod'
import * as capturaService from '../services/captura-departamento.service.js' 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 { AppError, BadRequestError } from '../utils/errors.js'
import { esAdminOManager } from '../utils/roles.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 ───────────────────────────────────────────────── // ── Middleware admin ─────────────────────────────────────────────────
function requireAdmin(req: Request, _res: Response, next: NextFunction): void { 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])) { if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
throw new BadRequestError(`Departamento inválido: ${body.departamento}`) throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
} }
validarCamposDelDepartamento(body.departamento, [{ unidad: body.unidadCodigo, campo: body.campo, valor: body.valor ?? null }])
await capturaService.guardarCampo( await capturaService.guardarCampo(
body.desarrollo, 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])) { if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
throw new BadRequestError(`Departamento inválido: ${body.departamento}`) 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( await capturaService.guardarBulk(
body.desarrollo, body.desarrollo,

View File

@@ -1,7 +1,26 @@
import type { Request, Response } from 'express' 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 { perfilCompleto } from '../services/contactos-detalle.service.js'
import { NotFoundError } from '../utils/errors.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: * 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> { export async function listarContactosEndpoint(req: Request, res: Response): Promise<void> {
const paginaParam = Number(req.query['pagina']) const paginaParam = Number(req.query['pagina'])
const pagina = Number.isFinite(paginaParam) && paginaParam >= 1 ? Math.floor(paginaParam) : 1 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). * cotizaciones locales).
*/ */
export async function detalleContactoEndpoint(req: Request, res: Response): Promise<void> { 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') if (detalle === null) throw new NotFoundError('Contacto no encontrado')
res.json(detalle) 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> { export async function perfilCompletoEndpoint(req: Request, res: Response): Promise<void> {
const contactoId = String(req.params['id']) 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) const perfil = await perfilCompleto(contactoId)
if (perfil === null) throw new NotFoundError('Contacto no encontrado') if (perfil === null) throw new NotFoundError('Contacto no encontrado')
res.json(perfil) res.json(perfil)
@@ -47,6 +78,7 @@ export async function exportarContactosEndpoint(req: Request, res: Response): Pr
const csv = await exportarCSV({ const csv = await exportarCSV({
desarrollo: (req.query['desarrollo'] as string) ?? undefined, desarrollo: (req.query['desarrollo'] as string) ?? undefined,
estatus: (req.query['estatus'] 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-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="contactos.csv"') res.setHeader('Content-Disposition', 'attachment; filename="contactos.csv"')

View File

@@ -113,16 +113,15 @@ describe('resumenMiDiaEndpoint (100% local)', () => {
expect(JSON.stringify(resumen)).not.toContain('pendienteSync') expect(JSON.stringify(resumen)).not.toContain('pendienteSync')
}) })
it('los usuarios del equipo solo se consultan para roles coordinador/admin/corporativo', async () => { it('los usuarios se consultan SIEMPRE (resuelven el vendedor asignado de cada fila)', async () => {
await resumenMiDiaEndpoint(reqFake, resFake().res) // Aunque el rol no tenga sección de equipo, los nombres son necesarios para
expect(prismaMock.usuario.findMany).not.toHaveBeenCalled() // mostrar "vendedor asignado" y el capturista en las tarjetas y la bitácora.
prismaMock.usuario.findUnique.mockResolvedValue({ ...usuarioFake, rol: 'coordinador' })
await resumenMiDiaEndpoint(reqFake, resFake().res) await resumenMiDiaEndpoint(reqFake, resFake().res)
expect(prismaMock.usuario.findMany).toHaveBeenCalledWith({ expect(prismaMock.usuario.findMany).toHaveBeenCalledWith({
where: { activo: true }, where: { activo: true },
select: { id: true, nombre: true, rol: true, 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 () => { it('un error de BD se propaga (no se traga nada)', async () => {

View File

@@ -53,15 +53,15 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
tipo: a.tipo, tipo: a.tipo,
})) }))
// Usuarios activos (para la sección equipo, solo coordinador/admin) // Usuarios activos: se cargan SIEMPRE. La sección Equipo sólo se arma para
let usuarios: UsuarioLocal[] = [] // los roles con equipo, pero el nombre del vendedor asignado y el capturista
if (ROLES_CON_EQUIPO.includes(usuario.rol)) { // de cada fila se resuelven con esta lista (sin ella la bitácora mostraba
const usuariosRaw = await prisma.usuario.findMany({ // "sin asignar" a los propios vendedores).
where: { activo: true }, const usuariosRaw = await prisma.usuario.findMany({
select: { id: true, nombre: true, rol: true, activo: true }, 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 })) })
} 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 } const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol }

View File

@@ -1,3 +1,4 @@
import { AppError } from '../utils/errors.js'
import { Router } from 'express' import { Router } from 'express'
import { health } from '../controllers/health.controller.js' import { health } from '../controllers/health.controller.js'
import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js' import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js'
@@ -205,14 +206,29 @@ apiRouter.post('/borradores', guardarBorrador)
apiRouter.get('/borradores/:id', obtenerBorrador) apiRouter.get('/borradores/:id', obtenerBorrador)
apiRouter.delete('/borradores/:id', eliminarBorrador) apiRouter.delete('/borradores/:id', eliminarBorrador)
apiRouter.post('/planes', crearPlan) // Cobranza: sólo roles con el módulo (admin/manager/dirección y los que
apiRouter.get('/planes', listarPlanes) // gestionan venta). Un vendedor no ve los planes de todo el mundo.
apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy) /**
apiRouter.get('/planes/:id', obtenerPlan) * Middleware: permite los roles cuyo perfil incluye la cobranza. El vendedor
apiRouter.get('/planes/:id/timeline', timelinePlan) * queda fuera (no ve planes de otros clientes ni puede registrar abonos).
apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint) */
apiRouter.post('/planes/:id/notas', agregarNota) function requireModuloCobranza(req: import('express').Request, _res: import('express').Response, next: import('express').NextFunction): void {
apiRouter.post('/planes/:id/cancelar', cancelarPlan) 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.get('/cobranza/recordatorios', listarRecordatorios)
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios) apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)

View File

@@ -35,6 +35,21 @@ export interface ListaContactos {
hayMas: boolean 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> { export async function listarContactosLocal(usuarioId?: string, pagina = 1): Promise<ListaContactos> {
const porPagina = CONTACTOS_POR_PAGINA const porPagina = CONTACTOS_POR_PAGINA
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1 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 vistos = new Set<string>()
const lista: ContactoListaLocal[] = [] const lista: ContactoListaLocal[] = []
const agregar = (entrada: ContactoListaLocal): void => { const agregar = (entrada: ContactoListaLocal): void => {
const clave = claveTelefono(entrada.telefono) // Clave por teléfono; si no hay, por nombre + email (antes los contactos sin
if (clave !== '') { // teléfono se duplicaban siempre)
if (vistos.has(clave)) return const tel = claveTelefono(entrada.telefono)
vistos.add(clave) const clave = tel !== ''
} ? tel
: `nombre:${entrada.nombre.trim().toLowerCase()}|${(entrada.email ?? '').trim().toLowerCase()}`
if (vistos.has(clave)) return
vistos.add(clave)
lista.push(entrada) lista.push(entrada)
} }
@@ -253,11 +271,18 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
* Genera CSV de todos los contactos (Cliente + LeadCaptura). * Genera CSV de todos los contactos (Cliente + LeadCaptura).
* Columnas: Nombre, Teléfono, Email, Desarrollo, Estatus, Última actividad. * Columnas: Nombre, Teléfono, Email, Desarrollo, Estatus, Última actividad.
*/ */
export async function exportarCSV(filtros: { desarrollo?: string; estatus?: string } = {}): Promise<string> { export async function exportarCSV(
const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' } }) 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({ const capturas = await prisma.leadCaptura.findMany({
where: { where: {
board: { in: ['OPORTUNIDADES', 'APARTADO', 'LEADS'] }, board: { in: ['OPORTUNIDADES', 'APARTADO', 'LEADS'] },
...(filtros.usuarioId ? { usuarioId: filtros.usuarioId } : {}),
}, },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
}) })

View File

@@ -374,11 +374,16 @@ export class CrmLocalService {
nombreLead: oportunidad.nombreLead, nombreLead: oportunidad.nombreLead,
telefono: oportunidad.telefono, telefono: oportunidad.telefono,
email: oportunidad.email, email: oportunidad.email,
origen: oportunidad.origen,
desarrollo: oportunidad.desarrollo, desarrollo: oportunidad.desarrollo,
modelo: oportunidad.modelo, modelo: oportunidad.modelo,
board: 'APARTADO', board: 'APARTADO',
etapa: 'Pago Apartado', etapa: 'Pago Apartado',
datosJson: oportunidad.datosJson, 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, pendienteSync: true,
}, },
}), }),
@@ -445,7 +450,24 @@ export class CrmLocalService {
} }
/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */ /** 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 { function soloDigitos(telefono: string): string {
return telefono.replace(/\D/g, '') return telefono.replace(/\D/g, '')
} }

View File

@@ -72,7 +72,10 @@ describe('resumenMiDiaLocal', () => {
expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['loc_o1']) 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.misOportunidades.items[0]).toMatchObject({ etapa: 'Afluencia', desarrollo: 'SENDA', modeloInteres: '1R' })
expect(r.misApartados.map((a) => a.id)).toEqual(['loc_a1']) 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 // Lo que no existe en local va vacío/cero sin romper el front
expect(r.misActividadesHoy).toEqual([]) expect(r.misActividadesHoy).toEqual([])
expect(r.apartadosPorVencer).toEqual([]) expect(r.apartadosPorVencer).toEqual([])

View File

@@ -364,7 +364,29 @@ export function resumenMiDiaLocal(
pendienteSync: c.pendienteSync, 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 const misCapturas: CapturaMiDia[] = capturas
.filter((c) => c.board === 'LEADS')
.map((c) => ({ .map((c) => ({
leadId: c.leadId, leadId: c.leadId,
nombre: c.nombreLead, nombre: c.nombreLead,
@@ -375,13 +397,26 @@ export function resumenMiDiaLocal(
pendienteSync: c.pendienteSync, pendienteSync: c.pendienteSync,
})) }))
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn)) .sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
// Excluir de "Mis capturas" los que ya aparecen en "Mis leads" u "Mis // Excluir de "Mis capturas" los que ya aparecen en cualquiera de los otros
// oportunidades" para no duplicar visualmente. // 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([ const idsEnOtrosTabsLocal = new Set([
...misLeads.map((l) => l.id), ...misLeads.map((l) => l.id),
...itemsOportunidades.map((o) => o.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 etapaApartadoPorId = new Map(apartadosLocales.map((a) => [a.leadId, a.etapa]))
const misClientes: ClienteMiDia[] = clientes const misClientes: ClienteMiDia[] = clientes
@@ -505,22 +540,7 @@ export function resumenMiDiaLocal(
misOportunidades: { porEtapa, items: itemsOportunidades }, misOportunidades: { porEtapa, items: itemsOportunidades },
misApartados, misApartados,
// Todos los apartados incluyendo terminales (para Escrituración) // Todos los apartados incluyendo terminales (para Escrituración)
todosApartados: apartadosLocales todosApartados: todosApartadosLocal,
.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,
})),
conteos: { conteos: {
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter( leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
(c) => c.createdAt.getTime() >= hace7DiasMs, (c) => c.createdAt.getTime() >= hace7DiasMs,

View File

@@ -18,9 +18,17 @@ export function formatoFechaLarga(fecha: Date): string {
return fecha.toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' }) return fecha.toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' })
} }
/** ISO 'YYYY-MM-DD' a fecha legible corta: "08/10/2027" */ /**
export function formatoFechaCorta(iso: string): string { * Fecha ISO ('YYYY-MM-DD' o ISO completo con hora, como lo manda el API) a
return new Date(`${iso}T00:00:00`).toLocaleDateString('es-MX', { * fecha legible corta: "08/10/2027". Antes se concatenaba 'T00:00:00' a la
* cadena y con las fechas con hora salía "Invalid Date" en los PDFs.
*/
export function formatoFechaCorta(iso: string | null | undefined): string {
if (!iso) return '—'
const soloFecha = String(iso).slice(0, 10)
const fecha = new Date(`${soloFecha}T00:00:00`)
if (Number.isNaN(fecha.getTime())) return '—'
return fecha.toLocaleDateString('es-MX', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric', year: 'numeric',

View File

@@ -35,7 +35,7 @@ describe('getUnidades', () => {
const resultado = await getUnidades() const resultado = await getUnidades()
expect(fetchMock).toHaveBeenCalledWith('http://192.168.10.123:3000/api/unidades', {}) expect(fetchMock).toHaveBeenCalledWith('/api/unidades', {})
expect(resultado.usandoSeed).toBe(false) expect(resultado.usandoSeed).toBe(false)
expect(resultado.unidades).toEqual([unidadApi]) expect(resultado.unidades).toEqual([unidadApi])
}) })

View File

@@ -91,9 +91,16 @@ export interface GuardarCotizacionPayload {
oportunidadId?: string oportunidadId?: string
} }
/** URL pública de una cotización (destino del QR del PDF) */ /**
* URL pública de una cotización (destino del QR del PDF). Debe ser ABSOLUTA:
* un QR con ruta relativa ("/api/...") no es navegable al escanearlo desde el
* teléfono del cliente. Se arma con el origen actual cuando API_URL es relativa.
*/
export function urlPublicaCotizacion(id: string): string { export function urlPublicaCotizacion(id: string): string {
return `${API_URL}/cotizaciones/${id}/publico` const base = API_URL.startsWith('http')
? API_URL
: `${typeof window !== 'undefined' ? window.location.origin : ''}${API_URL}`
return `${base}/cotizaciones/${id}/publico`
} }
/** POST /cotizaciones — persiste y liga la oportunidad del cliente */ /** POST /cotizaciones — persiste y liga la oportunidad del cliente */

View File

@@ -1,4 +1,4 @@
import { Fragment, useMemo, useState, useCallback } from 'react' import { Fragment, useEffect, useMemo, useState, useCallback } from 'react'
import type { CampoDef, DatosCaptura } from '../services/captura' import type { CampoDef, DatosCaptura } from '../services/captura'
// ─── Tipos ────────────────────────────────────────────────────────── // ─── Tipos ──────────────────────────────────────────────────────────
@@ -185,6 +185,14 @@ export function TablaCaptura({
const [dirty, setDirty] = useState<DirtyMap>({}) const [dirty, setDirty] = useState<DirtyMap>({})
const [pagina, setPagina] = useState(0) const [pagina, setPagina] = useState(0)
// Al cambiar de departamento o de desarrollo los campos son otros: los cambios
// pendientes de la vista anterior se descartan (antes se guardaban en el
// departamento equivocado) y la paginación vuelve al inicio.
useEffect(() => {
setDirty({})
setPagina(0)
}, [campos])
/** Obtener estatus de una unidad (campo estatus en dept VENTAS, siempre disponible) */ /** Obtener estatus de una unidad (campo estatus en dept VENTAS, siempre disponible) */
const estatusUnidad = useCallback( const estatusUnidad = useCallback(
(unidad: string): string => { (unidad: string): string => {
@@ -252,12 +260,20 @@ export function TablaCaptura({
[datos], [datos],
) )
/** Enviar cambios al padre */ /**
* Enviar cambios al padre. NO se limpian aquí: el padre recarga los datos al
* guardar con éxito y el efecto de abajo limpia los pendientes. Si el guardado
* falla, lo capturado sigue en pantalla para reintentar.
*/
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
onGuardar(dirty) onGuardar(dirty)
setDirty({})
}, [dirty, onGuardar]) }, [dirty, onGuardar])
// Los datos guardados llegaron (recarga tras guardar): ya no hay pendientes
useEffect(() => {
setDirty({})
}, [datos])
if (unidades.length === 0) { if (unidades.length === 0) {
return ( return (
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]"> <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">

View File

@@ -79,7 +79,6 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
</div> </div>
{item.telefono && <p className="mt-1 text-xs text-[#94A3B8]">{item.telefono}</p>} {item.telefono && <p className="mt-1 text-xs text-[#94A3B8]">{item.telefono}</p>}
{item.unidadCodigo && <p className="mt-0.5 text-xs text-[#94A3B8]">{item.unidadCodigo}</p>} {item.unidadCodigo && <p className="mt-0.5 text-xs text-[#94A3B8]">{item.unidadCodigo}</p>}
{item.pendienteSync && <span className="mt-1 inline-block rounded-full bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">⏳ sync</span>}
</> </>
)} )}
</div> </div>

View File

@@ -1038,8 +1038,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
setAviso(null) setAviso(null)
try { try {
await cambiarEtapaOportunidad(op.id, 'Cotizador') await cambiarEtapaOportunidad(op.id, 'Cotizador')
await cargar() // refleja la etapa nueva sin recargar la página
} catch { } catch {
toast.error('No se pudo actualizar la etapa; el cotizador se abrirá de todos modos.') toast.error('No se pudo actualizar la etapa; el cotizador se abrirá de todos modos.')
} finally {
setEnviando(false) // sin esto la página quedaba bloqueada hasta recargar
} }
window.open(urlCotizadorParaOportunidad(op), '_blank') window.open(urlCotizadorParaOportunidad(op), '_blank')
} }
@@ -1128,7 +1131,20 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
const enEscrituracion = resumen const enEscrituracion = resumen
? (resumen.todosApartados || resumen.misApartados).filter((a) => { ? (resumen.todosApartados || resumen.misApartados).filter((a) => {
const e = (a.estado ?? '').toUpperCase() const e = (a.estado ?? '').toUpperCase()
return e.includes('FIRMA') || e.includes('VENTA') || e.includes('SEGUIMIENTO') || e.includes('INTEGRACION') || e.includes('X AUTORIZAR') || e.includes('AUTORIZADO') || e.includes('X ESCRITURAR') return (
e.includes('FIRMA') ||
e.includes('VENTA') ||
e.includes('SEGUIMIENTO') ||
e.includes('INTEGRACION') ||
e.includes('X AUTORIZAR') ||
e.includes('AUTORIZADO') ||
e.includes('X ESCRITURAR') ||
// Estados finales: sin ellos el registro desaparecía de Mi Día al
// marcarlo como Escriturado o Titulado
e.includes('ESCRITURADO') ||
e.includes('TITULADO') ||
e.includes('CANCO')
)
}) })
: [] : []
const escrituracionFiltrados = aplicarFiltros(enEscrituracion, filtrosPorTab.escrituracion, { const escrituracionFiltrados = aplicarFiltros(enEscrituracion, filtrosPorTab.escrituracion, {