feat: roles con modulos por rol + mejoras Mi dia y descarte
- Matriz de roles (vendedor, administracion_venta, director_ventas, escrituracion, direccion_general, admin) con modulos visibles por rol; auth/me y login incluyen 'modulos'; PATCH /usuarios/:id/rol (admin). - Landing: Cobranza -> Clientes, Chat IA -> Chat Hermes, nuevo modulo Contactos (vista interna leyendo el tablero Contactos), grid filtrado por rol, rol visible en Configuracion de cuenta. - Dashboards: tab 'Tablero comercial' y tabs filtrados por rol. - Boton '<- Menu' en el header de todas las apps. - Mi dia: cada lead muestra el ultimo cambio (que, quien, cuando) desde el activity log de Monday (normalizacion de created_at 100ns/us/ms). - Descarte: el motivo se crea en el grupo de su categoria en el tablero Motivos No Compra.
This commit is contained in:
@@ -81,6 +81,16 @@ export interface ApartadoMonday {
|
||||
estado: string // label de la columna status 'Estado'
|
||||
}
|
||||
|
||||
/** Contacto del tablero Contactos (módulo Contactos de la landing) */
|
||||
export interface ContactoMonday {
|
||||
id: string
|
||||
nombre: string
|
||||
telefono: string // columna phone 'Teléfono' ('' si no tiene)
|
||||
email: string // columna email 'Email' ('' si no tiene)
|
||||
estado: string // label de la columna status 'Estado' ('' si nunca se escribió)
|
||||
updatedAt: string // ISO (updated_at del item en Monday)
|
||||
}
|
||||
|
||||
/** Oportunidad del tablero Oportunidades (pipeline comercial) */
|
||||
export interface OportunidadMonday {
|
||||
id: string
|
||||
@@ -116,6 +126,22 @@ export interface UsuarioMonday {
|
||||
email: string
|
||||
}
|
||||
|
||||
/** Entrada del activity log de Monday (boards { activity_logs }) */
|
||||
interface ActivityLogMonday {
|
||||
id: string
|
||||
event: string
|
||||
created_at: string // string numérico, normalmente en microsegundos
|
||||
user_id: string
|
||||
data: string // JSON string; pulse_id identifica el item
|
||||
}
|
||||
|
||||
/** Último evento del activity log de un item (para "Último cambio" de Mi día) */
|
||||
export interface UltimoCambioMonday {
|
||||
fechaISO: string
|
||||
usuarioId: number | null // user_id de Monday; null si no es numérico
|
||||
evento: string // event crudo del activity log (create_pulse, update_status, ...)
|
||||
}
|
||||
|
||||
interface GraphQLErrorItem {
|
||||
message: string
|
||||
extensions?: { code?: string }
|
||||
@@ -135,6 +161,21 @@ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
|
||||
|
||||
const CACHE_UNIDADES_MS = 5 * 60 * 1000 // 5 minutos
|
||||
|
||||
/**
|
||||
* Grupo del tablero Motivos No Compra por categoría (ids verificados sep-2026).
|
||||
* El descarte crea el motivo directo en su grupo; si la categoría no está
|
||||
* mapeada, el item cae en el grupo default del tablero.
|
||||
*/
|
||||
export const GRUPOS_MOTIVOS_NO_COMPRA: Record<string, string> = {
|
||||
Timing: 'group_mm6spcsj',
|
||||
Cliente: 'group_mm6sp7nr',
|
||||
Competencia: 'group_mm6sbdfy',
|
||||
Financiamiento: 'group_mm6ssymc',
|
||||
Ubicacion: 'group_mm6sqern', // sin acento: así está el grupo en Monday
|
||||
Producto: 'group_mm6s7j0b',
|
||||
Precio: 'group_mm6sze0z',
|
||||
}
|
||||
|
||||
export interface MondayServiceOptions {
|
||||
apiKey?: string
|
||||
apiUrl?: string
|
||||
@@ -418,12 +459,12 @@ export class MondayService {
|
||||
return JSON.stringify(out)
|
||||
}
|
||||
|
||||
private async crearItem(boardId: string, nombre: string, columnValues: string): Promise<string> {
|
||||
private async crearItem(boardId: string, nombre: string, columnValues: string, groupId?: string): Promise<string> {
|
||||
const data = await this.graphql<{ create_item: { id: string } }>(
|
||||
`mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) {
|
||||
create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues) { id }
|
||||
`mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!, $groupId: String) {
|
||||
create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues, group_id: $groupId) { id }
|
||||
}`,
|
||||
{ boardId, itemName: nombre, columnValues },
|
||||
{ boardId, itemName: nombre, columnValues, groupId: groupId ?? null },
|
||||
)
|
||||
return data.create_item.id
|
||||
}
|
||||
@@ -660,7 +701,8 @@ export class MondayService {
|
||||
|
||||
// ---------- Motivos No Compra / Actividades (tableros CRM FRASA) ----------
|
||||
|
||||
/** Item en el tablero Motivos No Compra (nombre = "<motivo> — <item origen>") */
|
||||
/** Item en el tablero Motivos No Compra (nombre = "<motivo> — <item origen>"),
|
||||
* creado directo en el grupo de su categoría (si está mapeada) */
|
||||
async crearMotivoNoCompra(datos: DatosMotivoNoCompra): Promise<string> {
|
||||
const boardId = config.monday.boards.motivos
|
||||
if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_MOTIVOS no está configurado')
|
||||
@@ -668,7 +710,11 @@ export class MondayService {
|
||||
Categoria: datos.categoria,
|
||||
Descripcion: datos.descripcion,
|
||||
})
|
||||
return this.crearItem(boardId, datos.nombre, columnValues)
|
||||
const buscada = datos.categoria.trim().toUpperCase()
|
||||
const groupId = Object.entries(GRUPOS_MOTIVOS_NO_COMPRA).find(
|
||||
([categoria]) => categoria.toUpperCase() === buscada,
|
||||
)?.[1]
|
||||
return this.crearItem(boardId, datos.nombre, columnValues, groupId)
|
||||
}
|
||||
|
||||
/** Actividad de seguimiento ligada a un lead (bitácora del CRM) */
|
||||
@@ -904,6 +950,52 @@ export class MondayService {
|
||||
|
||||
// ---------- Mi día (CRM ligero para vendedores) ----------
|
||||
|
||||
/**
|
||||
* Último evento del activity log por item: UNA query por tablero con todos
|
||||
* los itemIds (limit = items × 5, por si hay varios eventos por item). El
|
||||
* item se identifica por el pulse_id del payload `data` (JSON string). El
|
||||
* created_at viene como string numérico; por magnitud se normaliza a ms:
|
||||
* > 1e16 ⇒ centésimas de µs (así llega hoy, 17 dígitos), > 1e14 ⇒ µs,
|
||||
* si no ms. itemIds vacío ⇒ Map vacío sin llamar a Monday.
|
||||
*/
|
||||
async getUltimosCambios(boardId: string, itemIds: string[]): Promise<Map<string, UltimoCambioMonday>> {
|
||||
const resultado = new Map<string, UltimoCambioMonday>()
|
||||
if (itemIds.length === 0) return resultado
|
||||
const data = await this.graphql<{ boards: { activity_logs: ActivityLogMonday[] }[] }>(
|
||||
`query ($ids: [ID!], $itemIds: [ID!], $limit: Int!) {
|
||||
boards(ids: $ids) {
|
||||
activity_logs(item_ids: $itemIds, limit: $limit) { id event created_at user_id data }
|
||||
}
|
||||
}`,
|
||||
{ ids: [boardId], itemIds, limit: itemIds.length * 5 },
|
||||
)
|
||||
for (const log of data.boards[0]?.activity_logs ?? []) {
|
||||
let itemId: string | undefined
|
||||
try {
|
||||
const datos = JSON.parse(log.data) as { pulse_id?: number | string }
|
||||
itemId = datos.pulse_id === undefined ? undefined : String(datos.pulse_id)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (itemId === undefined) continue
|
||||
const crudo = Number(log.created_at)
|
||||
if (!Number.isFinite(crudo)) continue
|
||||
// Normaliza a ms por magnitud: 100ns (17 dígitos) /1e4, µs /1e3, ms directo
|
||||
const ms = crudo > 1e16 ? crudo / 1e4 : crudo > 1e14 ? crudo / 1e3 : crudo
|
||||
const fechaISO = new Date(ms).toISOString()
|
||||
// Las fechas ISO comparan bien como strings: solo nos quedamos con el más reciente
|
||||
const actual = resultado.get(itemId)
|
||||
if (actual && actual.fechaISO >= fechaISO) continue
|
||||
const userId = Number(log.user_id)
|
||||
resultado.set(itemId, {
|
||||
fechaISO,
|
||||
usuarioId: Number.isFinite(userId) && userId > 0 ? userId : null,
|
||||
evento: log.event,
|
||||
})
|
||||
}
|
||||
return resultado
|
||||
}
|
||||
|
||||
/** Parsea el value JSON de una columna people a los user IDs (solo personas) */
|
||||
private peopleIds(raw: string | null | undefined): number[] {
|
||||
if (!raw) return []
|
||||
@@ -1028,6 +1120,70 @@ export class MondayService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Contactos del tablero Contactos (módulo Contactos de la landing): nombre,
|
||||
* teléfono, email y estado, ordenados por updated_at desc, máx 100.
|
||||
*/
|
||||
async getContactos(): Promise<ContactoMonday[]> {
|
||||
const boardId = config.monday.boards.contactos
|
||||
const cols = await this.columnasDe(boardId)
|
||||
const colTelefono = cols.get('Teléfono')
|
||||
const colEmail = cols.get('Email')
|
||||
const colEstado = cols.get('Estado')
|
||||
|
||||
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: 100) {
|
||||
cursor
|
||||
items { id name updated_at column_values { id text value } }
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ ids: [boardId] },
|
||||
)
|
||||
: await this.graphql(
|
||||
`query ($ids: [ID!], $cursor: String!) {
|
||||
boards(ids: $ids) {
|
||||
items_page(limit: 100, cursor: $cursor) {
|
||||
cursor
|
||||
items { id name 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]))
|
||||
// Misma corrección que mapearLead: status nunca escrito → value null y
|
||||
// `text` muestra el default del tablero; el estado real es vacío
|
||||
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
|
||||
const estado = estadoCv?.value ? (estadoCv.text ?? '') : ''
|
||||
return {
|
||||
id: item.id,
|
||||
nombre: item.name,
|
||||
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
|
||||
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
|
||||
estado,
|
||||
updatedAt: item.updated_at,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
.slice(0, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels de una columna status/dropdown (settings_str del board).
|
||||
* Monday los devuelve como objeto {index: label} o como arreglo [{id, name}].
|
||||
|
||||
Reference in New Issue
Block a user