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 {