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:
2026-08-28 06:23:15 +00:00
parent 1f5b6ee088
commit bc0f9da8fe
31 changed files with 5917 additions and 3 deletions

View File

@@ -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()