feat: automatizaciones ronda 2 + app de dashboards /dash

- Escalado: lead asignado sin contactar >48h genera actividad URGENTE
  al coordinador (LEAD_ESCALADO_HORAS, MONDAY_COORDINADOR_USER_ID)
- Oportunidad en Aparto crea item en Apartado (fecha límite +7d);
  en Ganado crea la Venta; ambos con dedup
- GET /api/reportes/resumen: leads, funnel, unidades por desarrollo,
  cobranza y actividades en una respuesta (cache 60s)
- App dashboards (/dash): 8 KPIs + 3 gráficas Recharts, con auth
- Landing: tarjeta Tableros; docs/AUTOMATIZACIONES-MONDAY.md con las
  recetas nativas para activar en la UI
- Tests: api 158, dashboards 7, landing 5 — todo verde
This commit is contained in:
2026-08-27 09:05:09 +00:00
parent de11af985e
commit 04568a5603
34 changed files with 6279 additions and 8 deletions

View File

@@ -32,11 +32,23 @@ interface ItemConFechas extends ItemMonday {
export interface LeadMonday {
id: string
nombre: string
createdAt: string // ISO (created_at del item en Monday)
updatedAt: string // ISO (updated_at del item en Monday)
estado: string // label de la columna status 'Estado' ('' si no tiene)
origen: string // label de la columna status 'Origen' ('' si no tiene)
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
}
/** Oportunidad del tablero Oportunidades (pipeline comercial) */
export interface OportunidadMonday {
id: string
nombre: string // nombre del cliente
createdAt: string // ISO
etapa: string // label de la columna status 'Estado'
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
precioFinal: number // columna numbers 'Precio final' (0 si no tiene)
}
/** Usuario del workspace (para la lista rotativa de vendedores) */
export interface UsuarioMonday {
id: number
@@ -521,16 +533,131 @@ export class MondayService {
vendedorIds = []
}
}
const colOrigen = cols.get('Origen')
return {
id: item.id,
nombre: item.name,
createdAt: item.created_at,
updatedAt: item.updated_at,
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '',
vendedorIds,
}
})
}
/**
* Items del tablero Oportunidades con etapa, unidad y precio final
* (los consumen las automatizaciones Aparto/Ganado y los reportes).
*/
async getOportunidades(): Promise<OportunidadMonday[]> {
const boardId = config.monday.boards.oportunidades
const cols = await this.columnasDe(boardId)
const colEstado = cols.get('Estado')
if (!colEstado) throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
const colUnidad = cols.get('Unidad')
const colPrecio = cols.get('Precio final')
const items: ItemConFechas[] = []
let cursor: string | null = null
do {
const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } =
cursor === null
? await this.graphql(
`query ($ids: [ID!]) {
boards(ids: $ids) {
items_page(limit: 200) {
cursor
items { id name created_at updated_at column_values { id text value } }
}
}
}`,
{ ids: [boardId] },
)
: await this.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) {
items_page(limit: 200, cursor: $cursor) {
cursor
items { id name created_at updated_at column_values { id text value } }
}
}
}`,
{ ids: [boardId], cursor },
)
const page = data.boards[0]?.items_page
if (!page) break
items.push(...page.items)
cursor = page.cursor
} while (cursor !== null)
return items.map((item) => {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
const precio = colPrecio ? Number(porId.get(colPrecio.id)?.text ?? '') : NaN
return {
id: item.id,
nombre: item.name,
createdAt: item.created_at,
etapa: porId.get(colEstado.id)?.text ?? '',
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
precioFinal: Number.isFinite(precio) ? precio : 0,
}
})
}
/** Item en el tablero Ventas (se crea cuando una oportunidad pasa a 'Ganado') */
async crearVenta(datos: DatosVenta): Promise<string> {
const boardId = config.monday.boards.ventas
const columnValues = await this.construirColumnValues(boardId, {
Unidad: datos.unidadCodigo,
'Valor venta': datos.valorVenta,
})
return this.crearItem(boardId, datos.clienteNombre, columnValues)
}
/**
* Número de actividades con Estado 'Abierto' en el tablero Actividades.
* Si el tablero no está configurado devuelve 0 (los reportes no deben caer por eso).
*/
async getActividadesAbiertas(): Promise<number> {
const boardId = config.monday.boards.actividades
if (boardId === '') return 0
const cols = await this.columnasDe(boardId)
const colEstado = cols.get('Estado')
if (!colEstado) return 0
let abiertas = 0
let cursor: string | null = null
do {
const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } =
cursor === null
? await this.graphql(
`query ($ids: [ID!]) {
boards(ids: $ids) {
items_page(limit: 500) { cursor items { id column_values { id text } } }
}
}`,
{ ids: [boardId] },
)
: await this.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) {
items_page(limit: 500, cursor: $cursor) { cursor items { id column_values { id text } } }
}
}`,
{ ids: [boardId], cursor },
)
const page = data.boards[0]?.items_page
if (!page) break
for (const item of page.items) {
const estado = item.column_values.find((cv) => cv.id === colEstado.id)?.text ?? ''
if (estado.trim().toUpperCase() === 'ABIERTO') abiertas++
}
cursor = page.cursor
} while (cursor !== null)
return abiertas
}
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
const boardId = config.monday.boards.leads
@@ -615,6 +742,12 @@ export interface DatosApartado {
estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
}
export interface DatosVenta {
clienteNombre: string
unidadCodigo?: string
valorVenta?: number
}
export interface DatosMotivoNoCompra {
nombre: string // '<motivo> — <nombre del item de origen>'
categoria: string // una de MOTIVOS_NO_COMPRA