diff --git a/api-gateway/src/controllers/apartados.controller.test.ts b/api-gateway/src/controllers/apartados.controller.test.ts
new file mode 100644
index 0000000..5a66f32
--- /dev/null
+++ b/api-gateway/src/controllers/apartados.controller.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import {
+ cambiarEstadoApartado,
+ FASES_APARTADO_PERMITIDAS,
+} from './apartados.controller.js'
+
+/** Monday mockeado: registra cambios de columna y movimientos de grupo */
+function mondayMock(grupoActual = 'topics') {
+ return {
+ cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
+ movimientos: [] as { itemId: string; grupo: string }[],
+ async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
+ this.cambios.push({ boardId, itemId, columna, valor })
+ },
+ async columnaIdPorTitulo(_boardId: string, titulo: string) {
+ return titulo === 'Estado' ? 'status' : null
+ },
+ async getItemConGrupo(itemId: string) {
+ return {
+ id: itemId,
+ name: 'Cliente Demo',
+ groupId: grupoActual,
+ column_values: [
+ { id: 'status', text: (this.cambios.find((c) => c.columna === 'Estado')?.valor as string) ?? '', value: '{}' },
+ ],
+ }
+ },
+ async moverItemAGrupo(itemId: string, grupo: string) {
+ this.movimientos.push({ itemId, grupo })
+ },
+ }
+}
+
+describe('cambiarEstadoApartado (POST /api/apartados/:id/estado)', () => {
+ it('cambia la fase (board 666 = MONDAY_BOARD_APARTADO de test)', async () => {
+ const monday = mondayMock()
+ await cambiarEstadoApartado(monday, 'apt-1', 'Alta Enkontrol')
+ expect(monday.cambios).toEqual([
+ { boardId: '666', itemId: 'apt-1', columna: 'Estado', valor: 'Alta Enkontrol' },
+ ])
+ // 'Alta Enkontrol' mapea al grupo topics: ya estaba ahí → no se mueve
+ expect(monday.movimientos).toEqual([])
+ })
+
+ it('Firma Cliente (Venta) mueve el item a "Clientes Activos"', async () => {
+ const monday = mondayMock()
+ await cambiarEstadoApartado(monday, 'apt-2', 'Firma Cliente (Venta)')
+ expect(monday.movimientos).toEqual([{ itemId: 'apt-2', grupo: 'group_mm6sx3rn' }])
+ })
+
+ it('Cancelacion Apartado mueve el item a "Historico"', async () => {
+ const monday = mondayMock()
+ await cambiarEstadoApartado(monday, 'apt-3', 'Cancelacion Apartado')
+ expect(monday.movimientos).toEqual([{ itemId: 'apt-3', grupo: 'group_mm6s2yyx' }])
+ })
+
+ it('las fases permitidas son los labels reales del board Apartado', () => {
+ expect(FASES_APARTADO_PERMITIDAS).toEqual([
+ 'Pago Apartado',
+ 'Alta Enkontrol',
+ 'Entrego Documentacion',
+ 'Cita Clientes',
+ 'Contrato Enkontrol',
+ 'Firma Cliente (Venta)',
+ 'Cancelacion Apartado',
+ ])
+ })
+})
diff --git a/api-gateway/src/controllers/apartados.controller.ts b/api-gateway/src/controllers/apartados.controller.ts
new file mode 100644
index 0000000..cc15c91
--- /dev/null
+++ b/api-gateway/src/controllers/apartados.controller.ts
@@ -0,0 +1,53 @@
+import type { Request, Response } from 'express'
+import { z } from 'zod'
+import { config } from '../config/index.js'
+import { mondayService, type MondayService } from '../services/monday.service.js'
+import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
+
+/**
+ * Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede
+ * mover desde los botones de "Mis apartados" en Mi día. Son los labels REALES
+ * de la columna status 'Estado' del board (verificados en Monday) — el
+ * frontend los manda tal cual.
+ */
+export const FASES_APARTADO_PERMITIDAS = [
+ 'Pago Apartado',
+ 'Alta Enkontrol',
+ 'Entrego Documentacion',
+ 'Cita Clientes',
+ 'Contrato Enkontrol',
+ 'Firma Cliente (Venta)',
+ 'Cancelacion Apartado',
+] as const
+
+const cambiarEstadoSchema = z.object({
+ estado: z.enum(FASES_APARTADO_PERMITIDAS),
+})
+
+type MondayApartados = Pick<
+ MondayService,
+ 'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'
+>
+
+/**
+ * Cambia la fase del apartado (columna 'Estado') y mueve el item al grupo que
+ * corresponde (idempotente; el webhook de change haría lo mismo, pero así la
+ * UI responde al instante).
+ */
+export async function cambiarEstadoApartado(
+ monday: MondayApartados,
+ itemId: string,
+ estado: (typeof FASES_APARTADO_PERMITIDAS)[number],
+): Promise {
+ const boardApartado = config.monday.boards.apartado
+ await monday.cambiarValorColumna(boardApartado, itemId, 'Estado', estado)
+ await moverAGrupoSegunEstado(monday, boardApartado, itemId)
+}
+
+/** POST /api/apartados/:itemId/estado { estado } */
+export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response): Promise {
+ const body = cambiarEstadoSchema.parse(req.body)
+ const itemId = String(req.params['itemId'])
+ await cambiarEstadoApartado(mondayService, itemId, body.estado)
+ res.json({ itemId, estado: body.estado })
+}
diff --git a/api-gateway/src/controllers/cotizaciones.controller.test.ts b/api-gateway/src/controllers/cotizaciones.controller.test.ts
index 75ebde7..1cab40e 100644
--- a/api-gateway/src/controllers/cotizaciones.controller.test.ts
+++ b/api-gateway/src/controllers/cotizaciones.controller.test.ts
@@ -111,6 +111,24 @@ describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', ()
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: null })
expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce()
})
+
+ it('con oportunidadId explícito (botón "Cotizar" de Mi día): actualiza ESE item y no consulta el funnel', async () => {
+ const { res, status, json } = resFake()
+ await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
+ expect(status).toHaveBeenCalledWith(201)
+ expect(funnelMock.oportunidadPorTelefono).not.toHaveBeenCalled()
+ expect(mondayMock.crearOportunidad).not.toHaveBeenCalled()
+ expect(mondayMock.actualizarOportunidadCotizacion).toHaveBeenCalledOnce()
+ const [opId, datos] = mondayMock.actualizarOportunidadCotizacion.mock.calls[0] as [string, Record]
+ expect(opId).toBe('opp-midia-1')
+ expect(datos).toMatchObject({ estadoLabel: 'Cotizador' })
+ // La cotización queda vinculada al item del cliente
+ expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'opp-midia-1' })
+ expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
+ where: { id: 'cot-1' },
+ data: { mondayItemId: 'opp-midia-1' },
+ })
+ })
})
describe('LABEL_PROPUESTA_MONDAY (códigos del cotizador → labels reales del board)', () => {
diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts
index fa70d7f..caf83c1 100644
--- a/api-gateway/src/controllers/cotizaciones.controller.ts
+++ b/api-gateway/src/controllers/cotizaciones.controller.ts
@@ -20,6 +20,9 @@ const crearCotizacionSchema = z.object({
clienteTelefono: z.string().min(7).optional(),
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
+ // Item del tablero Oportunidades al que queda vinculada la cotización
+ // (botón "Cotizar" de Mi día manda el id; sin él se busca por teléfono)
+ oportunidadId: z.string().trim().min(1).optional(),
})
const hoyISO = (): string => {
@@ -88,9 +91,15 @@ export async function crearCotizacion(req: Request, res: Response): Promise {
expect(monday.motivos).toEqual([])
})
})
+
+/** Monday mockeado para cambio de etapa: registra cambios y movimientos de grupo */
+function mondayEtapaMock(grupoActual = 'topics') {
+ return {
+ cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
+ movimientos: [] as { itemId: string; grupo: string }[],
+ async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
+ this.cambios.push({ boardId, itemId, columna, valor })
+ },
+ async columnaIdPorTitulo(_boardId: string, titulo: string) {
+ return titulo === 'Estado' ? 'status' : null
+ },
+ async getItemConGrupo(itemId: string) {
+ return {
+ id: itemId,
+ name: 'Cliente Demo',
+ groupId: grupoActual,
+ column_values: [{ id: 'status', text: this.cambios.find((c) => c.columna === 'Estado')?.valor as string ?? '', value: '{}' }],
+ }
+ },
+ async moverItemAGrupo(itemId: string, grupo: string) {
+ this.movimientos.push({ itemId, grupo })
+ },
+ }
+}
+
+describe('cambiarEtapaOportunidad (POST /api/oportunidades/:id/etapa)', () => {
+ it('cambia la etapa y mueve al grupo destino (Abandono → Perdidos)', async () => {
+ const monday = mondayEtapaMock()
+ await cambiarEtapaOportunidad(monday, 'opp-1', 'Abandono')
+ expect(monday.cambios).toEqual([
+ { boardId: '555', itemId: 'opp-1', columna: 'Estado', valor: 'Abandono' },
+ ])
+ expect(monday.movimientos).toEqual([{ itemId: 'opp-1', grupo: 'group_mm6s4cgq' }])
+ })
+
+ it('Agendo Cita setea también la columna Fecha', async () => {
+ const monday = mondayEtapaMock()
+ await cambiarEtapaOportunidad(monday, 'opp-2', 'Agendo Cita', '2026-09-10')
+ expect(monday.cambios).toEqual([
+ { boardId: '555', itemId: 'opp-2', columna: 'Estado', valor: 'Agendo Cita' },
+ { boardId: '555', itemId: 'opp-2', columna: 'Fecha', valor: '2026-09-10' },
+ ])
+ // 'Agendo Cita' mapea al grupo topics: ya estaba ahí → no se mueve
+ expect(monday.movimientos).toEqual([])
+ })
+
+ it('sin fecha no toca la columna Fecha', async () => {
+ const monday = mondayEtapaMock()
+ await cambiarEtapaOportunidad(monday, 'opp-3', 'Cotizador')
+ expect(monday.cambios).toEqual([
+ { boardId: '555', itemId: 'opp-3', columna: 'Estado', valor: 'Cotizador' },
+ ])
+ })
+
+ it('las etapas permitidas son las fases del funnel definidas por el cliente', () => {
+ expect(ETAPAS_OPORTUNIDAD_PERMITIDAS).toEqual([
+ 'Agendo Cita',
+ 'Afluencia',
+ 'Cotizador',
+ 'Aparto',
+ 'No Asistio',
+ 'Abandono',
+ ])
+ })
+})
diff --git a/api-gateway/src/controllers/oportunidades.controller.ts b/api-gateway/src/controllers/oportunidades.controller.ts
index ad6ecb4..9cd5219 100644
--- a/api-gateway/src/controllers/oportunidades.controller.ts
+++ b/api-gateway/src/controllers/oportunidades.controller.ts
@@ -2,6 +2,7 @@ import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
+import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
export const MOTIVOS_NO_COMPRA = [
@@ -53,3 +54,61 @@ export async function perderOportunidadEndpoint(req: Request, res: Response): Pr
const motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId })
}
+
+// ---------- Cambio de etapa desde Mi día ----------
+
+/**
+ * Etapas del funnel que el asesor puede mover desde los botones de
+ * "Mis oportunidades" (labels REALES de la columna status 'Estado' del board
+ * Oportunidades — el frontend los manda tal cual).
+ */
+export const ETAPAS_OPORTUNIDAD_PERMITIDAS = [
+ 'Agendo Cita',
+ 'Afluencia',
+ 'Cotizador',
+ 'Aparto',
+ 'No Asistio',
+ 'Abandono',
+] as const
+
+const cambiarEtapaSchema = z.object({
+ etapa: z.enum(ETAPAS_OPORTUNIDAD_PERMITIDAS),
+ // En 'Agendo Cita' puede setear la columna date 'Fecha'
+ fecha: z
+ .string()
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe ser YYYY-MM-DD')
+ .optional(),
+})
+
+type MondayEtapa = Pick<
+ MondayService,
+ 'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'
+>
+
+/**
+ * Cambia la etapa de la oportunidad (columna 'Estado'), setea la fecha de la
+ * cita si aplica y mueve el item al grupo que corresponde a la etapa nueva
+ * (idempotente; el webhook de change haría lo mismo, pero así la UI responde
+ * al instante).
+ */
+export async function cambiarEtapaOportunidad(
+ monday: MondayEtapa,
+ itemId: string,
+ etapa: (typeof ETAPAS_OPORTUNIDAD_PERMITIDAS)[number],
+ fecha?: string,
+): Promise {
+ const boardOportunidades = config.monday.boards.oportunidades
+ await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', etapa)
+ if (etapa === 'Agendo Cita' && fecha !== undefined) {
+ await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha)
+ }
+ await moverAGrupoSegunEstado(monday, boardOportunidades, itemId)
+}
+
+/** POST /api/oportunidades/:itemId/etapa { etapa, fecha? } */
+export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Response): Promise {
+ const body = cambiarEtapaSchema.parse(req.body)
+ const itemId = String(req.params['itemId'])
+ await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha)
+ res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
+}
diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts
index fa992a1..28d7e79 100644
--- a/api-gateway/src/routes/index.ts
+++ b/api-gateway/src/routes/index.ts
@@ -43,7 +43,11 @@ import {
obtenerPlan,
registrarAbonoEndpoint,
} from '../controllers/planes.controller.js'
-import { perderOportunidadEndpoint } from '../controllers/oportunidades.controller.js'
+import {
+ cambiarEtapaOportunidadEndpoint,
+ perderOportunidadEndpoint,
+} from '../controllers/oportunidades.controller.js'
+import { cambiarEstadoApartadoEndpoint } from '../controllers/apartados.controller.js'
import { correrRecordatorios, listarRecordatorios } from '../controllers/recordatorios.controller.js'
import {
cambiarEstadoLeadEndpoint,
@@ -134,6 +138,10 @@ apiRouter.get('/cobranza/recordatorios', listarRecordatorios)
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)
apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
+// Cambio de etapa desde los botones de "Mis oportunidades" (Mi día)
+apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
+// Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día)
+apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint)
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
diff --git a/api-gateway/src/services/grupos-estado.service.ts b/api-gateway/src/services/grupos-estado.service.ts
index 65732ca..1a3b11d 100644
--- a/api-gateway/src/services/grupos-estado.service.ts
+++ b/api-gateway/src/services/grupos-estado.service.ts
@@ -99,6 +99,7 @@ export const GRUPOS_POR_ESTADO: Record & { id: string }): ApartadoMo
unidadCodigo: 'U-101',
fechaLimite: '2026-09-02', // dentro de 7 días
estado: 'Pago Apartado',
+ vendedorIds: [MI_ID],
...parcial,
}
}
@@ -197,7 +198,71 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
],
})
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
- expect(r.misOportunidades).toEqual({ porEtapa: { Afluencia: 2, Ganado: 1 } })
+ expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 2, Ganado: 1 })
+ })
+
+ it('misOportunidades.items: lista de trabajo sin terminales, mías, por updatedAt desc, con perfil y último cambio', async () => {
+ const monday = mondayMock({
+ oportunidades: [
+ oportunidad({ id: 'o1', etapa: 'Afluencia', updatedAt: '2026-08-27T10:00:00Z', desarrollo: 'SENDA', modeloInteres: 'SOHO' }),
+ oportunidad({ id: 'o2', etapa: 'Cotizador', updatedAt: '2026-08-28T08:00:00Z', telefono: '4771234567', email: 'a@b.c', unidadCodigo: 'U-9' }),
+ oportunidad({ id: 'o3', etapa: 'Ganado' }), // terminal: fuera de la lista (sí cuenta en porEtapa)
+ oportunidad({ id: 'o4', etapa: 'Abandono' }), // terminal: fuera
+ oportunidad({ id: 'o5', etapa: 'Afluencia', asesorIds: [OTRO_ID] }), // de otro: fuera
+ ],
+ ultimosCambios: {
+ '555': new Map([['o2', { fechaISO: '2026-08-28T09:00:00.000Z', usuarioId: MI_ID, evento: 'update_column_value' }]]),
+ },
+ })
+ const nombres = new Map([[MI_ID, 'Martin Zavala']])
+ const r = await resumenMiDia(monday, vendedor, nombres, [], [], AHORA)
+ expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['o2', 'o1'])
+ const o2 = r.misOportunidades.items[0]
+ expect(o2?.etapa).toBe('Cotizador')
+ expect(o2?.telefono).toBe('4771234567')
+ expect(o2?.email).toBe('a@b.c')
+ expect(o2?.unidadCodigo).toBe('U-9')
+ expect(o2?.ultimoCambio).toEqual({
+ fechaISO: '2026-08-28T09:00:00.000Z',
+ usuario: 'Martin Zavala',
+ detalle: 'Actualización',
+ })
+ const o1 = r.misOportunidades.items[1]
+ expect(o1?.desarrollo).toBe('SENDA')
+ expect(o1?.modeloInteres).toBe('SOHO')
+ expect(o1?.ultimoCambio).toBeNull()
+ })
+
+ it('misApartados: Vendedor = yo, sin estados finales, por fecha límite asc (sin fecha al final), con último cambio', async () => {
+ const monday = mondayMock({
+ apartados: [
+ apartado({ id: 'p1', fechaLimite: '2026-09-05' }),
+ apartado({ id: 'p2', fechaLimite: '2026-09-01', estado: 'Cita Clientes' }),
+ apartado({ id: 'p3', fechaLimite: '', estado: 'Alta Enkontrol' }),
+ apartado({ id: 'p4', estado: 'Firma Cliente (Venta)' }), // final: fuera
+ apartado({ id: 'p5', estado: 'Cancelacion Apartado' }), // final: fuera
+ apartado({ id: 'p6', vendedorIds: [OTRO_ID] }), // de otro: fuera
+ ],
+ ultimosCambios: {
+ '666': new Map([['p1', { fechaISO: '2026-08-28T09:00:00.000Z', usuarioId: OTRO_ID, evento: 'create_update' }]]),
+ },
+ })
+ const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
+ expect(r.misApartados.map((a) => a.id)).toEqual(['p2', 'p1', 'p3'])
+ expect(r.misApartados[0]?.estado).toBe('Cita Clientes')
+ expect(r.misApartados[1]?.ultimoCambio).toEqual({
+ fechaISO: '2026-08-28T09:00:00.000Z',
+ usuario: null, // OTRO_ID no está en el mapa de nombres
+ detalle: 'Nota agregada',
+ })
+ })
+
+ it('esEstadoFinalApartado: venta y cancelación cierran el pipeline', () => {
+ expect(esEstadoFinalApartado('Firma Cliente (Venta)')).toBe(true)
+ expect(esEstadoFinalApartado('Cancelacion Apartado')).toBe(true)
+ expect(esEstadoFinalApartado('Escriturado')).toBe(true)
+ expect(esEstadoFinalApartado('Pago Apartado')).toBe(false)
+ expect(esEstadoFinalApartado('Contrato Enkontrol')).toBe(false)
})
it('actividades de hoy: solo abiertas con fecha <= mañana y mías', async () => {
@@ -244,7 +309,8 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
const r = await resumenMiDia(monday, sinMapeo, new Map(), [], [], AHORA)
expect(r.sinMapeoMonday).toBe(true)
expect(r.misLeads).toEqual([])
- expect(r.misOportunidades).toEqual({ porEtapa: {} })
+ expect(r.misOportunidades).toEqual({ porEtapa: {}, items: [] })
+ expect(r.misApartados).toEqual([])
expect(r.misActividadesHoy).toEqual([])
expect(r.conteos).toEqual({ leadsNuevosSemana: 0, actividadesAbiertas: 0 })
// Los apartados por vencer son del pipeline completo: sí se muestran
diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts
index d717643..a905a8f 100644
--- a/api-gateway/src/services/mi-dia.service.ts
+++ b/api-gateway/src/services/mi-dia.service.ts
@@ -20,7 +20,15 @@ export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO']
// Estados del board Apartado que cierran el pipeline Enkontrol
-export const ESTADOS_FINALES_APARTADO = ['ESCRITURADO', 'CANCELACION', 'CANCELADO', 'LIBERADO', 'FORMALIZADO']
+export const ESTADOS_FINALES_APARTADO = [
+ 'ESCRITURADO',
+ 'CANCELACION',
+ 'CANCELACION APARTADO',
+ 'CANCELADO',
+ 'LIBERADO',
+ 'FORMALIZADO',
+ 'FIRMA CLIENTE (VENTA)', // la venta se concreta: pasa a Escrituración
+]
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
@@ -81,6 +89,32 @@ export interface ClienteMiDia {
estadoApartado: string | null
}
+/** Oportunidad asignada al asesor (tab "Mis oportunidades" de Mi día) */
+export interface OportunidadMiDia {
+ id: string
+ nombre: string
+ telefono: string
+ email: string
+ etapa: string // label de la columna status 'Estado'
+ desarrollo: string
+ modeloInteres: string
+ unidadCodigo: string
+ updatedAt: string
+ /** Último evento del activity log del board; null si el item no tiene eventos */
+ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
+}
+
+/** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */
+export interface ApartadoMiDia {
+ id: string
+ nombre: string
+ unidadCodigo: string
+ estado: string
+ fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
+ /** Último evento del activity log del board; null si el item no tiene eventos */
+ ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
+}
+
export interface ResumenMiDia {
generadoEn: string
sinMapeoMonday: boolean
@@ -89,8 +123,10 @@ export interface ResumenMiDia {
misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[]
- /** Conteo de MIS oportunidades por etapa (columna Estado del board) */
- misOportunidades: { porEtapa: Record }
+ /** MIS oportunidades: conteo por etapa + lista de trabajo (etapas no terminales) */
+ misOportunidades: { porEtapa: Record; items: OportunidadMiDia[] }
+ /** MIS apartados (Vendedor = yo), en estados no finales del pipeline Enkontrol */
+ misApartados: ApartadoMiDia[]
conteos: {
leadsNuevosSemana: number // míos, últimos 7 días naturales
actividadesAbiertas: number // mías, Estado Abierto
@@ -223,17 +259,41 @@ export async function resumenMiDia(
b.updatedAt.localeCompare(a.updatedAt),
)
- // "Último cambio" por item: UNA llamada al activity log por tablero (máx 2),
+ // "Último cambio" por item: UNA llamada al activity log por tablero (máx 3),
// agrupando los ids por fuente. El usuario se resuelve con el mapa
// mondayUserId → nombre que pasa el controller (ecosistema + workspace).
- const [cambiosLeads, cambiosOportunidades] = await Promise.all([
+ const itemsOportunidades: OportunidadMiDia[] = mios
+ .filter((o) => !esEtapaTerminal(o.etapa))
+ .map((o) => ({
+ id: o.id,
+ nombre: o.nombre,
+ telefono: o.telefono,
+ email: o.email,
+ etapa: o.etapa,
+ desarrollo: o.desarrollo ?? '',
+ modeloInteres: o.modeloInteres ?? '',
+ unidadCodigo: o.unidadCodigo,
+ updatedAt: o.updatedAt,
+ ultimoCambio: null,
+ }))
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
+
+ const miosApartados = sinMapeo
+ ? []
+ : apartados.filter((a) => a.vendedorIds.includes(miId) && !esEstadoFinalApartado(a.estado))
+
+ const [cambiosLeads, cambiosOportunidades, cambiosApartados] = await Promise.all([
monday.getUltimosCambios(
config.monday.boards.leads,
misLeads.filter((l) => l.fuente === 'lead').map((l) => l.id),
),
monday.getUltimosCambios(
config.monday.boards.oportunidades,
- misLeads.filter((l) => l.fuente === 'oportunidad').map((l) => l.id),
+ itemsOportunidades.map((o) => o.id),
+ ),
+ monday.getUltimosCambios(
+ config.monday.boards.apartado,
+ miosApartados.map((a) => a.id),
),
])
const cambiosPorFuente = { lead: cambiosLeads, oportunidad: cambiosOportunidades }
@@ -247,6 +307,38 @@ export async function resumenMiDia(
}
: null
}
+ for (const op of itemsOportunidades) {
+ const cambio = cambiosOportunidades.get(op.id)
+ op.ultimoCambio = cambio
+ ? {
+ fechaISO: cambio.fechaISO,
+ usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null,
+ detalle: detalleEventoUltimoCambio(cambio.evento),
+ }
+ : null
+ }
+
+ // "Mis apartados": Vendedor = yo, estado no final del pipeline Enkontrol,
+ // por fecha límite ascendente (sin fecha al final)
+ const misApartados: ApartadoMiDia[] = miosApartados
+ .map((a) => {
+ const cambio = cambiosApartados.get(a.id)
+ return {
+ id: a.id,
+ nombre: a.nombre,
+ unidadCodigo: a.unidadCodigo,
+ estado: a.estado,
+ fechaLimite: a.fechaLimite,
+ ultimoCambio: cambio
+ ? {
+ fechaISO: cambio.fechaISO,
+ usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null,
+ detalle: detalleEventoUltimoCambio(cambio.evento),
+ }
+ : null,
+ }
+ })
+ .sort((a, b) => (a.fechaLimite || '9999-12-31').localeCompare(b.fechaLimite || '9999-12-31'))
// Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
// del pipeline personal, no la lista de trabajo activo)
@@ -336,7 +428,8 @@ export async function resumenMiDia(
misClientes,
misActividadesHoy,
apartadosPorVencer,
- misOportunidades: { porEtapa },
+ misOportunidades: { porEtapa, items: itemsOportunidades },
+ misApartados,
conteos,
}
diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts
index d2744cc..d82e0d9 100644
--- a/api-gateway/src/services/monday.service.ts
+++ b/api-gateway/src/services/monday.service.ts
@@ -79,6 +79,7 @@ export interface ApartadoMonday {
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
fechaLimite: string // 'YYYY-MM-DD' de 'Fecha límite' ('' si no tiene)
estado: string // label de la columna status 'Estado'
+ vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
}
/** Contacto del tablero Contactos (módulo Contactos de la landing) */
@@ -1063,13 +1064,14 @@ export class MondayService {
})
}
- /** Todos los apartados del tablero Apartado, con unidad, fecha límite y estado */
+ /** Todos los apartados del tablero Apartado, con unidad, fecha límite, estado y vendedor */
async getApartados(): Promise {
const boardId = config.monday.boards.apartado
const cols = await this.columnasDe(boardId)
const colUnidad = cols.get('Unidad')
const colFechaLimite = cols.get('Fecha límite')
const colEstado = cols.get('Estado')
+ const colVendedor = cols.get('Vendedor')
const items: ItemMonday[] = []
let cursor: string | null = null
@@ -1116,6 +1118,7 @@ export class MondayService {
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
fechaLimite: colFechaLimite ? (porId.get(colFechaLimite.id)?.text ?? '') : '',
estado,
+ vendedorIds: colVendedor ? this.peopleIds(porId.get(colVendedor.id)?.value) : [],
}
})
}
diff --git a/apps/cotizador/src/pages/CotizadorPage.tsx b/apps/cotizador/src/pages/CotizadorPage.tsx
index b7c6c24..e925c84 100644
--- a/apps/cotizador/src/pages/CotizadorPage.tsx
+++ b/apps/cotizador/src/pages/CotizadorPage.tsx
@@ -81,6 +81,10 @@ export default function CotizadorPage() {
const [modalClienteAbierto, setModalClienteAbierto] = useState(false)
const [guardando, setGuardando] = useState(false)
const [aviso, setAviso] = useState(null)
+ // Prefill desde Mi día (botón "Cotizar" de una oportunidad): el id queda
+ // ligado a la cotización al guardar; la unidad se preselecciona al cargar
+ const [oportunidadId, setOportunidadId] = useState(null)
+ const [unidadPendiente, setUnidadPendiente] = useState(null)
useEffect(() => {
let vivo = true
@@ -113,6 +117,41 @@ export default function CotizadorPage() {
})
const nombreCliente = watch('nombre') ?? ''
+
+ // Prefill desde la URL (botón "Cotizar" de Mi día): cliente, contacto,
+ // desarrollo, unidad y el id de la oportunidad a vincular al guardar
+ useEffect(() => {
+ const qs = new URLSearchParams(window.location.search)
+ const cliente = qs.get('cliente')
+ const telefono = qs.get('telefono')
+ const email = qs.get('email')
+ const desarrolloParam = qs.get('desarrollo')
+ const unidadParam = qs.get('unidad')
+ const opId = qs.get('oportunidadId')
+ if (cliente) setValue('nombre', cliente, { shouldValidate: true })
+ if (telefono) setValue('telefono', telefono, { shouldValidate: true })
+ if (email) setValue('email', email, { shouldValidate: true })
+ if (desarrolloParam && LISTA_DESARROLLOS.includes(desarrolloParam)) setDesarrollo(desarrolloParam)
+ if (unidadParam) setUnidadPendiente(unidadParam)
+ if (opId) setOportunidadId(opId)
+ // Solo al montar: es el punto de entrada desde Mi día
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ // Preselecciona la unidad cuando el inventario terminó de cargar
+ useEffect(() => {
+ if (unidadPendiente === null || cargando) return
+ const encontrada = unidades.find((u) => u.codigo === unidadPendiente)
+ if (encontrada) {
+ const desarrolloUnidad = encontrada.desarrollo ?? 'DIAGONAL'
+ if (desarrolloUnidad !== desarrollo) setDesarrollo(desarrolloUnidad)
+ setUnidad(encontrada)
+ setAviso(`Unidad ${encontrada.codigo} preseleccionada desde Mi día.`)
+ }
+ setUnidadPendiente(null)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [unidades, cargando, unidadPendiente])
+
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !guardando
@@ -130,6 +169,7 @@ export default function CotizadorPage() {
acabados,
apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento,
+ ...(oportunidadId !== null ? { oportunidadId } : {}),
})
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
setUltimaGuardada(conFolio)
@@ -250,6 +290,7 @@ export default function CotizadorPage() {
nueva()
reset({ nombre: '', email: '', telefono: '' })
setUltimaGuardada(null)
+ setOportunidadId(null) // la nueva cotización ya no va ligada a la oportunidad anterior
setAviso(null)
}
@@ -265,6 +306,15 @@ export default function CotizadorPage() {
+ {oportunidadId !== null && nombreCliente.trim() !== '' && (
+
+ Cotizando para {nombreCliente} — al guardar, la cotización queda vinculada a su oportunidad en Monday.
+
+ )}
+
{/* Panel de configuración */}