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

@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest'
import {
cambiarEstadoLead,
crearLead,
descartarLead,
ESTADOS_LEAD_PERMITIDOS,
} from './leads.controller.js'
import type { LeadMonday } from '../services/monday.service.js'
/** Monday mockeado: registra escrituras y devuelve catálogos controlados */
function mondayMock(opts: { labelsOrigen?: string[]; duplicado?: LeadMonday | null } = {}) {
return {
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
leadsCreados: [] as Record<string, unknown>[],
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
this.cambios.push({ boardId, itemId, columna, valor })
},
async crearLead(datos: Record<string, unknown>) {
this.leadsCreados.push(datos)
return 'lead-nuevo-1'
},
async buscarLeadPorTelefono(_telefono: string) {
return opts.duplicado ?? null
},
async labelsDeColumna(_boardId: string, _titulo: string) {
return opts.labelsOrigen ?? ['WhatsApp', 'Web', 'Recomendado', 'Facebook', 'Otro']
},
async obtenerNombreItem(_itemId: string) {
return 'Lead Prueba'
},
async crearMotivoNoCompra(datos: { nombre: string; categoria: string; descripcion?: string }) {
this.motivos.push(datos)
return 'motivo-item-1'
},
}
}
const datosBase = { nombre: 'Lead Prueba', telefono: '4771234567' }
describe('crearLead (POST /api/leads)', () => {
it('crea el item en Monday con Estado NUEVO y devuelve su id', async () => {
const monday = mondayMock()
const r = await crearLead(monday, { ...datosBase, origen: 'Facebook', notas: 'Vio el volante' })
expect(r).toEqual({ id: 'lead-nuevo-1' })
expect(monday.leadsCreados).toEqual([
{ nombre: 'Lead Prueba', telefono: '4771234567', email: undefined, origenLabel: 'Facebook', notas: 'Vio el volante' },
])
})
it('duplicado por teléfono: no crea nada y devuelve el lead existente (endpoint → 409)', async () => {
const existente: LeadMonday = {
id: 'lead-viejo-9',
nombre: 'Lead Prueba',
createdAt: '',
updatedAt: '',
estado: 'NUEVO',
origen: '',
telefono: '4771234567',
email: '',
vendedorIds: [],
}
const monday = mondayMock({ duplicado: existente })
const r = await crearLead(monday, datosBase)
expect(r).toEqual({ duplicado: existente })
expect(monday.leadsCreados).toEqual([])
})
it('rechaza un origen que no sea label real de la columna Origen', async () => {
const monday = mondayMock()
await expect(crearLead(monday, { ...datosBase, origen: 'Volanteo' })).rejects.toThrow(/Origen inválido/)
expect(monday.leadsCreados).toEqual([])
})
})
describe('cambiarEstadoLead (POST /api/leads/:itemId/estado)', () => {
it('la whitelist solo permite las transiciones del vendedor', () => {
expect(ESTADOS_LEAD_PERMITIDOS).toEqual(['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'])
// DESCARTADO tiene su propio endpoint (lleva motivo); NUEVO no es acción
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('DESCARTADO')
expect(ESTADOS_LEAD_PERMITIDOS).not.toContain('NUEVO')
})
it('cambia solo la columna Estado en el board Leads (444 en tests)', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'CONTACTADO')
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'CONTACTADO' }])
})
it('AGENDA CITA con fecha también setea la columna Fecha', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA', '2026-09-01')
expect(monday.cambios).toEqual([
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'AGENDA CITA' },
{ boardId: '444', itemId: 'lead-1', columna: 'Fecha', valor: '2026-09-01' },
])
})
it('AGENDA CITA sin fecha no toca la columna Fecha', async () => {
const monday = mondayMock()
await cambiarEstadoLead(monday, 'lead-1', 'AGENDA CITA')
expect(monday.cambios).toHaveLength(1)
})
})
describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
it('Estado DESCARTADO + Notas + registro en Motivos No Compra', async () => {
const monday = mondayMock()
const motivoItemId = await descartarLead(monday, 'lead-1', 'Precio', 'Se le hizo caro')
expect(motivoItemId).toBe('motivo-item-1')
expect(monday.cambios).toEqual([
{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' },
{ boardId: '444', itemId: 'lead-1', columna: 'Notas', valor: 'Se le hizo caro' },
])
expect(monday.motivos).toEqual([
{ nombre: 'Precio — Lead Prueba', categoria: 'Precio', descripcion: 'Se le hizo caro' },
])
})
it('sin comentario no escribe Notas', async () => {
const monday = mondayMock()
await descartarLead(monday, 'lead-1', 'Timing')
expect(monday.cambios).toEqual([{ boardId: '444', itemId: 'lead-1', columna: 'Estado', valor: 'DESCARTADO' }])
expect(monday.motivos[0]).toEqual({ nombre: 'Timing — Lead Prueba', categoria: 'Timing' })
})
it('propaga el error si Monday falla (el error handler responde 5xx, no truena el server)', async () => {
const monday = mondayMock()
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
await expect(descartarLead(monday, 'lead-1', 'Cliente')).rejects.toThrow('Monday caído')
expect(monday.motivos).toEqual([])
})
})

