- Al avanzar cada paso del wizard: upsert silencioso del borrador local + sync a Monday (iniciar → Contacto en PROSPECTO; pasos siguientes → PATCH con los campos mapeables: datos generales, dirección armada, actividad económica) - Al completar: Contacto pasa a CLIENTE + Apartado + funnel (dedup) - progresoId viaja con el borrador (se restaura solo); fallos de sync no bloquean el avance (aviso ámbar no bloqueante); submit final con fallback al endpoint legado si nunca hubo sync - API: POST /clientes/progreso/iniciar, PATCH /clientes/progreso/:id, POST /clientes/progreso/:id/completar; POST /clientes legado intacto - Tests: API 338 (+16), alta-clientes 112 (+4); smoke en vivo verificado (PROSPECTO → patch → CLIENTE + Apartado) y limpiado
1091 lines
40 KiB
TypeScript
1091 lines
40 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,
|
|
})
|
|
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
|
|
}
|
|
|
|
/** Singleton usado por los controllers (los tests instancian el suyo) */
|
|
export const mondayService = new MondayService()
|
|
|