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

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