feat(mi-dia): app /hoy + CRM ligero para vendedores
- App Mi día: KPIs, mis actividades, mis leads con acciones rápidas (contactado/intento/agenda cita/descartar con motivo), captura rápida de lead (409 en duplicado por teléfono), vista equipo para coordinadores - API: GET /api/mi-dia/resumen, POST /api/leads, /leads/:id/estado, /leads/:id/descartar; mapeo Usuario.mondayUserId (seed listo para cuando inviten a los vendedores a Monday) - Landing: tarjeta Mi día; tests api 188, mi-dia 8, landing 4
This commit is contained in:
190
api-gateway/src/services/mi-dia.service.ts
Normal file
190
api-gateway/src/services/mi-dia.service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { MondayService } from './monday.service.js'
|
||||
|
||||
/**
|
||||
* Agregador "Mi día": lo que un vendedor necesita al abrir la app /hoy.
|
||||
* Todo se filtra por su mondayUserId (columna people de cada tablero); si el
|
||||
* usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales
|
||||
* van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando).
|
||||
*/
|
||||
|
||||
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
|
||||
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
|
||||
|
||||
// Estados del board Apartado que cierran el pipeline Enkontrol
|
||||
export const ESTADOS_FINALES_APARTADO = ['ESCRITURADO', 'CANCELACION', 'CANCELADO', 'LIBERADO', 'FORMALIZADO']
|
||||
|
||||
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
||||
|
||||
export interface UsuarioMiDia {
|
||||
nombre: string
|
||||
rol: string
|
||||
mondayUserId: number | null
|
||||
}
|
||||
|
||||
export interface LeadMiDia {
|
||||
id: string
|
||||
nombre: string
|
||||
telefono: string
|
||||
email: string
|
||||
estado: string
|
||||
origen: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ActividadMiDia {
|
||||
id: string
|
||||
nombre: string
|
||||
tipo: string
|
||||
fecha: string
|
||||
estado: string
|
||||
}
|
||||
|
||||
export interface ApartadoPorVencer {
|
||||
id: string
|
||||
nombre: string
|
||||
unidadCodigo: string
|
||||
fechaLimite: string
|
||||
estado: string
|
||||
diasRestantes: number
|
||||
}
|
||||
|
||||
export interface ResumenMiDia {
|
||||
generadoEn: string
|
||||
sinMapeoMonday: boolean
|
||||
misLeads: LeadMiDia[]
|
||||
misActividadesHoy: ActividadMiDia[]
|
||||
apartadosPorVencer: ApartadoPorVencer[]
|
||||
// null = el tablero Oportunidades no tiene columna people de asesor (hoy no existe)
|
||||
misOportunidades: { porEtapa: Record<string, number> } | null
|
||||
notaOportunidades: string | null
|
||||
conteos: {
|
||||
leadsNuevosSemana: number // míos, últimos 7 días naturales
|
||||
actividadesAbiertas: number // mías, Estado Abierto
|
||||
}
|
||||
// Solo roles coordinador/corporativo/admin
|
||||
equipo?: {
|
||||
leadsNuevosSinAsignar: number
|
||||
porVendedor: { mondayUserId: number; nombre: string | null; leadsAsignados: number }[]
|
||||
}
|
||||
}
|
||||
|
||||
type MondayMiDia = Pick<MondayService, 'getLeads' | 'getActividades' | 'getApartados'>
|
||||
|
||||
const MS_DIA = 24 * 60 * 60 * 1000
|
||||
|
||||
/** Fecha local 'YYYY-MM-DD' desplazada n días desde `base` */
|
||||
function fechaLocal(base: Date, dias: number): string {
|
||||
const d = new Date(base.getTime() + dias * MS_DIA)
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${dd}`
|
||||
}
|
||||
|
||||
export function esEstadoTerminalLead(estado: string): boolean {
|
||||
return ESTADOS_TERMINALES_LEAD.includes(estado.trim().toUpperCase())
|
||||
}
|
||||
|
||||
export function esEstadoFinalApartado(estado: string): boolean {
|
||||
return ESTADOS_FINALES_APARTADO.includes(estado.trim().toUpperCase())
|
||||
}
|
||||
|
||||
export async function resumenMiDia(
|
||||
monday: MondayMiDia,
|
||||
usuario: UsuarioMiDia,
|
||||
/** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */
|
||||
nombresPorMondayId: Map<number, string>,
|
||||
ahora: Date = new Date(),
|
||||
): Promise<ResumenMiDia> {
|
||||
const miId = usuario.mondayUserId
|
||||
const sinMapeo = miId === null
|
||||
|
||||
const [leads, actividades, apartados] = await Promise.all([
|
||||
monday.getLeads(),
|
||||
monday.getActividades(),
|
||||
monday.getApartados(),
|
||||
])
|
||||
|
||||
const hoy = fechaLocal(ahora, 0)
|
||||
const manana = fechaLocal(ahora, 1)
|
||||
const en7Dias = fechaLocal(ahora, 7)
|
||||
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
|
||||
|
||||
// --- Listas personales (vacías si no hay mapeo Monday) ---
|
||||
const mios = sinMapeo ? [] : leads.filter((l) => l.vendedorIds.includes(miId))
|
||||
const misLeads: LeadMiDia[] = mios
|
||||
.filter((l) => !esEstadoTerminalLead(l.estado))
|
||||
.map((l) => ({
|
||||
id: l.id,
|
||||
nombre: l.nombre,
|
||||
telefono: l.telefono,
|
||||
email: l.email,
|
||||
estado: l.estado,
|
||||
origen: l.origen,
|
||||
updatedAt: l.updatedAt,
|
||||
}))
|
||||
|
||||
const misActividades = sinMapeo ? [] : actividades.filter((a) => a.responsableIds.includes(miId))
|
||||
const misActividadesHoy: ActividadMiDia[] = misActividades
|
||||
.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO' && a.fecha !== '' && a.fecha <= manana)
|
||||
.sort((a, b) => a.fecha.localeCompare(b.fecha))
|
||||
.map((a) => ({ id: a.id, nombre: a.nombre, tipo: a.tipo, fecha: a.fecha, estado: a.estado }))
|
||||
|
||||
// Apartados por vencer es del pipeline completo (Enkontrol no lleva asesor people)
|
||||
const apartadosPorVencer: ApartadoPorVencer[] = apartados
|
||||
.filter(
|
||||
(a) =>
|
||||
a.fechaLimite !== '' &&
|
||||
a.fechaLimite >= hoy &&
|
||||
a.fechaLimite <= en7Dias &&
|
||||
!esEstadoFinalApartado(a.estado),
|
||||
)
|
||||
.sort((a, b) => a.fechaLimite.localeCompare(b.fechaLimite))
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
nombre: a.nombre,
|
||||
unidadCodigo: a.unidadCodigo,
|
||||
fechaLimite: a.fechaLimite,
|
||||
estado: a.estado,
|
||||
diasRestantes: Math.round((Date.parse(`${a.fechaLimite}T00:00:00`) - Date.parse(`${hoy}T00:00:00`)) / MS_DIA),
|
||||
}))
|
||||
|
||||
const conteos = {
|
||||
leadsNuevosSemana: mios.filter((l) => Date.parse(l.createdAt) >= hace7DiasMs).length,
|
||||
actividadesAbiertas: misActividades.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO').length,
|
||||
}
|
||||
|
||||
const resumen: ResumenMiDia = {
|
||||
generadoEn: ahora.toISOString(),
|
||||
sinMapeoMonday: sinMapeo,
|
||||
misLeads,
|
||||
misActividadesHoy,
|
||||
apartadosPorVencer,
|
||||
// El tablero Oportunidades no tiene columna people de asesor: no se puede
|
||||
// filtrar "mío"; se omite con nota explícita
|
||||
misOportunidades: null,
|
||||
notaOportunidades: 'El tablero Oportunidades no tiene columna de asesor (people); no se puede filtrar por vendedor',
|
||||
conteos,
|
||||
}
|
||||
|
||||
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
|
||||
const sinAsignar = leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado))
|
||||
const conteoPorId = new Map<number, number>()
|
||||
for (const l of leads) {
|
||||
if (esEstadoTerminalLead(l.estado)) continue
|
||||
for (const id of l.vendedorIds) conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1)
|
||||
}
|
||||
resumen.equipo = {
|
||||
leadsNuevosSinAsignar: sinAsignar.filter((l) => l.estado.trim().toUpperCase() === 'NUEVO').length,
|
||||
porVendedor: [...conteoPorId.entries()]
|
||||
.map(([mondayUserId, leadsAsignados]) => ({
|
||||
mondayUserId,
|
||||
nombre: nombresPorMondayId.get(mondayUserId) ?? null,
|
||||
leadsAsignados,
|
||||
}))
|
||||
.sort((a, b) => b.leadsAsignados - a.leadsAsignados),
|
||||
}
|
||||
}
|
||||
|
||||
return resumen
|
||||
}
|
||||
Reference in New Issue
Block a user