View File

@@ -0,0 +1,146 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type LeadMonday, type MondayService } from '../services/monday.service.js'
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
/**
* CRM ligero sobre el tablero Leads: captura rápida y acciones del vendedor.
* Los labels de estado/origen son los reales del board (verificados contra
* Monday); la whitelist evita escribir labels que el status no tenga dados de alta.
*/
// Transiciones que el vendedor puede hacer desde la app Mi día
export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'] as const
const crearLeadSchema = z.object({
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
telefono: z.string().trim().min(1).optional(),
email: z.string().trim().email('Email inválido').optional(),
origen: z.string().trim().min(1).optional(),
notas: z.string().trim().min(1).optional(),
})
const cambiarEstadoSchema = z.object({
estado: z.enum(ESTADOS_LEAD_PERMITIDOS),
fecha: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe ser YYYY-MM-DD')
.optional(),
})
const descartarSchema = z.object({
motivo: z.enum(MOTIVOS_NO_COMPRA),
comentario: z.string().trim().min(1).optional(),
})
type MondayLeads = Pick<
MondayService,
| 'cambiarValorColumna'
| 'crearLead'
| 'crearMotivoNoCompra'
| 'buscarLeadPorTelefono'
| 'labelsDeColumna'
| 'obtenerNombreItem'
>
export type ResultadoCrearLead = { id: string } | { duplicado: LeadMonday }
/**
* Crea un lead en Monday (Estado NUEVO). Si viene teléfono y ya existe un lead
* con ese número, no crea nada y devuelve { duplicado } (el endpoint → 409).
* El origen se valida contra los labels reales de la columna del board.
*/
export async function crearLead(
monday: MondayLeads,
datos: z.infer<typeof crearLeadSchema>,
): Promise<ResultadoCrearLead> {
const boardLeads = config.monday.boards.leads
if (datos.telefono !== undefined) {
const existente = await monday.buscarLeadPorTelefono(datos.telefono)
if (existente) return { duplicado: existente }
}
if (datos.origen !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Origen')
if (!labels.includes(datos.origen)) {
throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`)
}
}
const id = await monday.crearLead({
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
origenLabel: datos.origen,
notas: datos.notas,
})
return { id }
}
/** Cambia el estado del lead (whitelist) y, en AGENDA CITA, puede setear la Fecha */
export async function cambiarEstadoLead(
monday: MondayLeads,
itemId: string,
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
fecha?: string,
): Promise<void> {
const boardLeads = config.monday.boards.leads
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', estado)
if (estado === 'AGENDA CITA' && fecha !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Fecha', fecha)
}
}
/** Estado DESCARTADO + registro en el catálogo Motivos No Compra (+ Notas opcional) */
export async function descartarLead(
monday: MondayLeads,
itemId: string,
motivo: string,
comentario?: string,
): Promise<string> {
const boardLeads = config.monday.boards.leads
const nombreItem = await monday.obtenerNombreItem(itemId)
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'DESCARTADO')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario)
}
return monday.crearMotivoNoCompra({
nombre: `${motivo} — ${nombreItem}`,
categoria: motivo,
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
}
/** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = crearLeadSchema.parse(req.body)
const resultado = await crearLead(mondayService, body)
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
leadId: resultado.duplicado.id,
nombre: resultado.duplicado.nombre,
})
return
}
res.status(201).json({ id: resultado.id, estado: 'NUEVO' })
}
/** POST /api/leads/:itemId/estado { estado, fecha? } */
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha)
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
}
/** POST /api/leads/:itemId/descartar { motivo, comentario? } */
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = descartarSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario)
res.json({ itemId, estado: 'DESCARTADO', motivo: body.motivo, motivoItemId })
}

View File

@@ -0,0 +1,36 @@
import type { Request, Response } from 'express'
import { prisma } from '../services/prisma.js'
import { mondayService } from '../services/monday.service.js'
import { resumenMiDia } from '../services/mi-dia.service.js'
import { AppError } from '../utils/errors.js'
/**
* GET /api/mi-dia/resumen — agenda del vendedor logueado.
* El "mío" se resuelve por Usuario.mondayUserId (mapeo usuario↔Monday);
* sin mapeo, las listas personales van vacías con sinMapeoMonday: true.
*/
export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise<void> {
// Con AUTH_ENABLED=false (arranque temprano/tests) no hay token: se usa el admin
const sub = req.usuarioToken?.sub
const usuario = sub
? await prisma.usuario.findUnique({ where: { id: sub } })
: await prisma.usuario.findUnique({ where: { username: 'admin' } })
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
// Nombres de los usuarios ya mapeados a Monday (para la sección equipo)
const mapeados = await prisma.usuario.findMany({
where: { mondayUserId: { not: null }, activo: true },
select: { nombre: true, mondayUserId: true },
})
const nombresPorMondayId = new Map<number, string>(
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
)
res.json(
await resumenMiDia(
mondayService,
{ nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId },
nombresPorMondayId,
),
)
}