diff --git a/api-gateway/scripts/importar-cobranza-maestro.js b/api-gateway/scripts/importar-cobranza-maestro.js index 2c8e129..beecf99 100644 --- a/api-gateway/scripts/importar-cobranza-maestro.js +++ b/api-gateway/scripts/importar-cobranza-maestro.js @@ -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 }) diff --git a/api-gateway/src/controllers/captura-departamento.controller.ts b/api-gateway/src/controllers/captura-departamento.controller.ts index 57a6e16..ddcd191 100644 --- a/api-gateway/src/controllers/captura-departamento.controller.ts +++ b/api-gateway/src/controllers/captura-departamento.controller.ts @@ -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 { 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, diff --git a/api-gateway/src/controllers/contactos.controller.ts b/api-gateway/src/controllers/contactos.controller.ts index 7e2d9aa..9ae4be1 100644 --- a/api-gateway/src/controllers/contactos.controller.ts +++ b/api-gateway/src/controllers/contactos.controller.ts @@ -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 { 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 { - 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 { 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"') diff --git a/api-gateway/src/controllers/mi-dia.controller.test.ts b/api-gateway/src/controllers/mi-dia.controller.test.ts index 6be974f..a92e517 100644 --- a/api-gateway/src/controllers/mi-dia.controller.test.ts +++ b/api-gateway/src/controllers/mi-dia.controller.test.ts @@ -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 () => { diff --git a/api-gateway/src/controllers/mi-dia.controller.ts b/api-gateway/src/controllers/mi-dia.controller.ts index 8a80ab8..6676ec7 100644 --- a/api-gateway/src/controllers/mi-dia.controller.ts +++ b/api-gateway/src/controllers/mi-dia.controller.ts @@ -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 } diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index f795781..58bca6e 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -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) diff --git a/api-gateway/src/services/contactos-local.service.ts b/api-gateway/src/services/contactos-local.service.ts index 58b860a..86284cf 100644 --- a/api-gateway/src/services/contactos-local.service.ts +++ b/api-gateway/src/services/contactos-local.service.ts @@ -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 { + 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 { 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() 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 { - const clientes = await prisma.cliente.findMany({ orderBy: { createdAt: 'desc' } }) +export async function exportarCSV( + filtros: { desarrollo?: string; estatus?: string; usuarioId?: string | undefined } = {}, +): Promise { + // 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' }, }) diff --git a/api-gateway/src/services/crm-local.service.ts b/api-gateway/src/services/crm-local.service.ts index efe01a2..61ec96c 100644 --- a/api-gateway/src/services/crm-local.service.ts +++ b/api-gateway/src/services/crm-local.service.ts @@ -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) : {} + 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, '') } diff --git a/api-gateway/src/services/mi-dia-local.test.ts b/api-gateway/src/services/mi-dia-local.test.ts index b785184..39108b9 100644 --- a/api-gateway/src/services/mi-dia-local.test.ts +++ b/api-gateway/src/services/mi-dia-local.test.ts @@ -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([]) diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index a45867d..9fee6bb 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -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, diff --git a/apps/cobranza/src/lib/formatters.ts b/apps/cobranza/src/lib/formatters.ts index 07c725a..1199055 100644 --- a/apps/cobranza/src/lib/formatters.ts +++ b/apps/cobranza/src/lib/formatters.ts @@ -18,9 +18,17 @@ export function formatoFechaLarga(fecha: Date): string { 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 { - return new Date(`${iso}T00:00:00`).toLocaleDateString('es-MX', { +/** + * Fecha ISO ('YYYY-MM-DD' o ISO completo con hora, como lo manda el API) a + * 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', month: '2-digit', year: 'numeric', diff --git a/apps/cotizador/src/services/api.test.ts b/apps/cotizador/src/services/api.test.ts index d230b72..8ec6c02 100644 --- a/apps/cotizador/src/services/api.test.ts +++ b/apps/cotizador/src/services/api.test.ts @@ -35,7 +35,7 @@ describe('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.unidades).toEqual([unidadApi]) }) diff --git a/apps/cotizador/src/services/api.ts b/apps/cotizador/src/services/api.ts index 46039d5..8127e23 100644 --- a/apps/cotizador/src/services/api.ts +++ b/apps/cotizador/src/services/api.ts @@ -91,9 +91,16 @@ export interface GuardarCotizacionPayload { 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 { - 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 */ diff --git a/apps/landing/src/components/TablaCaptura.tsx b/apps/landing/src/components/TablaCaptura.tsx index 50b64dc..e80bfd6 100644 --- a/apps/landing/src/components/TablaCaptura.tsx +++ b/apps/landing/src/components/TablaCaptura.tsx @@ -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' // ─── Tipos ────────────────────────────────────────────────────────── @@ -185,6 +185,14 @@ export function TablaCaptura({ const [dirty, setDirty] = useState({}) 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) */ const estatusUnidad = useCallback( (unidad: string): string => { @@ -252,12 +260,20 @@ export function TablaCaptura({ [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(() => { onGuardar(dirty) - setDirty({}) }, [dirty, onGuardar]) + // Los datos guardados llegaron (recarga tras guardar): ya no hay pendientes + useEffect(() => { + setDirty({}) + }, [datos]) + if (unidades.length === 0) { return (

diff --git a/apps/mi-dia/src/components/KanbanView.tsx b/apps/mi-dia/src/components/KanbanView.tsx index 777e610..7e6759f 100644 --- a/apps/mi-dia/src/components/KanbanView.tsx +++ b/apps/mi-dia/src/components/KanbanView.tsx @@ -79,7 +79,6 @@ export default function KanbanView {item.telefono &&

{item.telefono}

} {item.unidadCodigo &&

{item.unidadCodigo}

} - {item.pendienteSync && ⏳ sync} )} diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 4d241d7..635726f 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -1038,8 +1038,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones setAviso(null) try { await cambiarEtapaOportunidad(op.id, 'Cotizador') + await cargar() // refleja la etapa nueva sin recargar la página } catch { 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') } @@ -1128,7 +1131,20 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones const enEscrituracion = resumen ? (resumen.todosApartados || resumen.misApartados).filter((a) => { 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, {