feat(mi-dia): tabs Mis oportunidades/Mis apartados con fases y cotizador vinculado
- 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
This commit is contained in:
@@ -99,6 +99,7 @@ export const GRUPOS_POR_ESTADO: Record<string, { columna: string; destinos: Reco
|
||||
FORMALIZADO: 'group_mm6s40br',
|
||||
CANCELADO: 'group_mm6s2yyx',
|
||||
Cancelacion: 'group_mm6s2yyx',
|
||||
'Cancelacion Apartado': 'group_mm6s2yyx',
|
||||
LIBERADO: 'group_mm6s2yyx',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -69,6 +69,7 @@ function apartado(parcial: Partial<ApartadoMonday> & { 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
|
||||
|
||||
@@ -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<string, number> }
|
||||
/** MIS oportunidades: conteo por etapa + lista de trabajo (etapas no terminales) */
|
||||
misOportunidades: { porEtapa: Record<string, number>; 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ApartadoMonday[]> {
|
||||
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) : [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user