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:
2026-09-04 21:54:44 +00:00
parent a555f69dcd
commit 1963c06d93
17 changed files with 953 additions and 35 deletions

View File

@@ -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