From 1963c06d93de7b3f99dc2d86763d932d1147db00 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 4 Sep 2026 21:54:44 +0000 Subject: [PATCH] feat(mi-dia): tabs Mis oportunidades/Mis apartados con fases y cotizador vinculado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resumen Mi día: misOportunidades.items (lista de trabajo con perfil y último cambio) y misApartados (Vendedor = yo, estados no finales) - Nuevos endpoints: POST /oportunidades/:id/etapa (Agendo Cita, Afluencia, Cotizador, Aparto, No Asistio, Abandono; con fecha de cita) y POST /apartados/:id/estado (pipeline Enkontrol: Pago Apartado → Firma Cliente (Venta) / Cancelacion Apartado); ambos mueven el item al grupo Monday correspondiente - Mi día: tabs Mis leads / Mis oportunidades / Mis apartados; botón Cotizar abre /quote con el cliente prellenado y la oportunidad ligada - Cotizador: prefill desde query params (cliente, teléfono, email, desarrollo, unidad) y envío de oportunidadId; al guardar, el API actualiza ESE item (Precio final, Tipo propuesta, etapa Cotizador) en vez de buscar por teléfono o crear duplicado - grupos-estado: 'Cancelacion Apartado' → Historico - Estados finales de apartado: agrega Firma Cliente (Venta) y Cancelacion Apartado - Tests: 455 API + 14 mi-dia + 88 cotizador, todo en verde --- .../controllers/apartados.controller.test.ts | 68 ++++ .../src/controllers/apartados.controller.ts | 53 +++ .../cotizaciones.controller.test.ts | 18 + .../controllers/cotizaciones.controller.ts | 13 +- .../oportunidades.controller.test.ts | 72 +++- .../controllers/oportunidades.controller.ts | 59 ++++ api-gateway/src/routes/index.ts | 10 +- .../src/services/grupos-estado.service.ts | 1 + .../src/services/mi-dia.service.test.ts | 70 +++- api-gateway/src/services/mi-dia.service.ts | 107 +++++- api-gateway/src/services/monday.service.ts | 5 +- apps/cotizador/src/pages/CotizadorPage.tsx | 50 +++ apps/cotizador/src/services/api.ts | 2 + apps/mi-dia/src/lib/mi-dia.test.ts | 40 +++ apps/mi-dia/src/lib/mi-dia.ts | 80 ++++- apps/mi-dia/src/pages/MiDiaPage.tsx | 319 ++++++++++++++++-- apps/mi-dia/src/services/mi-dia.ts | 21 ++ 17 files changed, 953 insertions(+), 35 deletions(-) create mode 100644 api-gateway/src/controllers/apartados.controller.test.ts create mode 100644 api-gateway/src/controllers/apartados.controller.ts 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 */}