feat(mi-dia): seccion 'Mis capturas de afluencia' en el panel del vendedor
Nueva tabla LeadCaptura (quien capturo cada lead, algo que Monday no guarda porque el creador del item siempre es el token del API). El POST /api/leads la registra con el usuario del token y el resumen de Mi dia la enriquece con el estado/origen actual del lead en Monday. La captura rapida refresca el panel al registrar.
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LeadCaptura" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"leadId" TEXT NOT NULL,
|
||||||
|
"oportunidadId" TEXT,
|
||||||
|
"usuarioId" TEXT NOT NULL,
|
||||||
|
"nombreLead" TEXT NOT NULL,
|
||||||
|
"telefono" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "LeadCaptura_leadId_key" ON "LeadCaptura"("leadId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "LeadCaptura_usuarioId_idx" ON "LeadCaptura"("usuarioId");
|
||||||
|
|
||||||
@@ -150,6 +150,21 @@ model LeadOportunidad {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registro local de qué usuario del ecosistema capturó cada lead (alta de
|
||||||
|
// afluencia). Monday no guarda al capturista (el creador del item siempre es
|
||||||
|
// el token del API); esta tabla alimenta "Mis capturas" del panel Mi día.
|
||||||
|
model LeadCaptura {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
leadId String @unique // item del tablero Leads (Monday)
|
||||||
|
oportunidadId String? // item del tablero Oportunidades ligado al capturar
|
||||||
|
usuarioId String // Usuario.id del capturista
|
||||||
|
nombreLead String
|
||||||
|
telefono String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([usuarioId])
|
||||||
|
}
|
||||||
|
|
||||||
// Estado persistente de las automatizaciones de Monday (round-robin, recordatorios).
|
// Estado persistente de las automatizaciones de Monday (round-robin, recordatorios).
|
||||||
// Clave-valor genérico: 'rr:indice', 'rr:vendedores', 'recordatorio:<itemId>:<fecha>', etc.
|
// Clave-valor genérico: 'rr:indice', 'rr:vendedores', 'recordatorio:<itemId>:<fecha>', etc.
|
||||||
model AutomatizacionState {
|
model AutomatizacionState {
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { Request, Response } from 'express'
|
||||||
import {
|
import {
|
||||||
buscarLeads,
|
buscarLeads,
|
||||||
buscarLeadsEndpoint,
|
buscarLeadsEndpoint,
|
||||||
cambiarEstadoLead,
|
cambiarEstadoLead,
|
||||||
crearLead,
|
crearLead,
|
||||||
|
crearLeadEndpoint,
|
||||||
descartarLead,
|
descartarLead,
|
||||||
ESTADOS_LEAD_PERMITIDOS,
|
ESTADOS_LEAD_PERMITIDOS,
|
||||||
} from './leads.controller.js'
|
} from './leads.controller.js'
|
||||||
import type { LeadMonday } from '../services/monday.service.js'
|
import { mondayService, type LeadMonday } from '../services/monday.service.js'
|
||||||
|
import { funnelService } from '../services/funnel.service.js'
|
||||||
|
|
||||||
|
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
|
||||||
|
|
||||||
|
const prismaMock = vi.hoisted(() => ({
|
||||||
|
leadCaptura: {
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||||
|
|
||||||
/** Monday mockeado: registra escrituras y devuelve catálogos controlados */
|
/** Monday mockeado: registra escrituras y devuelve catálogos controlados */
|
||||||
function mondayMock(
|
function mondayMock(
|
||||||
@@ -306,6 +319,69 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('crearLeadEndpoint (POST /api/leads)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// El singleton Monday responde como si creara el lead; el funnel liga la oportunidad
|
||||||
|
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null)
|
||||||
|
vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9')
|
||||||
|
vi.spyOn(funnelService, 'asegurarOportunidadAfluencia').mockResolvedValue('op-9')
|
||||||
|
})
|
||||||
|
|
||||||
|
function reqFake(overrides: Partial<Request> = {}): Request {
|
||||||
|
return {
|
||||||
|
body: { nombre: 'Lead Prueba', telefono: '4771234567' },
|
||||||
|
usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' },
|
||||||
|
...overrides,
|
||||||
|
} as Request
|
||||||
|
}
|
||||||
|
|
||||||
|
function resFake() {
|
||||||
|
const json = vi.fn()
|
||||||
|
const status = vi.fn().mockReturnValue({ json })
|
||||||
|
return { res: { json, status } as unknown as Response, status, json }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('201: registra la captura local con el usuario del token', async () => {
|
||||||
|
prismaMock.leadCaptura.create.mockResolvedValue({})
|
||||||
|
const { res, status, json } = resFake()
|
||||||
|
|
||||||
|
await crearLeadEndpoint(reqFake(), res)
|
||||||
|
|
||||||
|
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
leadId: 'lead-nuevo-9',
|
||||||
|
oportunidadId: 'op-9',
|
||||||
|
usuarioId: 'u-1',
|
||||||
|
nombreLead: 'Lead Prueba',
|
||||||
|
telefono: '4771234567',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
|
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sin token (AUTH_ENABLED=false) no registra captura y responde 201 igual', async () => {
|
||||||
|
const { res, status } = resFake()
|
||||||
|
|
||||||
|
await crearLeadEndpoint(reqFake({ usuarioToken: undefined }), res)
|
||||||
|
|
||||||
|
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
||||||
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un fallo del registro local no rompe la respuesta (el lead ya quedó en Monday)', async () => {
|
||||||
|
prismaMock.leadCaptura.create.mockRejectedValue(new Error('SQLite bloqueado'))
|
||||||
|
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const { res, status } = resFake()
|
||||||
|
|
||||||
|
await crearLeadEndpoint(reqFake(), res)
|
||||||
|
|
||||||
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
|
espia.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('buscarLeads (GET /api/leads/buscar)', () => {
|
describe('buscarLeads (GET /api/leads/buscar)', () => {
|
||||||
const lead = (overrides: Partial<LeadMonday>): LeadMonday => ({
|
const lead = (overrides: Partial<LeadMonday>): LeadMonday => ({
|
||||||
id: 'lead-1',
|
id: 'lead-1',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|||||||
import { config } from '../config/index.js'
|
import { config } from '../config/index.js'
|
||||||
import { mondayService, type LeadMonday, type MondayService } from '../services/monday.service.js'
|
import { mondayService, type LeadMonday, type MondayService } from '../services/monday.service.js'
|
||||||
import { funnelService, type FunnelService } from '../services/funnel.service.js'
|
import { funnelService, type FunnelService } from '../services/funnel.service.js'
|
||||||
|
import { prisma } from '../services/prisma.js'
|
||||||
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
|
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
|
||||||
import { AppError } from '../utils/errors.js'
|
import { AppError } from '../utils/errors.js'
|
||||||
|
|
||||||
@@ -236,6 +237,25 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Registro local del capturista para "Mis capturas" del panel Mi día (Monday
|
||||||
|
// no guarda quién dio de alta el lead). Un fallo aquí no rompe la respuesta:
|
||||||
|
// el lead ya quedó creado en Monday.
|
||||||
|
if (req.usuarioToken?.sub) {
|
||||||
|
try {
|
||||||
|
await prisma.leadCaptura.create({
|
||||||
|
data: {
|
||||||
|
leadId: resultado.id,
|
||||||
|
oportunidadId: resultado.oportunidadId,
|
||||||
|
usuarioId: req.usuarioToken.sub,
|
||||||
|
nombreLead: body.nombre,
|
||||||
|
telefono: body.telefono ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: resultado.oportunidadId })
|
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: resultado.oportunidadId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,18 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
|||||||
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
|
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Capturas de afluencia registradas por el usuario (registro local)
|
||||||
|
const capturas = await prisma.leadCaptura.findMany({
|
||||||
|
where: { usuarioId: usuario.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
res.json(
|
res.json(
|
||||||
await resumenMiDia(
|
await resumenMiDia(
|
||||||
mondayService,
|
mondayService,
|
||||||
{ nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId },
|
{ nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId },
|
||||||
nombresPorMondayId,
|
nombresPorMondayId,
|
||||||
|
capturas,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
lead({ id: '6', estado: 'NUEVO', vendedorIds: [OTRO_ID] }),
|
lead({ id: '6', estado: 'NUEVO', vendedorIds: [OTRO_ID] }),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
const r = await resumenMiDia(monday, vendedor, new Map(), AHORA)
|
const r = await resumenMiDia(monday, vendedor, new Map(), [], AHORA)
|
||||||
expect(r.sinMapeoMonday).toBe(false)
|
expect(r.sinMapeoMonday).toBe(false)
|
||||||
expect(r.misLeads.map((l) => l.id)).toEqual(['1', '2'])
|
expect(r.misLeads.map((l) => l.id)).toEqual(['1', '2'])
|
||||||
expect(r.misLeads[1]?.telefono).toBe('4771234567')
|
expect(r.misLeads[1]?.telefono).toBe('4771234567')
|
||||||
@@ -101,7 +101,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
actividad({ id: 'a7', fecha: '' }), // sin fecha: no
|
actividad({ id: 'a7', fecha: '' }), // sin fecha: no
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
const r = await resumenMiDia(monday, vendedor, new Map(), AHORA)
|
const r = await resumenMiDia(monday, vendedor, new Map(), [], AHORA)
|
||||||
// Ordenadas por fecha: vencida primero
|
// Ordenadas por fecha: vencida primero
|
||||||
expect(r.misActividadesHoy.map((a) => a.id)).toEqual(['a3', 'a1', 'a2'])
|
expect(r.misActividadesHoy.map((a) => a.id)).toEqual(['a3', 'a1', 'a2'])
|
||||||
expect(r.conteos.actividadesAbiertas).toBe(5) // abiertas mías: a1,a2,a3,a4,a7
|
expect(r.conteos.actividadesAbiertas).toBe(5) // abiertas mías: a1,a2,a3,a4,a7
|
||||||
@@ -118,7 +118,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
apartado({ id: 'p6', fechaLimite: '2026-09-01', estado: 'Cancelacion' }),
|
apartado({ id: 'p6', fechaLimite: '2026-09-01', estado: 'Cancelacion' }),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
const r = await resumenMiDia(monday, vendedor, new Map(), AHORA)
|
const r = await resumenMiDia(monday, vendedor, new Map(), [], AHORA)
|
||||||
expect(r.apartadosPorVencer.map((a) => a.id)).toEqual(['p1', 'p2'])
|
expect(r.apartadosPorVencer.map((a) => a.id)).toEqual(['p1', 'p2'])
|
||||||
expect(r.apartadosPorVencer[0]?.diasRestantes).toBe(0)
|
expect(r.apartadosPorVencer[0]?.diasRestantes).toBe(0)
|
||||||
expect(r.apartadosPorVencer[1]?.diasRestantes).toBe(7)
|
expect(r.apartadosPorVencer[1]?.diasRestantes).toBe(7)
|
||||||
@@ -130,7 +130,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
actividades: [actividad({ id: 'a1' })],
|
actividades: [actividad({ id: 'a1' })],
|
||||||
apartados: [apartado({ id: 'p1' })],
|
apartados: [apartado({ id: 'p1' })],
|
||||||
})
|
})
|
||||||
const r = await resumenMiDia(monday, sinMapeo, new Map(), AHORA)
|
const r = await resumenMiDia(monday, sinMapeo, new Map(), [], AHORA)
|
||||||
expect(r.sinMapeoMonday).toBe(true)
|
expect(r.sinMapeoMonday).toBe(true)
|
||||||
expect(r.misLeads).toEqual([])
|
expect(r.misLeads).toEqual([])
|
||||||
expect(r.misActividadesHoy).toEqual([])
|
expect(r.misActividadesHoy).toEqual([])
|
||||||
@@ -148,12 +148,12 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
lead({ id: '4', createdAt: '2026-08-27T08:00:00', vendedorIds: [OTRO_ID] }), // de otro
|
lead({ id: '4', createdAt: '2026-08-27T08:00:00', vendedorIds: [OTRO_ID] }), // de otro
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
const r = await resumenMiDia(monday, vendedor, new Map(), AHORA)
|
const r = await resumenMiDia(monday, vendedor, new Map(), [], AHORA)
|
||||||
expect(r.conteos.leadsNuevosSemana).toBe(2)
|
expect(r.conteos.leadsNuevosSemana).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('oportunidades se omiten con nota (el board no tiene columna de asesor)', async () => {
|
it('oportunidades se omiten con nota (el board no tiene columna de asesor)', async () => {
|
||||||
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), AHORA)
|
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], AHORA)
|
||||||
expect(r.misOportunidades).toBeNull()
|
expect(r.misOportunidades).toBeNull()
|
||||||
expect(r.notaOportunidades).toContain('asesor')
|
expect(r.notaOportunidades).toContain('asesor')
|
||||||
})
|
})
|
||||||
@@ -171,10 +171,10 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
})
|
})
|
||||||
const nombres = new Map([[MI_ID, 'Martin Zavala']])
|
const nombres = new Map([[MI_ID, 'Martin Zavala']])
|
||||||
|
|
||||||
const rVendedor = await resumenMiDia(monday, vendedor, nombres, AHORA)
|
const rVendedor = await resumenMiDia(monday, vendedor, nombres, [], AHORA)
|
||||||
expect(rVendedor.equipo).toBeUndefined()
|
expect(rVendedor.equipo).toBeUndefined()
|
||||||
|
|
||||||
const rCoord = await resumenMiDia(monday, coordinador, nombres, AHORA)
|
const rCoord = await resumenMiDia(monday, coordinador, nombres, [], AHORA)
|
||||||
expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(1)
|
expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(1)
|
||||||
expect(rCoord.equipo?.porVendedor).toEqual([
|
expect(rCoord.equipo?.porVendedor).toEqual([
|
||||||
{ mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 2, leadsNuevosSemana: 1 },
|
{ mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 2, leadsNuevosSemana: 1 },
|
||||||
@@ -185,10 +185,57 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
it('corporativo y admin también ven equipo', async () => {
|
it('corporativo y admin también ven equipo', async () => {
|
||||||
const monday = mondayMock({ leads: [lead({ id: '1' })] })
|
const monday = mondayMock({ leads: [lead({ id: '1' })] })
|
||||||
for (const rol of ['corporativo', 'admin']) {
|
for (const rol of ['corporativo', 'admin']) {
|
||||||
const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), AHORA)
|
const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), [], AHORA)
|
||||||
expect(r.equipo).toBeDefined()
|
expect(r.equipo).toBeDefined()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('mis capturas: enriquecidas con el estado/origen actual del lead en Monday', async () => {
|
||||||
|
const monday = mondayMock({
|
||||||
|
leads: [lead({ id: '100', nombre: 'Ana López', estado: 'CONTACTADO', origen: 'WhatsApp', vendedorIds: [OTRO_ID] })],
|
||||||
|
})
|
||||||
|
const capturas = [
|
||||||
|
{ leadId: '100', nombreLead: 'Ana López', telefono: '6641112233', createdAt: new Date('2026-08-27T09:00:00Z') },
|
||||||
|
]
|
||||||
|
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, AHORA)
|
||||||
|
// Aunque el lead quedó asignado a OTRO por round-robin, la captura es mía
|
||||||
|
expect(r.misCapturas).toEqual([
|
||||||
|
{
|
||||||
|
leadId: '100',
|
||||||
|
nombre: 'Ana López',
|
||||||
|
telefono: '6641112233',
|
||||||
|
estado: 'CONTACTADO',
|
||||||
|
origen: 'WhatsApp',
|
||||||
|
capturadoEn: '2026-08-27T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mis capturas: si el lead ya no existe en Monday, estado y origen van vacíos', async () => {
|
||||||
|
const monday = mondayMock({})
|
||||||
|
const capturas = [
|
||||||
|
{ leadId: 'borrado-1', nombreLead: 'Lead Borrado', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') },
|
||||||
|
]
|
||||||
|
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, AHORA)
|
||||||
|
expect(r.misCapturas).toEqual([
|
||||||
|
{ leadId: 'borrado-1', nombre: 'Lead Borrado', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mis capturas: ordenadas por fecha de captura descendente', async () => {
|
||||||
|
const monday = mondayMock({})
|
||||||
|
const capturas = [
|
||||||
|
{ leadId: '1', nombreLead: 'Antiguo', telefono: null, createdAt: new Date('2026-08-20T09:00:00Z') },
|
||||||
|
{ leadId: '2', nombreLead: 'Reciente', telefono: null, createdAt: new Date('2026-08-27T09:00:00Z') },
|
||||||
|
]
|
||||||
|
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, AHORA)
|
||||||
|
expect(r.misCapturas.map((c) => c.leadId)).toEqual(['2', '1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sin capturas: misCapturas va vacío', async () => {
|
||||||
|
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], AHORA)
|
||||||
|
expect(r.misCapturas).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('clasificadores de estado', () => {
|
describe('clasificadores de estado', () => {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { MondayService } from './monday.service.js'
|
|||||||
* Todo se filtra por su mondayUserId (columna people de cada tablero); si el
|
* Todo se filtra por su mondayUserId (columna people de cada tablero); si el
|
||||||
* usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales
|
* usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales
|
||||||
* van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando).
|
* van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando).
|
||||||
|
* "Mis capturas" viene del registro local (tabla LeadCaptura), no de Monday:
|
||||||
|
* es la lista de leads que el usuario dio de alta, con su estado actual.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
|
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
|
||||||
@@ -48,10 +50,20 @@ export interface ApartadoPorVencer {
|
|||||||
diasRestantes: number
|
diasRestantes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CapturaMiDia {
|
||||||
|
leadId: string
|
||||||
|
nombre: string
|
||||||
|
telefono: string
|
||||||
|
estado: string // estado ACTUAL en Monday; '' si el lead ya no existe en el board
|
||||||
|
origen: string
|
||||||
|
capturadoEn: string // ISO
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResumenMiDia {
|
export interface ResumenMiDia {
|
||||||
generadoEn: string
|
generadoEn: string
|
||||||
sinMapeoMonday: boolean
|
sinMapeoMonday: boolean
|
||||||
misLeads: LeadMiDia[]
|
misLeads: LeadMiDia[]
|
||||||
|
misCapturas: CapturaMiDia[]
|
||||||
misActividadesHoy: ActividadMiDia[]
|
misActividadesHoy: ActividadMiDia[]
|
||||||
apartadosPorVencer: ApartadoPorVencer[]
|
apartadosPorVencer: ApartadoPorVencer[]
|
||||||
// null = el tablero Oportunidades no tiene columna people de asesor (hoy no existe)
|
// null = el tablero Oportunidades no tiene columna people de asesor (hoy no existe)
|
||||||
@@ -100,6 +112,8 @@ export async function resumenMiDia(
|
|||||||
usuario: UsuarioMiDia,
|
usuario: UsuarioMiDia,
|
||||||
/** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */
|
/** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */
|
||||||
nombresPorMondayId: Map<number, string>,
|
nombresPorMondayId: Map<number, string>,
|
||||||
|
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
|
||||||
|
capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [],
|
||||||
ahora: Date = new Date(),
|
ahora: Date = new Date(),
|
||||||
): Promise<ResumenMiDia> {
|
): Promise<ResumenMiDia> {
|
||||||
const miId = usuario.mondayUserId
|
const miId = usuario.mondayUserId
|
||||||
@@ -130,6 +144,23 @@ export async function resumenMiDia(
|
|||||||
updatedAt: l.updatedAt,
|
updatedAt: l.updatedAt,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// "Mis capturas" se enriquece con el estado/origen ACTUAL del lead en Monday
|
||||||
|
// (si ya no existe en el board, estado y origen van ''), más reciente primero
|
||||||
|
const leadsPorId = new Map(leads.map((l) => [l.id, l]))
|
||||||
|
const misCapturas: CapturaMiDia[] = capturas
|
||||||
|
.map((c) => {
|
||||||
|
const lead = leadsPorId.get(c.leadId)
|
||||||
|
return {
|
||||||
|
leadId: c.leadId,
|
||||||
|
nombre: c.nombreLead,
|
||||||
|
telefono: c.telefono ?? '',
|
||||||
|
estado: lead?.estado ?? '',
|
||||||
|
origen: lead?.origen ?? '',
|
||||||
|
capturadoEn: c.createdAt.toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
|
||||||
|
|
||||||
const misActividades = sinMapeo ? [] : actividades.filter((a) => a.responsableIds.includes(miId))
|
const misActividades = sinMapeo ? [] : actividades.filter((a) => a.responsableIds.includes(miId))
|
||||||
const misActividadesHoy: ActividadMiDia[] = misActividades
|
const misActividadesHoy: ActividadMiDia[] = misActividades
|
||||||
.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO' && a.fecha !== '' && a.fecha <= manana)
|
.filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO' && a.fecha !== '' && a.fecha <= manana)
|
||||||
@@ -164,6 +195,7 @@ export async function resumenMiDia(
|
|||||||
generadoEn: ahora.toISOString(),
|
generadoEn: ahora.toISOString(),
|
||||||
sinMapeoMonday: sinMapeo,
|
sinMapeoMonday: sinMapeo,
|
||||||
misLeads,
|
misLeads,
|
||||||
|
misCapturas,
|
||||||
misActividadesHoy,
|
misActividadesHoy,
|
||||||
apartadosPorVencer,
|
apartadosPorVencer,
|
||||||
// El tablero Oportunidades no tiene columna people de asesor: no se puede
|
// El tablero Oportunidades no tiene columna people de asesor: no se puede
|
||||||
|
|||||||
@@ -27,10 +27,20 @@ export interface ApartadoPorVencer {
|
|||||||
diasRestantes: number
|
diasRestantes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CapturaMiDia {
|
||||||
|
leadId: string
|
||||||
|
nombre: string
|
||||||
|
telefono: string
|
||||||
|
estado: string // estado actual en Monday; '' si el lead ya no existe en el board
|
||||||
|
origen: string
|
||||||
|
capturadoEn: string // ISO
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResumenMiDia {
|
export interface ResumenMiDia {
|
||||||
generadoEn: string
|
generadoEn: string
|
||||||
sinMapeoMonday: boolean
|
sinMapeoMonday: boolean
|
||||||
misLeads: LeadMiDia[]
|
misLeads: LeadMiDia[]
|
||||||
|
misCapturas: CapturaMiDia[]
|
||||||
misActividadesHoy: ActividadMiDia[]
|
misActividadesHoy: ActividadMiDia[]
|
||||||
apartadosPorVencer: ApartadoPorVencer[]
|
apartadosPorVencer: ApartadoPorVencer[]
|
||||||
misOportunidades: { porEtapa: Record<string, number> } | null
|
misOportunidades: { porEtapa: Record<string, number> } | null
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ export default function MiDiaPage() {
|
|||||||
setCapEmail('')
|
setCapEmail('')
|
||||||
setCapDesarrollo('')
|
setCapDesarrollo('')
|
||||||
setCapNotas('')
|
setCapNotas('')
|
||||||
|
// Refresca el resumen para que la captura aparezca al momento en "Mis capturas"
|
||||||
|
await cargar()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCapMensaje({ tipo: 'error', texto: err instanceof Error ? err.message : 'No se pudo registrar el lead' })
|
setCapMensaje({ tipo: 'error', texto: err instanceof Error ? err.message : 'No se pudo registrar el lead' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -367,6 +369,35 @@ export default function MiDiaPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Mis capturas de afluencia" className="mt-12">
|
||||||
|
<TituloSeccion>Mis capturas de afluencia</TituloSeccion>
|
||||||
|
{resumen.misCapturas.length === 0 ? (
|
||||||
|
<EstadoVacio mensaje="Aún no registras afluencias" />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{resumen.misCapturas.map((c) => (
|
||||||
|
<li key={c.leadId} className="flex flex-wrap items-center justify-between gap-3 border border-neutral-200 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{c.nombre}</p>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
{c.telefono || 'Sin teléfono'}
|
||||||
|
{c.origen ? ` · ${c.origen}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="border border-neutral-300 px-2 py-1 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
|
{c.estado || '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
|
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section aria-label="Captura rápida de lead" className="mt-12">
|
<section aria-label="Captura rápida de lead" className="mt-12">
|
||||||
<TituloSeccion>Captura rápida de lead</TituloSeccion>
|
<TituloSeccion>Captura rápida de lead</TituloSeccion>
|
||||||
<form onSubmit={enviarCaptura} className="grid gap-4 border border-neutral-200 p-6 sm:grid-cols-2">
|
<form onSubmit={enviarCaptura} className="grid gap-4 border border-neutral-200 p-6 sm:grid-cols-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user