feat(mi-dia): app /hoy + CRM ligero para vendedores
- App Mi día: KPIs, mis actividades, mis leads con acciones rápidas (contactado/intento/agenda cita/descartar con motivo), captura rápida de lead (409 en duplicado por teléfono), vista equipo para coordinadores - API: GET /api/mi-dia/resumen, POST /api/leads, /leads/:id/estado, /leads/:id/descartar; mapeo Usuario.mondayUserId (seed listo para cuando inviten a los vendedores a Monday) - Landing: tarjeta Mi día; tests api 188, mi-dia 8, landing 4
This commit is contained in:
@@ -36,9 +36,30 @@ export interface LeadMonday {
|
||||
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
|
||||
@@ -544,6 +565,8 @@ export class MondayService {
|
||||
}
|
||||
}
|
||||
const colOrigen = cols.get('Origen')
|
||||
const colTelefono = cols.get('Teléfono')
|
||||
const colEmail = cols.get('Email')
|
||||
return {
|
||||
id: item.id,
|
||||
nombre: item.name,
|
||||
@@ -551,6 +574,8 @@ export class MondayService {
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -615,6 +640,188 @@ export class MondayService {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 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,
|
||||
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
|
||||
@@ -773,6 +980,14 @@ export interface DatosActividad {
|
||||
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
|
||||
notas?: string | undefined
|
||||
}
|
||||
|
||||
/** Singleton usado por los controllers (los tests instancian el suyo) */
|
||||
export const mondayService = new MondayService()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user