From 31f83b05049dcc341ea8338729fcd48418c43449 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Thu, 17 Sep 2026 06:54:57 +0000 Subject: [PATCH] =?UTF-8?q?fix(mi-dia):=20admin=20reasigna/mueve=20leads,?= =?UTF-8?q?=20capturista=20real=20en=20cards,=20datos=20en=20apartados,=20?= =?UTF-8?q?INE=20obligatoria=20y=20bit=C3=A1cora=20persistente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/apartados.controller.test.ts | 4 +- .../src/controllers/apartados.controller.ts | 2 +- .../src/controllers/leads.controller.ts | 17 ++++- api-gateway/src/services/mi-dia-local.test.ts | 12 +++- api-gateway/src/services/mi-dia.service.ts | 21 +++++- .../src/components/DetalleLeadModal.tsx | 2 +- .../src/components/NotasApartado.test.tsx | 4 ++ apps/mi-dia/src/lib/mi-dia.ts | 9 ++- apps/mi-dia/src/pages/MiDiaPage.tsx | 71 +++++++++++++++++++ apps/mi-dia/src/services/exportar.test.ts | 4 ++ apps/mi-dia/src/services/mi-dia.ts | 30 ++++++++ 11 files changed, 162 insertions(+), 14 deletions(-) diff --git a/api-gateway/src/controllers/apartados.controller.test.ts b/api-gateway/src/controllers/apartados.controller.test.ts index ea1f4db..ec9c7f9 100644 --- a/api-gateway/src/controllers/apartados.controller.test.ts +++ b/api-gateway/src/controllers/apartados.controller.test.ts @@ -115,8 +115,8 @@ describe('cambiarEstadoApartadoEndpoint (POST /api/apartados/:id/estado) — loc ]) }) - it('los tipos de documento de la precarga son capturas obligatorias + generales', () => { - expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO']) + it('los tipos de documento de la precarga son capturas obligatorias + INE + generales', () => { + expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'INE', 'OTRO']) }) }) diff --git a/api-gateway/src/controllers/apartados.controller.ts b/api-gateway/src/controllers/apartados.controller.ts index 8c47387..706d62b 100644 --- a/api-gateway/src/controllers/apartados.controller.ts +++ b/api-gateway/src/controllers/apartados.controller.ts @@ -50,7 +50,7 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response) * obligatorios del proceso; OTRO cubre los documentos generales (INE, CURP, * comprobantes, etc.). El tipo viaja en el campo `tipo` del multipart. */ -export const TIPOS_DOCUMENTO_APARTADO = ['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'] as const +export const TIPOS_DOCUMENTO_APARTADO = ['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'INE', 'OTRO'] as const const MIME_PERMITIDOS_APARTADO = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']) const EXT_POR_MIME_APARTADO: Record = { diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index 2206ad6..7eff955 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -6,7 +6,7 @@ import { crmLocal, esIdLocal } from '../services/crm-local.service.js' import * as auditoria from '../services/auditoria.service.js' import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js' import { AppError, NotFoundError } from '../utils/errors.js' -import { recordatorioVendedorService } from '../services/recordatorio-vendedor.service.js' +import { recordatorioVendedorService, calcularClienteClave } from '../services/recordatorio-vendedor.service.js' /** * Captura y acciones del CRM ligero, sobre DOS tableros locales (tabla @@ -163,11 +163,14 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise { expect(r.equipo).toBeUndefined() }) - it('ultimoCambio viene del updatedAt local y el quién es el capturista ("app" si no hay nombre)', () => { + it('ultimoCambio viene del updatedAt local y el quién es el capturista REAL de la fila (no el que ve)', () => { const capturas = [fila({ leadId: 'loc_l1', board: 'LEADS', updatedAt: new Date('2026-09-05T08:00:00') })] - const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA) + // El capturista de la fila (u1) es otro usuario distinto del que consulta + const usuarios = [{ id: 'u1', nombre: 'Martín Zavala', rol: 'vendedor', activo: true }] + const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA, [], usuarios) expect(r.misLeads[0]?.ultimoCambio).toEqual({ fechaISO: '2026-09-05T08:00:00.000Z', - usuario: 'Martin Zavala', + usuario: 'Martín Zavala', detalle: 'Actualización', }) + // Sin la lista de usuarios (o el capturista fuera de ella) → 'app' + const rSinUsuarios = resumenMiDiaLocal(vendedor, capturas, [], AHORA) + expect(rSinUsuarios.misLeads[0]?.ultimoCambio?.usuario).toBe('app') + const rSinNombre = resumenMiDiaLocal(sinNombre, capturas, [], AHORA) expect(rSinNombre.misLeads[0]?.ultimoCambio?.usuario).toBe('app') }) diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 1e26a79..8522326 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -135,6 +135,10 @@ export interface OportunidadMiDia { export interface ApartadoMiDia { id: string nombre: string + telefono: string + email: string + origen: string + desarrollo: string unidadCodigo: string estado: string fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene) @@ -268,6 +272,9 @@ export function resumenMiDiaLocal( ): ResumenMiDia { const hace7DiasMs = ahora.getTime() - 7 * MS_DIA + // Resolución del capturista real de cada fila (no el usuario que ve la pantalla) + const nombrePorUsuarioId = new Map(usuarios.map((u) => [u.id, u.nombre])) + const leadsLocales = capturas.filter((c) => c.board === 'LEADS') const oportunidadesLocales = capturas.filter((c) => c.board === 'OPORTUNIDADES') const apartadosLocales = capturas.filter((c) => c.board === 'APARTADO') @@ -285,7 +292,7 @@ export function resumenMiDiaLocal( desarrollo: c.desarrollo ?? '', updatedAt: c.updatedAt.toISOString(), fuente: 'lead' as const, - ultimoCambio: ultimoCambioLocal(c, usuario.nombre), + ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''), pendienteSync: c.pendienteSync, })) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) @@ -321,7 +328,7 @@ export function resumenMiDiaLocal( modeloInteres: c.modelo ?? '', unidadCodigo, updatedAt: c.updatedAt.toISOString(), - ultimoCambio: ultimoCambioLocal(c, usuario.nombre), + ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''), fechaCita, pendienteSync: c.pendienteSync, } @@ -339,10 +346,14 @@ export function resumenMiDiaLocal( .map((c) => ({ id: c.leadId, nombre: c.nombreLead, + telefono: c.telefono ?? '', + email: c.email ?? '', + origen: c.origen ?? '', + desarrollo: c.desarrollo ?? '', unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '', estado: c.etapa, fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '', - ultimoCambio: ultimoCambioLocal(c, usuario.nombre), + ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''), pendienteSync: c.pendienteSync, })) @@ -492,6 +503,10 @@ export function resumenMiDiaLocal( .map((c) => ({ id: c.leadId, nombre: c.nombreLead, + telefono: c.telefono ?? '', + email: c.email ?? '', + origen: c.origen ?? '', + desarrollo: c.desarrollo ?? '', unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '', estado: c.etapa, fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '', diff --git a/apps/mi-dia/src/components/DetalleLeadModal.tsx b/apps/mi-dia/src/components/DetalleLeadModal.tsx index f62dd81..f06c98f 100644 --- a/apps/mi-dia/src/components/DetalleLeadModal.tsx +++ b/apps/mi-dia/src/components/DetalleLeadModal.tsx @@ -15,7 +15,7 @@ function calcularClienteClave(nombre?: string | null, telefono?: string | null): } function esLead(item: LeadItem): item is LeadMiDia { - return 'email' in item && 'origen' in item && !('modeloInteres' in item) + return 'fuente' in item && item.fuente === 'lead' } function esOportunidad(item: LeadItem): item is OportunidadMiDia { return 'modeloInteres' in item diff --git a/apps/mi-dia/src/components/NotasApartado.test.tsx b/apps/mi-dia/src/components/NotasApartado.test.tsx index 52d4ae5..86cccae 100644 --- a/apps/mi-dia/src/components/NotasApartado.test.tsx +++ b/apps/mi-dia/src/components/NotasApartado.test.tsx @@ -26,6 +26,10 @@ const sesionFake = { token: 'jwt-prueba', usuario: { nombre: 'Test', rol: 'vende const apartadoFake: ApartadoMiDia = { id: 'apt-1', nombre: 'Juan Pérez', + telefono: '6641234567', + email: 'juan@example.com', + origen: 'PASE', + desarrollo: 'DIAGONAL', unidadCodigo: 'D-101', estado: 'En proceso', fechaLimite: '2026-09-15', diff --git a/apps/mi-dia/src/lib/mi-dia.ts b/apps/mi-dia/src/lib/mi-dia.ts index 3bd08e7..a7653a6 100644 --- a/apps/mi-dia/src/lib/mi-dia.ts +++ b/apps/mi-dia/src/lib/mi-dia.ts @@ -72,6 +72,10 @@ export interface OportunidadMiDia { export interface ApartadoMiDia { id: string nombre: string + telefono: string + email: string + origen: string + desarrollo: string unidadCodigo: string estado: string fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene) @@ -236,11 +240,12 @@ export const ETIQUETA_FASE_OPORTUNIDAD: Record = { } // Slots de la precarga de documentos en "Mis apartados"; los obligatorios son -// los que pide el proceso de venta (captura del cotizador y de pago apartado) +// los que pide el proceso de venta (INE, captura del cotizador y de pago apartado) export const SLOTS_DOCS_APARTADO = [ { tipo: 'CAPTURA_COTIZADOR', etiqueta: 'Captura del cotizador', obligatorio: true }, { tipo: 'CAPTURA_PAGO_APARTADO', etiqueta: 'Captura de pago apartado', obligatorio: true }, - { tipo: 'OTRO', etiqueta: 'Documentos generales (INE, CURP, comprobantes…)', obligatorio: false }, + { tipo: 'INE', etiqueta: 'INE (identificación oficial)', obligatorio: true }, + { tipo: 'OTRO', etiqueta: 'Documentos generales (CURP, comprobantes…)', obligatorio: false }, ] as const export type SlotDocApartado = (typeof SLOTS_DOCS_APARTADO)[number]['tipo'] diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 246efda..26edc4c 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -45,6 +45,8 @@ import { fetchArchivoDocumentoApartado, fetchResumenMiDia, listarDocumentosApartado, + moverLead as moverLeadApi, + reasignarLead as reasignarLeadApi, subirDocumentoApartado, type DocumentoApartado, } from '../services/mi-dia' @@ -378,6 +380,12 @@ function FilaApartado({ {apartado.unidadCodigo} ) : null}

+

+ + {apartado.desarrollo ? ` · ${apartado.desarrollo}` : ''} + {apartado.origen ? ` · ${apartado.origen}` : ''} + {apartado.email ? ` · ${apartado.email}` : ''} +

{apartado.fechaLimite !== '' ? `Fecha límite: ${formatoFechaCorta(apartado.fechaLimite)}` : 'Sin fecha límite'}

@@ -523,6 +531,15 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {

Expediente del cliente

+ {documentos !== null && + SLOTS_DOCS_APARTADO.filter((s) => s.obligatorio && !(documentos ?? []).some((d) => d.tipo === s.tipo)).length > 0 && ( +

+ Documentos obligatorios pendientes:{' '} + {SLOTS_DOCS_APARTADO.filter((s) => s.obligatorio && !(documentos ?? []).some((d) => d.tipo === s.tipo)) + .map((s) => s.etiqueta) + .join(', ')} +

+ )} {error && (

{error} @@ -675,6 +692,10 @@ function FilaLead({ onVerDetalle, enviando, acciones, + esAdmin, + vendedores, + onReasignar, + onMover, }: { lead: LeadMiDia onAccion: (estado: string, fecha?: string) => void @@ -683,6 +704,11 @@ function FilaLead({ onVerDetalle?: () => void enviando: boolean acciones?: string[] + /** Solo admin: reasignar a cualquier vendedor y mover entre tableros */ + esAdmin?: boolean + vendedores?: { id: string; nombre: string }[] + onReasignar?: (usuarioId: string) => void + onMover?: () => void }) { return (

  • @@ -752,6 +778,30 @@ function FilaLead({ Descartar + {/* Admin: reasignar a cualquier vendedor y mover entre tableros */} + {esAdmin && ( +
    + Admin + + +
    + )}
  • ) } @@ -994,6 +1044,23 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones window.open(urlCotizadorParaOportunidad(op), '_blank') } + /** Reasignar lead a otro vendedor (solo admin) */ + async function reasignar(lead: LeadMiDia, usuarioId: string) { + await ejecutar(async () => { + const nombre = await reasignarLeadApi(lead.id, usuarioId) + toast.success(`Lead reasignado a ${nombre || 'vendedor'}`) + }, 'No se pudo reasignar el lead') + } + + /** Mover lead entre tableros Leads ↔ Oportunidades (solo admin) */ + async function mover(lead: LeadMiDia) { + const destino = lead.fuente === 'lead' ? 'OPORTUNIDADES' : 'LEADS' + await ejecutar(async () => { + await moverLeadApi(lead.id, destino) + toast.success(destino === 'OPORTUNIDADES' ? 'Lead movido a Oportunidades' : 'Lead movido a Leads') + }, 'No se pudo mover el lead') + } + async function enviarCaptura(e: FormEvent) { e.preventDefault() setCapEnviando(true) @@ -1246,6 +1313,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones onDescartar={() => setDescartando(lead)} onVerDetalle={() => setLeadSeleccionado(lead)} acciones={acciones} + esAdmin={rol === 'admin'} + vendedores={resumen.equipo?.porVendedor.map((v) => ({ id: v.usuarioId, nombre: v.nombre ?? '' }))} + onReasignar={(usuarioId) => void reasignar(lead, usuarioId)} + onMover={() => void mover(lead)} /> ))} diff --git a/apps/mi-dia/src/services/exportar.test.ts b/apps/mi-dia/src/services/exportar.test.ts index eafbd79..1119d2b 100644 --- a/apps/mi-dia/src/services/exportar.test.ts +++ b/apps/mi-dia/src/services/exportar.test.ts @@ -54,6 +54,10 @@ const opFake: OportunidadMiDia = { const apartadoFake: ApartadoMiDia = { id: '3', nombre: 'Carlos Ruiz', + telefono: '6641234567', + email: 'carlos@example.com', + origen: 'REFERIDO', + desarrollo: 'ESTANZA', unidadCodigo: 'E-202', estado: 'En proceso', fechaLimite: '2026-09-15', diff --git a/apps/mi-dia/src/services/mi-dia.ts b/apps/mi-dia/src/services/mi-dia.ts index d3dad47..7c6fcc1 100644 --- a/apps/mi-dia/src/services/mi-dia.ts +++ b/apps/mi-dia/src/services/mi-dia.ts @@ -107,6 +107,36 @@ export function cambiarEstadoApartado( }) } +/** POST /leads/:itemId/mover — mover lead a otro tablero (solo admin) */ +export async function moverLead(itemId: string, board: 'LEADS' | 'OPORTUNIDADES'): Promise { + const res = await fetch(`${API_URL}/leads/${itemId}/mover`, conAuth({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ board }), + })) + manejar401(res) + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null + throw new Error(body?.error ?? `Error ${res.status}`) + } +} + +/** PATCH /leads/:itemId/asignar — reasignar el lead a otro vendedor (solo admin) */ +export async function reasignarLead(itemId: string, usuarioId: string): Promise { + const res = await fetch(`${API_URL}/leads/${itemId}/asignar`, conAuth({ + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ usuarioId }), + })) + manejar401(res) + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null + throw new Error(body?.error ?? `Error ${res.status}`) + } + const data = (await res.json()) as { nombre?: string } + return data.nombre ?? '' +} + // ---------- Precarga de documentos del expediente (Mis apartados) ---------- export interface DocumentoApartado {