Agrega al formulario ligero y a POST /api/leads: tipo de propiedad, medio de contacto, edad, estado civil, tipo de empleo, ocupacion, rango salarial, credito, tipo de economia, califica y lo quiere para. Selects obligatorios (estado civil, tipo de empleo, tipo de economia, califica) con las etiquetas sembradas como labels status en el tablero Leads de Monday.
1114 lines
41 KiB
TypeScript
1114 lines
41 KiB
TypeScript
import { config } from '../config/index.js'
|
|
import type { UnidadCotizable } from './cotizacion.service.js'
|
|
import { AppError } from '../utils/errors.js'
|
|
|
|
/** Unidad tal como la consume el API (y el frontend del cotizador) */
|
|
export type Unidad = UnidadCotizable
|
|
|
|
interface ColumnaMeta {
|
|
id: string
|
|
title: string
|
|
type: string
|
|
}
|
|
|
|
interface ColumnValue {
|
|
id: string
|
|
text: string | null
|
|
value: string | null
|
|
}
|
|
|
|
interface ItemMonday {
|
|
id: string
|
|
name: string
|
|
column_values: ColumnValue[]
|
|
}
|
|
|
|
interface ItemConFechas extends ItemMonday {
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
/** Lead del tablero Leads tal como lo consumen las automatizaciones */
|
|
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)
|
|
telefono: string // columna phone 'Teléfono' ('' si no tiene)
|
|
email: string // columna email 'Email' ('' si no tiene)
|
|
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
|
|
}
|
|
|
|
/** Actividad del tablero Actividades (agenda de seguimiento del CRM) */
|
|
export interface ActividadMonday {
|
|
id: string
|
|
nombre: string
|
|
tipo: string // label de la columna status 'Tipo' (Llamada | Reunion | Tarea | ...)
|
|
fecha: string // 'YYYY-MM-DD' de la columna date 'Fecha' ('' si no tiene)
|
|
estado: string // label de la columna status 'Estado' (Abierto | Listo | ...)
|
|
responsableIds: number[] // user IDs de la columna people 'Responsable'
|
|
}
|
|
|
|
/** Apartado del tablero Apartado (pipeline Enkontrol) */
|
|
export interface ApartadoMonday {
|
|
id: string
|
|
nombre: string // nombre del cliente
|
|
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
|
|
fechaLimite: string // 'YYYY-MM-DD' de 'Fecha límite' ('' si no tiene)
|
|
estado: string // label de la columna status 'Estado'
|
|
}
|
|
|
|
/** 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
|
|
nombre: string
|
|
email: string
|
|
}
|
|
|
|
interface GraphQLErrorItem {
|
|
message: string
|
|
extensions?: { code?: string }
|
|
}
|
|
|
|
interface GraphQLResponse<T> {
|
|
data?: T
|
|
errors?: GraphQLErrorItem[]
|
|
error_code?: string
|
|
error_message?: string
|
|
}
|
|
|
|
/** Error retryable de Monday (rate limit / presupuesto de complejidad / 5xx) */
|
|
class MondayRetryableError extends Error {}
|
|
|
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
|
|
|
const CACHE_UNIDADES_MS = 5 * 60 * 1000 // 5 minutos
|
|
|
|
export interface MondayServiceOptions {
|
|
apiKey?: string
|
|
apiUrl?: string
|
|
maxReintentos?: number
|
|
baseDelayMs?: number
|
|
}
|
|
|
|
export class MondayService {
|
|
private readonly apiKey: string
|
|
private readonly apiUrl: string
|
|
private readonly maxReintentos: number
|
|
private readonly baseDelayMs: number
|
|
|
|
// Cache de metadatos de columnas: boardId -> (título -> meta)
|
|
private readonly columnasCache = new Map<string, Map<string, ColumnaMeta>>()
|
|
// Cache de unidades + mapa codigo -> itemId (para escrituras)
|
|
private unidadesCache: { unidades: Unidad[]; itemIds: Map<string, string>; expira: number } | null = null
|
|
|
|
constructor(opts: MondayServiceOptions = {}) {
|
|
this.apiKey = opts.apiKey ?? config.monday.apiKey
|
|
this.apiUrl = opts.apiUrl ?? config.monday.apiUrl
|
|
this.maxReintentos = opts.maxReintentos ?? 3
|
|
this.baseDelayMs = opts.baseDelayMs ?? 500
|
|
}
|
|
|
|
/**
|
|
* Helper GraphQL contra la API de Monday.
|
|
* - Headers Authorization / API-Version.
|
|
* - Lanza en errores GraphQL.
|
|
* - Retry con backoff exponencial ante 429, errores de complejidad y 5xx
|
|
* (máx `maxReintentos` reintentos).
|
|
*/
|
|
async graphql<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
|
|
let ultimoError: Error | null = null
|
|
for (let intento = 0; intento <= this.maxReintentos; intento++) {
|
|
if (intento > 0) await sleep(this.baseDelayMs * 2 ** (intento - 1))
|
|
try {
|
|
return await this.graphqlOnce<T>(query, variables)
|
|
} catch (err) {
|
|
if (!(err instanceof MondayRetryableError)) throw err
|
|
ultimoError = err
|
|
}
|
|
}
|
|
throw new AppError(502, `Monday no respondió tras ${this.maxReintentos + 1} intentos: ${ultimoError?.message}`)
|
|
}
|
|
|
|
private async graphqlOnce<T>(query: string, variables: Record<string, unknown>): Promise<T> {
|
|
const res = await fetch(this.apiUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: this.apiKey,
|
|
'API-Version': config.monday.apiVersion,
|
|
},
|
|
body: JSON.stringify({ query, variables }),
|
|
})
|
|
|
|
if (res.status === 429) throw new MondayRetryableError('HTTP 429 (rate limit)')
|
|
if (res.status >= 500) throw new MondayRetryableError(`HTTP ${res.status}`)
|
|
if (!res.ok) throw new AppError(502, `Monday respondió HTTP ${res.status}`)
|
|
|
|
const body = (await res.json()) as GraphQLResponse<T>
|
|
|
|
// Monday devuelve errores de complejidad como 200 con error_code
|
|
if (body.error_code) {
|
|
if (/complexity/i.test(body.error_code) || /complexity/i.test(body.error_message ?? '')) {
|
|
throw new MondayRetryableError(`Complejidad excedida: ${body.error_code}`)
|
|
}
|
|
throw new AppError(502, `Error de Monday: ${body.error_code} ${body.error_message ?? ''}`)
|
|
}
|
|
if (body.errors?.length) {
|
|
const msg = body.errors.map((e) => e.message).join('; ')
|
|
const esComplejidad = body.errors.some(
|
|
(e) => /complexity/i.test(e.message) || /complexity/i.test(e.extensions?.code ?? ''),
|
|
)
|
|
if (esComplejidad) throw new MondayRetryableError(`Complejidad excedida: ${msg}`)
|
|
throw new AppError(502, `Errores GraphQL de Monday: ${msg}`)
|
|
}
|
|
if (!body.data) throw new AppError(502, 'Monday respondió sin data')
|
|
return body.data
|
|
}
|
|
|
|
/** Metadatos de columnas de un tablero, mapeadas por TÍTULO (cache en memoria) */
|
|
private async columnasDe(boardId: string): Promise<Map<string, ColumnaMeta>> {
|
|
const cached = this.columnasCache.get(boardId)
|
|
if (cached) return cached
|
|
const data = await this.graphql<{ boards: { columns: ColumnaMeta[] }[] }>(
|
|
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
|
|
{ ids: [boardId] },
|
|
)
|
|
const columnas = data.boards[0]?.columns ?? []
|
|
const mapa = new Map<string, ColumnaMeta>(columnas.map((c) => [c.title, c]))
|
|
this.columnasCache.set(boardId, mapa)
|
|
return mapa
|
|
}
|
|
|
|
/** Resuelve el column ID por título exacto; lanza si no existe en el tablero */
|
|
private async columnaId(boardId: string, titulo: string): Promise<ColumnaMeta> {
|
|
const cols = await this.columnasDe(boardId)
|
|
const col = cols.get(titulo)
|
|
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero ${boardId}`)
|
|
return col
|
|
}
|
|
|
|
/** Ping barato para /api/health */
|
|
async ping(): Promise<boolean> {
|
|
try {
|
|
const data = await this.graphql<{ boards: { id: string }[] }>(
|
|
`query ($ids: [ID!]) { boards(ids: $ids) { id } }`,
|
|
{ ids: [config.monday.boards.unidades] },
|
|
)
|
|
return (data.boards?.length ?? 0) > 0
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/** Invalida el cache de unidades (llamar tras cualquier escritura que las afecte) */
|
|
invalidarCacheUnidades(): void {
|
|
this.unidadesCache = null
|
|
}
|
|
|
|
/** Todas las unidades del tablero, con paginación por cursor y cache de 5 min */
|
|
async getUnidades(): Promise<Unidad[]> {
|
|
const ahora = Date.now()
|
|
if (this.unidadesCache && this.unidadesCache.expira > ahora) {
|
|
return this.unidadesCache.unidades
|
|
}
|
|
const boardId = config.monday.boards.unidades
|
|
const cols = await this.columnasDe(boardId)
|
|
|
|
const items: ItemMonday[] = []
|
|
let cursor: string | null = null
|
|
do {
|
|
// La primera página no lleva cursor; las siguientes sí (nunca asumir una sola página)
|
|
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 name column_values { id text value } } }
|
|
}
|
|
}`,
|
|
{ ids: [boardId] },
|
|
)
|
|
: await this.graphql(
|
|
`query ($ids: [ID!], $cursor: String!) {
|
|
boards(ids: $ids) {
|
|
items_page(limit: 500, cursor: $cursor) { cursor items { id name 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)
|
|
|
|
const itemIds = new Map<string, string>()
|
|
const unidades: Unidad[] = []
|
|
for (const item of items) {
|
|
const unidad = this.mapearUnidad(item, cols)
|
|
// Items placeholder (sin modelo ni precios, p. ej. el "Task 1" default de
|
|
// Monday) no son inventario: se excluyen del listado
|
|
if (unidad.modelo === '' && unidad.precioSinAcabados === 0 && unidad.precioConAcabados === 0) continue
|
|
itemIds.set(item.name, item.id)
|
|
unidades.push(unidad)
|
|
}
|
|
this.unidadesCache = { unidades, itemIds, expira: ahora + CACHE_UNIDADES_MS }
|
|
return unidades
|
|
}
|
|
|
|
/** Una unidad por código (U-101, U-310...); null si no existe */
|
|
async getUnidad(codigo: string): Promise<Unidad | null> {
|
|
const unidades = await this.getUnidades()
|
|
return unidades.find((u) => u.codigo === codigo) ?? null
|
|
}
|
|
|
|
/** Item ID de una unidad por código (para escrituras); null si no existe */
|
|
async itemIdDeUnidad(codigo: string): Promise<string | null> {
|
|
await this.getUnidades() // asegura cache fresco
|
|
return this.unidadesCache?.itemIds.get(codigo) ?? null
|
|
}
|
|
|
|
private mapearUnidad(item: ItemMonday, cols: Map<string, ColumnaMeta>): Unidad {
|
|
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
|
const textoDe = (titulo: string): string => {
|
|
const col = cols.get(titulo)
|
|
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero Unidades`)
|
|
return porId.get(col.id)?.text ?? ''
|
|
}
|
|
const numeroDe = (titulo: string): number => {
|
|
const n = Number(textoDe(titulo))
|
|
return Number.isFinite(n) ? n : 0
|
|
}
|
|
const status = textoDe('Disponibilidad').toUpperCase()
|
|
// La columna "Meses para firma" es opcional (solo la usa SOLANA)
|
|
const colMeses = cols.get('Meses para firma')
|
|
const mesesTexto = colMeses ? (porId.get(colMeses.id)?.text ?? '') : ''
|
|
const mesesNum = Number(mesesTexto)
|
|
// La columna status "Etapa" (pipeline de 10 etapas) es opcional
|
|
const colEtapa = cols.get('Etapa')
|
|
const etapaTexto = colEtapa ? (porId.get(colEtapa.id)?.text ?? '') : ''
|
|
return {
|
|
codigo: item.name,
|
|
modelo: textoDe('Modelo'),
|
|
fechaEntrega: textoDe('Fecha entrega') || null,
|
|
supEstacionamiento: numeroDe('Sup. estacionamiento'),
|
|
supConstruccion: numeroDe('Sup. construcción'),
|
|
supExcedente: numeroDe('Sup. excedente'),
|
|
precioSinAcabados: numeroDe('Precio sin acabados'),
|
|
precioConAcabados: numeroDe('Precio con acabados'),
|
|
// Se conserva el status real del board (VENDIDO, ESCRITURADO, BLOQUEADO, ...)
|
|
status: status || 'DISPONIBLE',
|
|
desarrollo: textoDe('Desarrollo'),
|
|
mesesParaFirma: mesesTexto !== '' && Number.isFinite(mesesNum) ? mesesNum : null,
|
|
etapa: etapaTexto !== '' ? etapaTexto : null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serializa un valor crudo al formato que espera el tipo de columna de Monday.
|
|
* column_values SIEMPRE se envía con JSON.stringify en variables (nunca
|
|
* template literals dentro del query).
|
|
*/
|
|
private valorPorTipo(col: ColumnaMeta, crudo: string | number | boolean): unknown {
|
|
switch (col.type) {
|
|
case 'status':
|
|
return { label: String(crudo) }
|
|
case 'dropdown':
|
|
return { labels: [String(crudo)] }
|
|
case 'date':
|
|
return { date: String(crudo) }
|
|
case 'numbers':
|
|
return typeof crudo === 'number' ? crudo : Number(crudo)
|
|
case 'email':
|
|
return { email: String(crudo), text: String(crudo) }
|
|
case 'phone':
|
|
return { phone: String(crudo), countryShortName: 'MX' }
|
|
case 'checkbox':
|
|
return { checked: crudo === true || crudo === 'true' ? 'true' : 'false' }
|
|
case 'people':
|
|
return { personsAndTeams: [{ id: Number(crudo), kind: 'person' }] }
|
|
case 'board_relation':
|
|
return { item_ids: [Number(crudo)] }
|
|
case 'long_text':
|
|
return { text: String(crudo) }
|
|
default:
|
|
return String(crudo)
|
|
}
|
|
}
|
|
|
|
private async construirColumnValues(
|
|
boardId: string,
|
|
valores: Record<string, string | number | boolean | undefined>,
|
|
): Promise<string> {
|
|
const out: Record<string, unknown> = {}
|
|
for (const [titulo, crudo] of Object.entries(valores)) {
|
|
if (crudo === undefined || crudo === '') continue
|
|
const col = await this.columnaId(boardId, titulo)
|
|
out[col.id] = this.valorPorTipo(col, crudo)
|
|
}
|
|
return JSON.stringify(out)
|
|
}
|
|
|
|
private async crearItem(boardId: string, nombre: string, columnValues: 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 }
|
|
}`,
|
|
{ boardId, itemName: nombre, columnValues },
|
|
)
|
|
return data.create_item.id
|
|
}
|
|
|
|
// ---------- Escrituras de negocio ----------
|
|
|
|
async crearOportunidad(datos: DatosOportunidad): Promise<string> {
|
|
const boardId = config.monday.boards.oportunidades
|
|
const columnValues = await this.construirColumnValues(boardId, {
|
|
Unidad: datos.unidadCodigo,
|
|
'Cliente email': datos.clienteEmail,
|
|
'Cliente teléfono': datos.clienteTelefono,
|
|
'Tipo propuesta': datos.tipoPropuestaLabel,
|
|
'Precio de lista': datos.precioLista,
|
|
'Descuento %': datos.descuentoPct,
|
|
'Precio final': datos.precioFinal,
|
|
'Fecha cotización': datos.fechaCotizacion,
|
|
Estado: datos.estadoLabel,
|
|
})
|
|
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
|
}
|
|
|
|
/**
|
|
* Oportunidad en etapa 'Afluencia' a partir de un lead recién capturado
|
|
* (entrada al funnel; sin unidad ni precios todavía).
|
|
*/
|
|
async crearOportunidadAfluencia(datos: DatosOportunidadAfluencia): Promise<string> {
|
|
const boardId = config.monday.boards.oportunidades
|
|
const columnValues = await this.construirColumnValues(boardId, {
|
|
'Cliente teléfono': datos.telefono,
|
|
'Cliente email': datos.email,
|
|
Desarrollo: datos.desarrolloLabel,
|
|
Estado: 'Afluencia',
|
|
})
|
|
return this.crearItem(boardId, datos.nombre, columnValues)
|
|
}
|
|
|
|
/**
|
|
* Actualiza una oportunidad existente con los datos de una cotización
|
|
* (mismo builder de valores que crearOportunidad) y la mueve a 'Cotizador'.
|
|
*/
|
|
async actualizarOportunidadCotizacion(oportunidadId: string, datos: DatosOportunidad): Promise<void> {
|
|
const boardId = config.monday.boards.oportunidades
|
|
const columnValues = await this.construirColumnValues(boardId, {
|
|
Unidad: datos.unidadCodigo,
|
|
'Tipo propuesta': datos.tipoPropuestaLabel,
|
|
'Precio de lista': datos.precioLista,
|
|
'Descuento %': datos.descuentoPct,
|
|
'Precio final': datos.precioFinal,
|
|
'Fecha cotización': datos.fechaCotizacion,
|
|
Estado: datos.estadoLabel,
|
|
})
|
|
await this.graphql(
|
|
`mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) {
|
|
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id }
|
|
}`,
|
|
{ boardId, itemId: oportunidadId, columnValues },
|
|
)
|
|
}
|
|
|
|
async crearContacto(datos: DatosContacto): Promise<string> {
|
|
const boardId = config.monday.boards.contactos
|
|
const columnValues = await this.construirColumnValues(boardId, {
|
|
Teléfono: datos.telefono,
|
|
Email: datos.email,
|
|
CURP: datos.curp,
|
|
RFC: datos.rfc,
|
|
Dirección: datos.direccion,
|
|
Estado: datos.estadoLabel,
|
|
// Perfil demográfico (vocabulario CRM FRASA); solo se envía lo capturado
|
|
Sexo: datos.sexoLabel,
|
|
'Estado Civil': datos.estadoCivilLabel,
|
|
'Rango de Edad': datos.rangoEdadLabel,
|
|
'Actividad Economica': datos.actividadEconomicaLabel,
|
|
'Vivienda Actual': datos.viviendaActualLabel,
|
|
'Proposito de Compra': datos.propositoCompraLabel,
|
|
'Fecha Nacimiento': datos.fechaNacimiento,
|
|
})
|
|
return this.crearItem(boardId, datos.nombre, columnValues)
|
|
}
|
|
|
|
/**
|
|
* Actualiza un Contacto existente con los campos presentes (mismo builder de
|
|
* valores que crearContacto) — usado por el guardado progresivo del alta.
|
|
* El nombre del item se actualiza por el column id especial 'name'.
|
|
*/
|
|
async actualizarContacto(itemId: string, datos: Partial<DatosContacto>): Promise<void> {
|
|
const boardId = config.monday.boards.contactos
|
|
const valores = JSON.parse(
|
|
await this.construirColumnValues(boardId, {
|
|
Teléfono: datos.telefono,
|
|
Email: datos.email,
|
|
CURP: datos.curp,
|
|
RFC: datos.rfc,
|
|
Dirección: datos.direccion,
|
|
Estado: datos.estadoLabel,
|
|
Sexo: datos.sexoLabel,
|
|
'Estado Civil': datos.estadoCivilLabel,
|
|
'Rango de Edad': datos.rangoEdadLabel,
|
|
'Actividad Economica': datos.actividadEconomicaLabel,
|
|
'Vivienda Actual': datos.viviendaActualLabel,
|
|
'Proposito de Compra': datos.propositoCompraLabel,
|
|
'Fecha Nacimiento': datos.fechaNacimiento,
|
|
}),
|
|
) as Record<string, unknown>
|
|
if (datos.nombre !== undefined && datos.nombre !== '') valores['name'] = datos.nombre
|
|
await this.graphql(
|
|
`mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) {
|
|
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id }
|
|
}`,
|
|
{ boardId, itemId, columnValues: JSON.stringify(valores) },
|
|
)
|
|
}
|
|
|
|
async crearApartado(datos: DatosApartado): Promise<string> {
|
|
const boardId = config.monday.boards.apartado
|
|
const columnValues = await this.construirColumnValues(boardId, {
|
|
Unidad: datos.unidadCodigo,
|
|
'Monto apartado': datos.montoApartado,
|
|
'Fecha apartado': datos.fechaApartado,
|
|
'Fecha límite': datos.fechaLimite,
|
|
Estado: datos.estadoLabel,
|
|
})
|
|
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
|
}
|
|
|
|
async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise<void> {
|
|
const boardId = config.monday.boards.unidades
|
|
// Asegura cache fresco para resolver el itemId
|
|
await this.getUnidades()
|
|
const itemId = this.unidadesCache?.itemIds.get(codigo)
|
|
if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`)
|
|
const col = await this.columnaId(boardId, 'Disponibilidad')
|
|
await this.graphql(
|
|
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
|
|
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
|
|
}`,
|
|
{ boardId, itemId, columnId: col.id, value: JSON.stringify({ label: status }) },
|
|
)
|
|
this.invalidarCacheUnidades()
|
|
}
|
|
|
|
// ---------- Escrituras genéricas ----------
|
|
|
|
/**
|
|
* Cambia UNA columna de un item, resolviendo el column ID por título y
|
|
* serializando según el tipo de columna (status, dropdown, checkbox, date...).
|
|
*/
|
|
async cambiarValorColumna(
|
|
boardId: string,
|
|
itemId: string,
|
|
tituloColumna: string,
|
|
valor: string | number | boolean,
|
|
): Promise<void> {
|
|
const col = await this.columnaId(boardId, tituloColumna)
|
|
await this.graphql(
|
|
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
|
|
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
|
|
}`,
|
|
{ boardId, itemId, columnId: col.id, value: JSON.stringify(this.valorPorTipo(col, valor)) },
|
|
)
|
|
}
|
|
|
|
/** Nombre de un item por ID (cualquier tablero) */
|
|
async obtenerNombreItem(itemId: string): Promise<string> {
|
|
const data = await this.graphql<{ items: { name: string }[] }>(
|
|
`query ($ids: [ID!]) { items(ids: $ids) { name } }`,
|
|
{ ids: [itemId] },
|
|
)
|
|
const nombre = data.items[0]?.name
|
|
if (nombre === undefined) throw new AppError(404, `Item ${itemId} no encontrado en Monday`)
|
|
return nombre
|
|
}
|
|
|
|
// ---------- Motivos No Compra / Actividades (tableros CRM FRASA) ----------
|
|
|
|
/** Item en el tablero Motivos No Compra (nombre = "<motivo> — <item origen>") */
|
|
async crearMotivoNoCompra(datos: DatosMotivoNoCompra): Promise<string> {
|
|
const boardId = config.monday.boards.motivos
|
|
if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_MOTIVOS no está configurado')
|
|
const columnValues = await this.construirColumnValues(boardId, {
|
|
Categoria: datos.categoria,
|
|
Descripcion: datos.descripcion,
|
|
})
|
|
return this.crearItem(boardId, datos.nombre, columnValues)
|
|
}
|
|
|
|
/** Actividad de seguimiento ligada a un lead (bitácora del CRM) */
|
|
async crearActividad(datos: DatosActividad): Promise<string> {
|
|
const boardId = config.monday.boards.actividades
|
|
if (boardId === '') throw new AppError(500, 'MONDAY_BOARD_ACTIVIDADES no está configurado')
|
|
// La columna board_relation "Lead" no puede crearse vía API (limitación de
|
|
// Monday): si existe en el tablero se liga, si no, la actividad se crea igual.
|
|
let lead: string | undefined
|
|
try {
|
|
await this.columnaId(boardId, 'Lead')
|
|
lead = datos.leadItemId
|
|
} catch {
|
|
lead = undefined
|
|
}
|
|
const columnValues = await this.construirColumnValues(boardId, {
|
|
Tipo: datos.tipoLabel,
|
|
Responsable: datos.responsableId,
|
|
Fecha: datos.fecha,
|
|
Estado: datos.estadoLabel,
|
|
Lead: lead,
|
|
})
|
|
return this.crearItem(boardId, datos.nombre, columnValues)
|
|
}
|
|
|
|
// ---------- Leads (automatizaciones) ----------
|
|
|
|
/** Items del tablero Leads con estado, fecha de actualización y vendedor asignado */
|
|
async getLeads(): Promise<LeadMonday[]> {
|
|
const boardId = config.monday.boards.leads
|
|
const cols = await this.columnasDe(boardId)
|
|
|
|
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) => this.mapearLead(item, cols))
|
|
}
|
|
|
|
/** Un lead del tablero Leads por item ID (el payload del webhook no trae datos) */
|
|
async getLeadPorId(itemId: string): Promise<LeadMonday | null> {
|
|
const boardId = config.monday.boards.leads
|
|
const cols = await this.columnasDe(boardId)
|
|
const data = await this.graphql<{ items: (ItemConFechas | null)[] }>(
|
|
`query ($ids: [ID!]) {
|
|
items(ids: $ids) { id name created_at updated_at column_values { id text value } }
|
|
}`,
|
|
{ ids: [itemId] },
|
|
)
|
|
const item = data.items[0]
|
|
if (!item) return null
|
|
return this.mapearLead(item, cols)
|
|
}
|
|
|
|
private mapearLead(item: ItemConFechas, cols: Map<string, ColumnaMeta>): LeadMonday {
|
|
const colVendedor = cols.get('Vendedor')
|
|
if (!colVendedor || colVendedor.type !== 'people') {
|
|
throw new AppError(500, `Columna people "Vendedor" no existe en el tablero Leads`)
|
|
}
|
|
const colEstado = cols.get('Estado')
|
|
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
|
const rawVendedor = porId.get(colVendedor.id)?.value
|
|
let vendedorIds: number[] = []
|
|
if (rawVendedor) {
|
|
try {
|
|
const parsed = JSON.parse(rawVendedor) as { personsAndTeams?: { id: number; kind: string }[] }
|
|
vendedorIds = (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id)
|
|
} catch {
|
|
vendedorIds = []
|
|
}
|
|
}
|
|
const colOrigen = cols.get('Origen')
|
|
const colTelefono = cols.get('Teléfono')
|
|
const colEmail = cols.get('Email')
|
|
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 ?? '') : '',
|
|
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
|
|
email: colEmail ? (porId.get(colEmail.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,
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------- Mi día (CRM ligero para vendedores) ----------
|
|
|
|
/** Parsea el value JSON de una columna people a los user IDs (solo personas) */
|
|
private peopleIds(raw: string | null | undefined): number[] {
|
|
if (!raw) return []
|
|
try {
|
|
const parsed = JSON.parse(raw) as { personsAndTeams?: { id: number; kind: string }[] }
|
|
return (parsed.personsAndTeams ?? []).filter((p) => p.kind === 'person').map((p) => p.id)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
/** Todas las actividades del tablero Actividades, con tipo, fecha, estado y responsables */
|
|
async getActividades(): Promise<ActividadMonday[]> {
|
|
const boardId = config.monday.boards.actividades
|
|
if (boardId === '') return []
|
|
const cols = await this.columnasDe(boardId)
|
|
const colTipo = cols.get('Tipo')
|
|
const colFecha = cols.get('Fecha')
|
|
const colEstado = cols.get('Estado')
|
|
const colResponsable = cols.get('Responsable')
|
|
|
|
const items: ItemMonday[] = []
|
|
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 name column_values { id text value } }
|
|
}
|
|
}
|
|
}`,
|
|
{ ids: [boardId] },
|
|
)
|
|
: await this.graphql(
|
|
`query ($ids: [ID!], $cursor: String!) {
|
|
boards(ids: $ids) {
|
|
items_page(limit: 500, cursor: $cursor) {
|
|
cursor
|
|
items { id name 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]))
|
|
return {
|
|
id: item.id,
|
|
nombre: item.name,
|
|
tipo: colTipo ? (porId.get(colTipo.id)?.text ?? '') : '',
|
|
fecha: colFecha ? (porId.get(colFecha.id)?.text ?? '') : '',
|
|
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
|
|
responsableIds: colResponsable ? this.peopleIds(porId.get(colResponsable.id)?.value) : [],
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Todos los apartados del tablero Apartado, con unidad, fecha límite y estado */
|
|
async getApartados(): Promise<ApartadoMonday[]> {
|
|
const boardId = config.monday.boards.apartado
|
|
const cols = await this.columnasDe(boardId)
|
|
const colUnidad = cols.get('Unidad')
|
|
const colFechaLimite = cols.get('Fecha límite')
|
|
const colEstado = cols.get('Estado')
|
|
|
|
const items: ItemMonday[] = []
|
|
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 name column_values { id text value } }
|
|
}
|
|
}
|
|
}`,
|
|
{ ids: [boardId] },
|
|
)
|
|
: await this.graphql(
|
|
`query ($ids: [ID!], $cursor: String!) {
|
|
boards(ids: $ids) {
|
|
items_page(limit: 500, cursor: $cursor) {
|
|
cursor
|
|
items { id name 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]))
|
|
return {
|
|
id: item.id,
|
|
nombre: item.name,
|
|
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
|
|
fechaLimite: colFechaLimite ? (porId.get(colFechaLimite.id)?.text ?? '') : '',
|
|
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Labels de una columna status/dropdown (settings_str del board).
|
|
* Monday los devuelve como objeto {index: label} o como arreglo [{id, name}].
|
|
*/
|
|
async labelsDeColumna(boardId: string, titulo: string): Promise<string[]> {
|
|
const data = await this.graphql<{ boards: { columns: { id: string; title: string; settings_str: string }[] }[] }>(
|
|
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title settings_str } } }`,
|
|
{ ids: [boardId] },
|
|
)
|
|
const col = data.boards[0]?.columns.find((c) => c.title === titulo)
|
|
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero ${boardId}`)
|
|
try {
|
|
const settings = JSON.parse(col.settings_str || '{}') as { labels?: Record<string, string> | { name: string }[] }
|
|
const labels = settings.labels
|
|
if (Array.isArray(labels)) return labels.map((l) => l.name)
|
|
if (labels && typeof labels === 'object') return Object.values(labels)
|
|
return []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
/** Item en el tablero Leads (Estado NUEVO; captura rápida del vendedor) */
|
|
async crearLead(datos: DatosLead): Promise<string> {
|
|
const boardId = config.monday.boards.leads
|
|
const columnValues = await this.construirColumnValues(boardId, {
|
|
Estado: 'NUEVO',
|
|
Teléfono: datos.telefono,
|
|
Email: datos.email,
|
|
Origen: datos.origenLabel,
|
|
Desarrollo: datos.desarrolloLabel,
|
|
Notas: datos.notas,
|
|
'Tipo de Propiedad': datos.tipoPropiedadLabel,
|
|
'Medio de Contacto': datos.medioContacto,
|
|
Edad: datos.edad,
|
|
'Estado Civil': datos.estadoCivilLabel,
|
|
'Tipo de Empleo': datos.tipoEmpleoLabel,
|
|
Ocupación: datos.ocupacion,
|
|
'Rango Salarial': datos.rangoSalarial,
|
|
Crédito: datos.creditoLabel,
|
|
'Tipo de Economía': datos.tipoEconomiaLabel,
|
|
Califica: datos.calificaLabel,
|
|
'Lo Quiere Para': datos.loQuierePara,
|
|
})
|
|
return this.crearItem(boardId, datos.nombre, columnValues)
|
|
}
|
|
|
|
/**
|
|
* Lead con el mismo teléfono (dedup de captura rápida). Compara solo dígitos
|
|
* y los últimos 10 (tolerante a lada/prefijo 52). null si no hay duplicado.
|
|
*/
|
|
async buscarLeadPorTelefono(telefono: string): Promise<LeadMonday | null> {
|
|
const digitos = telefono.replace(/\D/g, '')
|
|
if (digitos.length < 10) return null
|
|
const clave = digitos.slice(-10)
|
|
const leads = await this.getLeads()
|
|
return (
|
|
leads.find((l) => {
|
|
const d = l.telefono.replace(/\D/g, '')
|
|
return d.length >= 10 && d.slice(-10) === clave
|
|
}) ?? null
|
|
)
|
|
}
|
|
|
|
/** Borra un item de cualquier tablero (limpieza de datos de prueba) */
|
|
async eliminarItem(itemId: string): Promise<void> {
|
|
await this.graphql<{ delete_item: { id: string } }>(
|
|
`mutation ($itemId: ID!) { delete_item(item_id: $itemId) { id } }`,
|
|
{ itemId },
|
|
)
|
|
}
|
|
|
|
/** 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
|
|
const col = await this.columnaId(boardId, 'Vendedor')
|
|
await this.graphql(
|
|
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
|
|
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
|
|
}`,
|
|
{
|
|
boardId,
|
|
itemId,
|
|
columnId: col.id,
|
|
value: JSON.stringify({ personsAndTeams: [{ id: userId, kind: 'person' }] }),
|
|
},
|
|
)
|
|
}
|
|
|
|
/** Crea un update (comentario) en un item — usado para los recordatorios de inactividad */
|
|
async crearUpdateEnItem(itemId: string, body: string): Promise<string> {
|
|
const data = await this.graphql<{ create_update: { id: string } }>(
|
|
`mutation ($itemId: ID!, $body: String!) {
|
|
create_update(item_id: $itemId, body: $body) { id }
|
|
}`,
|
|
{ itemId, body },
|
|
)
|
|
return data.create_update.id
|
|
}
|
|
|
|
/**
|
|
* Usuarios activos no-guest del workspace (excluye las cuentas bot de Monday,
|
|
* cuyo email termina en @agent.monday.com).
|
|
*/
|
|
async getUsuariosWorkspace(): Promise<UsuarioMonday[]> {
|
|
const data = await this.graphql<{
|
|
users: { id: string; name: string; email: string; is_guest: boolean; enabled: boolean }[]
|
|
}>(`query { users(limit: 50) { id name email is_guest enabled } }`)
|
|
return data.users
|
|
.filter((u) => !u.is_guest && u.enabled && !u.email.endsWith('@agent.monday.com'))
|
|
.map((u) => ({ id: Number(u.id), nombre: u.name, email: u.email }))
|
|
}
|
|
}
|
|
|
|
// ---------- DTOs de escritura ----------
|
|
|
|
export interface DatosOportunidad {
|
|
clienteNombre: string
|
|
unidadCodigo: string
|
|
tipoPropuestaLabel: string
|
|
precioLista: number
|
|
descuentoPct: number // porcentaje (2 = 2%)
|
|
precioFinal: number
|
|
fechaCotizacion: string // 'YYYY-MM-DD'
|
|
estadoLabel: string // p. ej. 'COTIZADA'
|
|
clienteEmail?: string
|
|
clienteTelefono?: string
|
|
}
|
|
|
|
export interface DatosOportunidadAfluencia {
|
|
nombre: string // name del item = nombre del lead
|
|
telefono?: string | undefined
|
|
email?: string | undefined
|
|
desarrolloLabel?: string | undefined // label tal cual de la columna status 'Desarrollo'
|
|
}
|
|
|
|
export interface DatosContacto {
|
|
nombre: string
|
|
telefono?: string
|
|
email?: string
|
|
curp?: string
|
|
rfc?: string
|
|
direccion?: string
|
|
estadoLabel: string // p. ej. 'CLIENTE'
|
|
// Perfil demográfico (labels del dropdown correspondiente)
|
|
sexoLabel?: string | undefined
|
|
estadoCivilLabel?: string | undefined
|
|
rangoEdadLabel?: string | undefined
|
|
actividadEconomicaLabel?: string | undefined
|
|
viviendaActualLabel?: string | undefined
|
|
propositoCompraLabel?: string | undefined
|
|
fechaNacimiento?: string | undefined // 'YYYY-MM-DD'
|
|
}
|
|
|
|
export interface DatosApartado {
|
|
clienteNombre: string
|
|
unidadCodigo: string
|
|
montoApartado?: number
|
|
fechaApartado: string // 'YYYY-MM-DD'
|
|
fechaLimite: string // 'YYYY-MM-DD'
|
|
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
|
|
descripcion?: string
|
|
}
|
|
|
|
export interface DatosActividad {
|
|
nombre: string
|
|
tipoLabel: string // 'Llamada' | 'Reunion' | 'Tarea'
|
|
responsableId?: number // user ID de Monday (se omite si no hay responsable claro)
|
|
fecha: string // 'YYYY-MM-DD'
|
|
estadoLabel: string // 'Abierto' | 'Listo'
|
|
leadItemId?: string // item del tablero Leads relacionado (se omite si no aplica)
|
|
}
|
|
|
|
export interface DatosLead {
|
|
nombre: string
|
|
telefono?: string | undefined
|
|
email?: string | undefined
|
|
origenLabel?: string | undefined // label real de la columna status 'Origen' del board Leads
|
|
desarrolloLabel?: string | undefined // label real de la columna status 'Desarrollo' del board Leads
|
|
notas?: string | undefined
|
|
// Campos de calificación del interesado (labels reales de las columnas status del board Leads)
|
|
tipoPropiedadLabel?: string | undefined
|
|
medioContacto?: string | undefined
|
|
edad?: number | undefined
|
|
estadoCivilLabel?: string | undefined
|
|
tipoEmpleoLabel?: string | undefined
|
|
ocupacion?: string | undefined
|
|
rangoSalarial?: string | undefined
|
|
creditoLabel?: string | undefined
|
|
tipoEconomiaLabel?: string | undefined
|
|
calificaLabel?: string | undefined
|
|
loQuierePara?: string | undefined
|
|
}
|
|
|
|
/** Singleton usado por los controllers (los tests instancian el suyo) */
|
|
export const mondayService = new MondayService()
|
|
|