feat: mis clientes en Mi dia + perfilamiento de la afluencia en dashboards
- Cliente.capturadoPorId: el alta (directa y progresiva) registra al usuario que la inicio; el panel Mi dia muestra 'Mis clientes' con su estado (En captura / Cliente) sin depender de Monday. - LeadMonday ahora lee las 11 columnas de perfil del tablero Leads (estado civil, edad, rango salarial, tipo de empleo/economia, califica, credito, modelo, etc.). - GET /api/reportes/resumen agrega leads.perfil con conteos por dimension (valores vacios se omiten; edad en buckets). - /dash/ gana la seccion 'Perfilamiento de la afluencia' con mini-graficas de barras por dimension.
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Cliente" ADD COLUMN "capturadoPorId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Cliente_capturadoPorId_idx" ON "Cliente"("capturadoPorId");
|
||||||
|
|
||||||
@@ -53,10 +53,14 @@ model Cliente {
|
|||||||
firmaPng String? // base64 opcional
|
firmaPng String? // base64 opcional
|
||||||
mondayContactoId String?
|
mondayContactoId String?
|
||||||
mondayApartadoId String?
|
mondayApartadoId String?
|
||||||
|
// Usuario.id de quien inició el alta (sub del JWT); null en altas históricas
|
||||||
|
// o creadas sin auth (AUTH_ENABLED=false). Alimenta "Mis clientes" de Mi día.
|
||||||
|
capturadoPorId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
documentos Documento[]
|
documentos Documento[]
|
||||||
|
|
||||||
@@index([curp])
|
@@index([curp])
|
||||||
|
@@index([capturadoPorId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Documento {
|
model Documento {
|
||||||
|
|||||||
@@ -176,6 +176,20 @@ describe('iniciarProgreso (POST /api/clientes/progreso/iniciar)', () => {
|
|||||||
expect(status).toHaveBeenCalledWith(201)
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: null })
|
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: null })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => {
|
||||||
|
const { iniciarProgreso } = await import('./clientes.controller.js')
|
||||||
|
const req = reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } })
|
||||||
|
;(req as unknown as { usuarioToken: { sub: string } }).usuarioToken = { sub: 'usr-1' }
|
||||||
|
await iniciarProgreso(req, resFake().res)
|
||||||
|
expect(prismaMock.cliente.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }),
|
||||||
|
)
|
||||||
|
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), resFake().res)
|
||||||
|
expect(prismaMock.cliente.create).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('actualizarProgreso (PATCH /api/clientes/progreso/:id)', () => {
|
describe('actualizarProgreso (PATCH /api/clientes/progreso/:id)', () => {
|
||||||
|
|||||||
@@ -163,4 +163,16 @@ describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () =>
|
|||||||
expect(status).toHaveBeenCalledWith(201)
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: 'cont-1' })
|
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: 'cont-1' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => {
|
||||||
|
const { crearCliente } = await import('./clientes.controller.js')
|
||||||
|
await crearCliente({ body: altaBase, usuarioToken: { sub: 'usr-1' } } as unknown as Request, resFake().res)
|
||||||
|
expect(prismaMock.cliente.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }),
|
||||||
|
)
|
||||||
|
await crearCliente(reqFake(altaBase), resFake().res)
|
||||||
|
expect(prismaMock.cliente.create).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
|||||||
celular: dg.celular ?? null,
|
celular: dg.celular ?? null,
|
||||||
proyecto: vc.proyecto,
|
proyecto: vc.proyecto,
|
||||||
unidadCodigo: vc.ubicacion ?? '', // la "ubicación" del formato es el código de unidad (U-101, etc.)
|
unidadCodigo: vc.ubicacion ?? '', // la "ubicación" del formato es el código de unidad (U-101, etc.)
|
||||||
|
capturadoPorId: req.usuarioToken?.sub ?? null,
|
||||||
payloadJson: JSON.stringify(body),
|
payloadJson: JSON.stringify(body),
|
||||||
firmaPng: body.firma?.firmaDataUrl ?? null,
|
firmaPng: body.firma?.firmaDataUrl ?? null,
|
||||||
},
|
},
|
||||||
@@ -348,6 +349,7 @@ export async function iniciarProgreso(req: Request, res: Response): Promise<void
|
|||||||
email: dg.email ?? null,
|
email: dg.email ?? null,
|
||||||
celular: dg.celular ?? null,
|
celular: dg.celular ?? null,
|
||||||
proyecto: '', // se define al completar el alta
|
proyecto: '', // se define al completar el alta
|
||||||
|
capturadoPorId: req.usuarioToken?.sub ?? null,
|
||||||
payloadJson: JSON.stringify(body),
|
payloadJson: JSON.stringify(body),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,12 +32,19 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Altas de clientes iniciadas por el usuario (registro local)
|
||||||
|
const clientes = await prisma.cliente.findMany({
|
||||||
|
where: { capturadoPorId: 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,
|
capturas,
|
||||||
|
clientes,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +185,7 @@ 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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -197,7 +197,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
const capturas = [
|
const capturas = [
|
||||||
{ leadId: '100', nombreLead: 'Ana López', telefono: '6641112233', createdAt: new Date('2026-08-27T09:00:00Z') },
|
{ 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)
|
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
|
// Aunque el lead quedó asignado a OTRO por round-robin, la captura es mía
|
||||||
expect(r.misCapturas).toEqual([
|
expect(r.misCapturas).toEqual([
|
||||||
{
|
{
|
||||||
@@ -216,7 +216,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
const capturas = [
|
const capturas = [
|
||||||
{ leadId: 'borrado-1', nombreLead: 'Lead Borrado', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') },
|
{ 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)
|
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
|
||||||
expect(r.misCapturas).toEqual([
|
expect(r.misCapturas).toEqual([
|
||||||
{ leadId: 'borrado-1', nombre: 'Lead Borrado', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' },
|
{ leadId: 'borrado-1', nombre: 'Lead Borrado', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' },
|
||||||
])
|
])
|
||||||
@@ -228,14 +228,65 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
{ leadId: '1', nombreLead: 'Antiguo', telefono: null, createdAt: new Date('2026-08-20T09:00:00Z') },
|
{ 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') },
|
{ leadId: '2', nombreLead: 'Reciente', telefono: null, createdAt: new Date('2026-08-27T09:00:00Z') },
|
||||||
]
|
]
|
||||||
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, AHORA)
|
const r = await resumenMiDia(monday, vendedor, new Map(), capturas, [], AHORA)
|
||||||
expect(r.misCapturas.map((c) => c.leadId)).toEqual(['2', '1'])
|
expect(r.misCapturas.map((c) => c.leadId)).toEqual(['2', '1'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sin capturas: misCapturas va vacío', async () => {
|
it('sin capturas: misCapturas va vacío', async () => {
|
||||||
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], AHORA)
|
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], [], AHORA)
|
||||||
expect(r.misCapturas).toEqual([])
|
expect(r.misCapturas).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('mis clientes: mapeo con completado según proyecto (vacío = en captura)', async () => {
|
||||||
|
const clientes = [
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
nombreCompleto: 'Ana En Captura',
|
||||||
|
proyecto: '',
|
||||||
|
unidadCodigo: null,
|
||||||
|
createdAt: new Date('2026-08-26T09:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c2',
|
||||||
|
nombreCompleto: 'Luis Completado',
|
||||||
|
proyecto: 'DIAGONAL',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
createdAt: new Date('2026-08-27T09:00:00Z'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], clientes, AHORA)
|
||||||
|
expect(r.misClientes).toEqual([
|
||||||
|
{
|
||||||
|
id: 'c2',
|
||||||
|
nombre: 'Luis Completado',
|
||||||
|
proyecto: 'DIAGONAL',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
completado: true,
|
||||||
|
capturadoEn: '2026-08-27T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
nombre: 'Ana En Captura',
|
||||||
|
proyecto: '',
|
||||||
|
unidadCodigo: '',
|
||||||
|
completado: false,
|
||||||
|
capturadoEn: '2026-08-26T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mis clientes: proyecto con solo espacios cuenta como en captura', async () => {
|
||||||
|
const clientes = [
|
||||||
|
{ id: 'c1', nombreCompleto: 'Espacios', proyecto: ' ', unidadCodigo: null, createdAt: new Date('2026-08-26T09:00:00Z') },
|
||||||
|
]
|
||||||
|
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], clientes, AHORA)
|
||||||
|
expect(r.misClientes[0]?.completado).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sin clientes: misClientes va vacío (no requiere Monday)', async () => {
|
||||||
|
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], [], AHORA)
|
||||||
|
expect(r.misClientes).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('clasificadores de estado', () => {
|
describe('clasificadores de estado', () => {
|
||||||
|
|||||||
@@ -59,11 +59,21 @@ export interface CapturaMiDia {
|
|||||||
capturadoEn: string // ISO
|
capturadoEn: string // ISO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClienteMiDia {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
proyecto: string // '' = alta aún en captura
|
||||||
|
unidadCodigo: string
|
||||||
|
completado: boolean // true cuando el alta ya tiene proyecto (pasó a CLIENTE)
|
||||||
|
capturadoEn: string // ISO
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResumenMiDia {
|
export interface ResumenMiDia {
|
||||||
generadoEn: string
|
generadoEn: string
|
||||||
sinMapeoMonday: boolean
|
sinMapeoMonday: boolean
|
||||||
misLeads: LeadMiDia[]
|
misLeads: LeadMiDia[]
|
||||||
misCapturas: CapturaMiDia[]
|
misCapturas: CapturaMiDia[]
|
||||||
|
misClientes: ClienteMiDia[]
|
||||||
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)
|
||||||
@@ -114,6 +124,8 @@ export async function resumenMiDia(
|
|||||||
nombresPorMondayId: Map<number, string>,
|
nombresPorMondayId: Map<number, string>,
|
||||||
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
|
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
|
||||||
capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [],
|
capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [],
|
||||||
|
/** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */
|
||||||
|
clientes: { id: string; nombreCompleto: string; proyecto: string; unidadCodigo: string | null; createdAt: Date }[] = [],
|
||||||
ahora: Date = new Date(),
|
ahora: Date = new Date(),
|
||||||
): Promise<ResumenMiDia> {
|
): Promise<ResumenMiDia> {
|
||||||
const miId = usuario.mondayUserId
|
const miId = usuario.mondayUserId
|
||||||
@@ -161,6 +173,19 @@ export async function resumenMiDia(
|
|||||||
})
|
})
|
||||||
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
|
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
|
||||||
|
|
||||||
|
// "Mis clientes" es 100% local (no requiere Monday): el alta en progreso
|
||||||
|
// lleva proyecto '', el alta completada lleva el proyecto lleno
|
||||||
|
const misClientes: ClienteMiDia[] = clientes
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
nombre: c.nombreCompleto,
|
||||||
|
proyecto: c.proyecto,
|
||||||
|
unidadCodigo: c.unidadCodigo ?? '',
|
||||||
|
completado: c.proyecto.trim() !== '',
|
||||||
|
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)
|
||||||
@@ -196,6 +221,7 @@ export async function resumenMiDia(
|
|||||||
sinMapeoMonday: sinMapeo,
|
sinMapeoMonday: sinMapeo,
|
||||||
misLeads,
|
misLeads,
|
||||||
misCapturas,
|
misCapturas,
|
||||||
|
misClientes,
|
||||||
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
|
||||||
|
|||||||
@@ -247,4 +247,128 @@ describe('MondayService', () => {
|
|||||||
expect(parsed['date_fc']).toEqual({ date: '2026-08-21' })
|
expect(parsed['date_fc']).toEqual({ date: '2026-08-21' })
|
||||||
expect(parsed['text_t1']).toBeUndefined() // no se envían columnas vacías
|
expect(parsed['text_t1']).toBeUndefined() // no se envían columnas vacías
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getLeads / mapearLead (perfilamiento)', () => {
|
||||||
|
const COLUMNAS_LEADS_BASE = [
|
||||||
|
{ id: 'people_v', title: 'Vendedor', type: 'people' },
|
||||||
|
{ id: 'color_e', title: 'Estado', type: 'status' },
|
||||||
|
]
|
||||||
|
const COLUMNAS_PERFIL = [
|
||||||
|
{ id: 'color_des', title: 'Desarrollo', type: 'status' },
|
||||||
|
{ id: 'color_mod', title: 'Modelo de Interés', type: 'status' },
|
||||||
|
{ id: 'text_mc', title: 'Medio de Contacto', type: 'text' },
|
||||||
|
{ id: 'numeric_edad', title: 'Edad', type: 'numbers' },
|
||||||
|
{ id: 'color_ec', title: 'Estado Civil', type: 'status' },
|
||||||
|
{ id: 'color_te', title: 'Tipo de Empleo', type: 'status' },
|
||||||
|
{ id: 'color_cr', title: 'Crédito', type: 'status' },
|
||||||
|
{ id: 'color_teco', title: 'Tipo de Economía', type: 'status' },
|
||||||
|
{ id: 'color_cal', title: 'Califica', type: 'status' },
|
||||||
|
{ id: 'text_rs', title: 'Rango Salarial', type: 'text' },
|
||||||
|
{ id: 'text_lqp', title: 'Lo Quiere Para', type: 'text' },
|
||||||
|
]
|
||||||
|
const itemLead = (id: string, extras: { id: string; text: string }[] = []) => ({
|
||||||
|
id,
|
||||||
|
name: `Lead ${id}`,
|
||||||
|
created_at: '2026-08-20T10:00:00Z',
|
||||||
|
updated_at: '2026-08-21T10:00:00Z',
|
||||||
|
column_values: [
|
||||||
|
{ id: 'people_v', text: '', value: '{"personsAndTeams":[{"id":106736727,"kind":"person"}]}' },
|
||||||
|
{ id: 'color_e', text: 'NUEVO', value: null },
|
||||||
|
...extras.map((e) => ({ ...e, value: null })),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mapea las columnas de perfilamiento cuando existen en el board', async () => {
|
||||||
|
mockearFetch((body) => {
|
||||||
|
if (body.query.includes('columns')) {
|
||||||
|
return respuestaOk({ boards: [{ columns: [...COLUMNAS_LEADS_BASE, ...COLUMNAS_PERFIL] }] })
|
||||||
|
}
|
||||||
|
return respuestaOk({
|
||||||
|
boards: [
|
||||||
|
{
|
||||||
|
items_page: {
|
||||||
|
cursor: null,
|
||||||
|
items: [
|
||||||
|
itemLead('1', [
|
||||||
|
{ id: 'color_des', text: 'ATELIER' },
|
||||||
|
{ id: 'color_mod', text: '1R' },
|
||||||
|
{ id: 'text_mc', text: 'WhatsApp' },
|
||||||
|
{ id: 'numeric_edad', text: '32' },
|
||||||
|
{ id: 'color_ec', text: 'Soltero' },
|
||||||
|
{ id: 'color_te', text: 'Empleado' },
|
||||||
|
{ id: 'color_cr', text: 'Bancario' },
|
||||||
|
{ id: 'color_teco', text: 'Asalariado' },
|
||||||
|
{ id: 'color_cal', text: 'Si' },
|
||||||
|
{ id: 'text_rs', text: '20-30 mil' },
|
||||||
|
{ id: 'text_lqp', text: 'Habitarla' },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const leads = await crearServicio().getLeads()
|
||||||
|
expect(leads[0]).toMatchObject({
|
||||||
|
desarrollo: 'ATELIER',
|
||||||
|
modeloInteres: '1R',
|
||||||
|
medioContacto: 'WhatsApp',
|
||||||
|
edad: 32,
|
||||||
|
estadoCivil: 'Soltero',
|
||||||
|
tipoEmpleo: 'Empleado',
|
||||||
|
credito: 'Bancario',
|
||||||
|
tipoEconomia: 'Asalariado',
|
||||||
|
califica: 'Si',
|
||||||
|
rangoSalarial: '20-30 mil',
|
||||||
|
loQuierePara: 'Habitarla',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sin las columnas de perfilamiento devuelve \'\' / null y no truena', async () => {
|
||||||
|
mockearFetch((body) => {
|
||||||
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_LEADS_BASE }] })
|
||||||
|
return respuestaOk({ boards: [{ items_page: { cursor: null, items: [itemLead('1')] } }] })
|
||||||
|
})
|
||||||
|
|
||||||
|
const leads = await crearServicio().getLeads()
|
||||||
|
expect(leads[0]).toMatchObject({
|
||||||
|
desarrollo: '',
|
||||||
|
modeloInteres: '',
|
||||||
|
medioContacto: '',
|
||||||
|
edad: null,
|
||||||
|
estadoCivil: '',
|
||||||
|
tipoEmpleo: '',
|
||||||
|
credito: '',
|
||||||
|
tipoEconomia: '',
|
||||||
|
califica: '',
|
||||||
|
rangoSalarial: '',
|
||||||
|
loQuierePara: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edad no numérica o vacía mapea a null', async () => {
|
||||||
|
mockearFetch((body) => {
|
||||||
|
if (body.query.includes('columns')) {
|
||||||
|
return respuestaOk({ boards: [{ columns: [...COLUMNAS_LEADS_BASE, ...COLUMNAS_PERFIL] }] })
|
||||||
|
}
|
||||||
|
return respuestaOk({
|
||||||
|
boards: [
|
||||||
|
{
|
||||||
|
items_page: {
|
||||||
|
cursor: null,
|
||||||
|
items: [
|
||||||
|
itemLead('1', [{ id: 'numeric_edad', text: 'N/A' }]),
|
||||||
|
itemLead('2', [{ id: 'numeric_edad', text: '' }]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const leads = await crearServicio().getLeads()
|
||||||
|
expect(leads.map((l) => l.edad)).toEqual([null, null])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ export interface LeadMonday {
|
|||||||
telefono: string // columna phone 'Teléfono' ('' si no tiene)
|
telefono: string // columna phone 'Teléfono' ('' si no tiene)
|
||||||
email: string // columna email 'Email' ('' si no tiene)
|
email: string // columna email 'Email' ('' si no tiene)
|
||||||
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
|
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
|
||||||
|
// Perfilamiento (alimenta las gráficas de /dash/); todos tolerantes: si la
|
||||||
|
// columna no existe en el board o viene vacía, quedan '' / null
|
||||||
|
desarrollo?: string // status 'Desarrollo'
|
||||||
|
modeloInteres?: string // status 'Modelo de Interés'
|
||||||
|
medioContacto?: string // text 'Medio de Contacto'
|
||||||
|
edad?: number | null // numbers 'Edad' (null si vacía o no numérica)
|
||||||
|
estadoCivil?: string // status 'Estado Civil'
|
||||||
|
tipoEmpleo?: string // status 'Tipo de Empleo'
|
||||||
|
credito?: string // status 'Crédito'
|
||||||
|
tipoEconomia?: string // status 'Tipo de Economía'
|
||||||
|
califica?: string // status 'Califica'
|
||||||
|
rangoSalarial?: string // text 'Rango Salarial'
|
||||||
|
loQuierePara?: string // text 'Lo Quiere Para'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Actividad del tablero Actividades (agenda de seguimiento del CRM) */
|
/** Actividad del tablero Actividades (agenda de seguimiento del CRM) */
|
||||||
@@ -656,6 +669,13 @@ export class MondayService {
|
|||||||
const colOrigen = cols.get('Origen')
|
const colOrigen = cols.get('Origen')
|
||||||
const colTelefono = cols.get('Teléfono')
|
const colTelefono = cols.get('Teléfono')
|
||||||
const colEmail = cols.get('Email')
|
const colEmail = cols.get('Email')
|
||||||
|
// Perfilamiento: lectura tolerante por título ('' / null si falta la columna)
|
||||||
|
const textoDe = (titulo: string): string => {
|
||||||
|
const col = cols.get(titulo)
|
||||||
|
return col ? (porId.get(col.id)?.text ?? '') : ''
|
||||||
|
}
|
||||||
|
const edadRaw = textoDe('Edad')
|
||||||
|
const edadNum = Number(edadRaw)
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
nombre: item.name,
|
nombre: item.name,
|
||||||
@@ -666,6 +686,17 @@ export class MondayService {
|
|||||||
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
|
telefono: colTelefono ? (porId.get(colTelefono.id)?.text ?? '') : '',
|
||||||
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
|
email: colEmail ? (porId.get(colEmail.id)?.text ?? '') : '',
|
||||||
vendedorIds,
|
vendedorIds,
|
||||||
|
desarrollo: textoDe('Desarrollo'),
|
||||||
|
modeloInteres: textoDe('Modelo de Interés'),
|
||||||
|
medioContacto: textoDe('Medio de Contacto'),
|
||||||
|
edad: edadRaw.trim() === '' || Number.isNaN(edadNum) ? null : edadNum,
|
||||||
|
estadoCivil: textoDe('Estado Civil'),
|
||||||
|
tipoEmpleo: textoDe('Tipo de Empleo'),
|
||||||
|
credito: textoDe('Crédito'),
|
||||||
|
tipoEconomia: textoDe('Tipo de Economía'),
|
||||||
|
califica: textoDe('Califica'),
|
||||||
|
rangoSalarial: textoDe('Rango Salarial'),
|
||||||
|
loQuierePara: textoDe('Lo Quiere Para'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,52 @@ describe('construirResumen', () => {
|
|||||||
expect(r.cobranza.planesAlCorriente).toBe(0)
|
expect(r.cobranza.planesAlCorriente).toBe(0)
|
||||||
expect(r.cobranza.planesConVencidos).toBe(2)
|
expect(r.cobranza.planesConVencidos).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('perfil: cuenta solo valores capturados (los vacíos no van a ningún bucket)', () => {
|
||||||
|
const r = construirResumen(
|
||||||
|
{
|
||||||
|
...DATOS_BASE,
|
||||||
|
leads: [
|
||||||
|
lead('1', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', credito: 'Bancario', califica: 'Si' }),
|
||||||
|
lead('2', { estadoCivil: 'Soltero', desarrollo: 'ATELIER', rangoSalarial: '20-30 mil' }),
|
||||||
|
lead('3', { estadoCivil: '', desarrollo: ' ' }), // sin dato: no cuenta en ninguna dimensión
|
||||||
|
],
|
||||||
|
},
|
||||||
|
AHORA,
|
||||||
|
)
|
||||||
|
expect(r.leads.perfil.porEstadoCivil).toEqual({ Soltero: 2 })
|
||||||
|
expect(r.leads.perfil.porDesarrollo).toEqual({ ATELIER: 2 })
|
||||||
|
expect(r.leads.perfil.porCredito).toEqual({ Bancario: 1 })
|
||||||
|
expect(r.leads.perfil.porCalifica).toEqual({ Si: 1 })
|
||||||
|
expect(r.leads.perfil.porRangoSalarial).toEqual({ '20-30 mil': 1 })
|
||||||
|
// Dimensiones sin ningún dato van como objeto vacío
|
||||||
|
expect(r.leads.perfil.porModelo).toEqual({})
|
||||||
|
expect(r.leads.perfil.porTipoEmpleo).toEqual({})
|
||||||
|
expect(r.leads.perfil.porTipoEconomia).toEqual({})
|
||||||
|
expect(r.leads.perfil.porMedioContacto).toEqual({})
|
||||||
|
expect(r.leads.perfil.porLoQuierePara).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('perfil: buckets de edad y salto de edad null', () => {
|
||||||
|
const r = construirResumen(
|
||||||
|
{
|
||||||
|
...DATOS_BASE,
|
||||||
|
leads: [
|
||||||
|
lead('1', { edad: 18 }), // 15–24
|
||||||
|
lead('2', { edad: 24 }), // 15–24
|
||||||
|
lead('3', { edad: 25 }), // 25–34
|
||||||
|
lead('4', { edad: 39 }), // 35–44
|
||||||
|
lead('5', { edad: 50 }), // 45–54
|
||||||
|
lead('6', { edad: 55 }), // 55+
|
||||||
|
lead('7', { edad: 70 }), // 55+
|
||||||
|
lead('8', { edad: null }), // sin dato: se salta
|
||||||
|
lead('9'), // edad undefined: se salta
|
||||||
|
],
|
||||||
|
},
|
||||||
|
AHORA,
|
||||||
|
)
|
||||||
|
expect(r.leads.perfil.porEdad).toEqual({ '15–24': 2, '25–34': 1, '35–44': 1, '45–54': 1, '55+': 2 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------- ReportesService (cache en memoria) ----------
|
// ---------- ReportesService (cache en memoria) ----------
|
||||||
|
|||||||
@@ -31,6 +31,20 @@ export interface ResumenReportes {
|
|||||||
nuevosSemana: number // últimos 7 días naturales
|
nuevosSemana: number // últimos 7 días naturales
|
||||||
porEstado: Record<string, number>
|
porEstado: Record<string, number>
|
||||||
porOrigen: Record<string, number>
|
porOrigen: Record<string, number>
|
||||||
|
/** Perfilamiento de la afluencia: solo valores capturados (sin '(sin dato)') */
|
||||||
|
perfil: {
|
||||||
|
porDesarrollo: Record<string, number>
|
||||||
|
porModelo: Record<string, number>
|
||||||
|
porEstadoCivil: Record<string, number>
|
||||||
|
porTipoEmpleo: Record<string, number>
|
||||||
|
porTipoEconomia: Record<string, number>
|
||||||
|
porCalifica: Record<string, number>
|
||||||
|
porCredito: Record<string, number>
|
||||||
|
porRangoSalarial: Record<string, number>
|
||||||
|
porMedioContacto: Record<string, number>
|
||||||
|
porLoQuierePara: Record<string, number>
|
||||||
|
porEdad: Record<string, number> // buckets '15–24' | '25–34' | '35–44' | '45–54' | '55+'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
oportunidades: {
|
oportunidades: {
|
||||||
total: number
|
total: number
|
||||||
@@ -77,6 +91,26 @@ function contarPor(items: readonly string[]): Record<string, number> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Igual que contarPor pero sin bucket '(sin dato)': los vacíos se saltan (perfilamiento) */
|
||||||
|
function contarPorNoVacios(items: readonly string[]): Record<string, number> {
|
||||||
|
const out: Record<string, number> = {}
|
||||||
|
for (const item of items) {
|
||||||
|
const clave = item.trim()
|
||||||
|
if (clave === '') continue
|
||||||
|
out[clave] = (out[clave] ?? 0) + 1
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucket de edad para la gráfica de perfilamiento; null (sin dato) se salta */
|
||||||
|
function bucketEdad(edad: number): string {
|
||||||
|
if (edad < 25) return '15–24'
|
||||||
|
if (edad < 35) return '25–34'
|
||||||
|
if (edad < 45) return '35–44'
|
||||||
|
if (edad < 55) return '45–54'
|
||||||
|
return '55+'
|
||||||
|
}
|
||||||
|
|
||||||
/** Inicio del día local de `ahora` (para "nuevos hoy") */
|
/** Inicio del día local de `ahora` (para "nuevos hoy") */
|
||||||
function inicioDelDia(d: Date): Date {
|
function inicioDelDia(d: Date): Date {
|
||||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||||
@@ -153,6 +187,21 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
|
|||||||
nuevosSemana: creadosEn(hace7dias),
|
nuevosSemana: creadosEn(hace7dias),
|
||||||
porEstado: contarPor(datos.leads.map((l) => l.estado)),
|
porEstado: contarPor(datos.leads.map((l) => l.estado)),
|
||||||
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
|
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
|
||||||
|
perfil: {
|
||||||
|
porDesarrollo: contarPorNoVacios(datos.leads.map((l) => l.desarrollo ?? '')),
|
||||||
|
porModelo: contarPorNoVacios(datos.leads.map((l) => l.modeloInteres ?? '')),
|
||||||
|
porEstadoCivil: contarPorNoVacios(datos.leads.map((l) => l.estadoCivil ?? '')),
|
||||||
|
porTipoEmpleo: contarPorNoVacios(datos.leads.map((l) => l.tipoEmpleo ?? '')),
|
||||||
|
porTipoEconomia: contarPorNoVacios(datos.leads.map((l) => l.tipoEconomia ?? '')),
|
||||||
|
porCalifica: contarPorNoVacios(datos.leads.map((l) => l.califica ?? '')),
|
||||||
|
porCredito: contarPorNoVacios(datos.leads.map((l) => l.credito ?? '')),
|
||||||
|
porRangoSalarial: contarPorNoVacios(datos.leads.map((l) => l.rangoSalarial ?? '')),
|
||||||
|
porMedioContacto: contarPorNoVacios(datos.leads.map((l) => l.medioContacto ?? '')),
|
||||||
|
porLoQuierePara: contarPorNoVacios(datos.leads.map((l) => l.loQuierePara ?? '')),
|
||||||
|
porEdad: contarPorNoVacios(
|
||||||
|
datos.leads.filter((l) => l.edad != null).map((l) => bucketEdad(l.edad!)),
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
oportunidades: {
|
oportunidades: {
|
||||||
total: totalOps,
|
total: totalOps,
|
||||||
|
|||||||
59
apps/dashboards/src/components/Perfilamiento.tsx
Normal file
59
apps/dashboards/src/components/Perfilamiento.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||||
|
import { EstadoVacio, TituloSeccion } from './comunes'
|
||||||
|
import { rebanadasDe, type ResumenReportes } from '../lib/reportes'
|
||||||
|
|
||||||
|
// Dimensiones del perfilamiento de la afluencia (board Leads) en el orden del tablero
|
||||||
|
const DIMENSIONES: { titulo: string; registro: (perfil: ResumenReportes['leads']['perfil']) => Record<string, number> }[] = [
|
||||||
|
{ titulo: 'Estado civil', registro: (p) => p.porEstadoCivil },
|
||||||
|
{ titulo: 'Rango salarial', registro: (p) => p.porRangoSalarial },
|
||||||
|
{ titulo: 'Edad', registro: (p) => p.porEdad },
|
||||||
|
{ titulo: 'Tipo de empleo', registro: (p) => p.porTipoEmpleo },
|
||||||
|
{ titulo: 'Tipo de economía', registro: (p) => p.porTipoEconomia },
|
||||||
|
{ titulo: 'Califica', registro: (p) => p.porCalifica },
|
||||||
|
{ titulo: 'Crédito', registro: (p) => p.porCredito },
|
||||||
|
{ titulo: 'Desarrollo', registro: (p) => p.porDesarrollo },
|
||||||
|
{ titulo: 'Modelo de interés', registro: (p) => p.porModelo },
|
||||||
|
{ titulo: 'Medio de contacto', registro: (p) => p.porMedioContacto },
|
||||||
|
{ titulo: 'Lo quiere para', registro: (p) => p.porLoQuierePara },
|
||||||
|
]
|
||||||
|
|
||||||
|
function TarjetaDimension({ titulo, datos }: { titulo: string; datos: Record<string, number> }) {
|
||||||
|
const rebanadas = rebanadasDe(datos)
|
||||||
|
return (
|
||||||
|
<div className="border border-neutral-200 p-4">
|
||||||
|
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-neutral-500">{titulo}</p>
|
||||||
|
{rebanadas.length === 0 ? (
|
||||||
|
<EstadoVacio mensaje="Sin datos" />
|
||||||
|
) : (
|
||||||
|
<div className="h-48">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={rebanadas} layout="vertical" margin={{ top: 4, right: 24, left: 24, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||||
|
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis type="category" dataKey="nombre" width={90} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="valor" name="Leads" fill="#1B4332" isAnimationActive={false} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sección "Perfilamiento de la afluencia": una mini-gráfica de barras
|
||||||
|
* horizontales por dimensión capturada en el board Leads.
|
||||||
|
*/
|
||||||
|
export default function Perfilamiento({ perfil }: { perfil: ResumenReportes['leads']['perfil'] }) {
|
||||||
|
return (
|
||||||
|
<section aria-label="Perfilamiento de la afluencia" className="mt-12">
|
||||||
|
<TituloSeccion>Perfilamiento de la afluencia</TituloSeccion>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{DIMENSIONES.map((d) => (
|
||||||
|
<TarjetaDimension key={d.titulo} titulo={d.titulo} datos={d.registro(perfil)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import KpiCard from './KpiCard'
|
import KpiCard from './KpiCard'
|
||||||
|
import Perfilamiento from './Perfilamiento'
|
||||||
import SeccionEquipo from './SeccionEquipo'
|
import SeccionEquipo from './SeccionEquipo'
|
||||||
import { EstadoVacio, TituloSeccion } from './comunes'
|
import { EstadoVacio, TituloSeccion } from './comunes'
|
||||||
import {
|
import {
|
||||||
@@ -244,6 +245,8 @@ export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boole
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Perfilamiento perfil={resumen.leads.perfil} />
|
||||||
|
|
||||||
{mostrarEquipo && <SeccionEquipo />}
|
{mostrarEquipo && <SeccionEquipo />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
datosPipeline,
|
datosPipeline,
|
||||||
formatoMoneda,
|
formatoMoneda,
|
||||||
formatoPorcentaje,
|
formatoPorcentaje,
|
||||||
|
rebanadasDe,
|
||||||
unidadesApiladas,
|
unidadesApiladas,
|
||||||
unidadesEnEtapa,
|
unidadesEnEtapa,
|
||||||
type ResumenReportes,
|
type ResumenReportes,
|
||||||
@@ -13,7 +14,26 @@ import {
|
|||||||
function resumen(overrides: Partial<ResumenReportes> = {}): ResumenReportes {
|
function resumen(overrides: Partial<ResumenReportes> = {}): ResumenReportes {
|
||||||
return {
|
return {
|
||||||
generadoEn: '2026-08-22T12:00:00.000Z',
|
generadoEn: '2026-08-22T12:00:00.000Z',
|
||||||
leads: { total: 40, nuevosHoy: 3, nuevosSemana: 12, porEstado: {}, porOrigen: {} },
|
leads: {
|
||||||
|
total: 40,
|
||||||
|
nuevosHoy: 3,
|
||||||
|
nuevosSemana: 12,
|
||||||
|
porEstado: {},
|
||||||
|
porOrigen: {},
|
||||||
|
perfil: {
|
||||||
|
porDesarrollo: { ATELIER: 10, DIAGONAL: 8 },
|
||||||
|
porModelo: {},
|
||||||
|
porEstadoCivil: { Soltero: 15, Casado: 9 },
|
||||||
|
porTipoEmpleo: {},
|
||||||
|
porTipoEconomia: {},
|
||||||
|
porCalifica: { Si: 20 },
|
||||||
|
porCredito: {},
|
||||||
|
porRangoSalarial: {},
|
||||||
|
porMedioContacto: {},
|
||||||
|
porLoQuierePara: {},
|
||||||
|
porEdad: { '25–34': 12, '15–24': 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
oportunidades: {
|
oportunidades: {
|
||||||
total: 20,
|
total: 20,
|
||||||
activas: 15,
|
activas: 15,
|
||||||
@@ -121,6 +141,20 @@ describe('datosCobranza', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('rebanadasDe (perfilamiento)', () => {
|
||||||
|
it('ordena desc y filtra los valores en 0', () => {
|
||||||
|
expect(rebanadasDe({ Soltero: 15, Casado: 9, Viudo: 0, 'Union Libre': 22 })).toEqual([
|
||||||
|
{ nombre: 'Union Libre', valor: 22 },
|
||||||
|
{ nombre: 'Soltero', valor: 15 },
|
||||||
|
{ nombre: 'Casado', valor: 9 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registro vacío devuelve lista vacía (la tarjeta muestra "Sin datos")', () => {
|
||||||
|
expect(rebanadasDe({})).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('formatos', () => {
|
describe('formatos', () => {
|
||||||
it('moneda MXN sin centavos', () => {
|
it('moneda MXN sin centavos', () => {
|
||||||
expect(formatoMoneda(125000)).toBe('$125,000')
|
expect(formatoMoneda(125000)).toBe('$125,000')
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ export interface ResumenReportes {
|
|||||||
nuevosSemana: number
|
nuevosSemana: number
|
||||||
porEstado: Record<string, number>
|
porEstado: Record<string, number>
|
||||||
porOrigen: Record<string, number>
|
porOrigen: Record<string, number>
|
||||||
|
/** Perfilamiento de la afluencia: solo valores capturados (sin '(sin dato)') */
|
||||||
|
perfil: {
|
||||||
|
porDesarrollo: Record<string, number>
|
||||||
|
porModelo: Record<string, number>
|
||||||
|
porEstadoCivil: Record<string, number>
|
||||||
|
porTipoEmpleo: Record<string, number>
|
||||||
|
porTipoEconomia: Record<string, number>
|
||||||
|
porCalifica: Record<string, number>
|
||||||
|
porCredito: Record<string, number>
|
||||||
|
porRangoSalarial: Record<string, number>
|
||||||
|
porMedioContacto: Record<string, number>
|
||||||
|
porLoQuierePara: Record<string, number>
|
||||||
|
porEdad: Record<string, number> // buckets '15–24' | '25–34' | '35–44' | '45–54' | '55+'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
oportunidades: {
|
oportunidades: {
|
||||||
total: number
|
total: number
|
||||||
@@ -121,6 +135,17 @@ export function datosCobranza(resumen: ResumenReportes): Rebanada[] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebanadas de una dimensión de perfilamiento (barras horizontales): sin
|
||||||
|
* valores en 0, ordenadas de mayor a menor.
|
||||||
|
*/
|
||||||
|
export function rebanadasDe(registro: Record<string, number>): Rebanada[] {
|
||||||
|
return Object.entries(registro)
|
||||||
|
.filter(([, valor]) => valor > 0)
|
||||||
|
.map(([nombre, valor]) => ({ nombre, valor }))
|
||||||
|
.sort((a, b) => b.valor - a.valor)
|
||||||
|
}
|
||||||
|
|
||||||
const formatoMXN = new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 })
|
const formatoMXN = new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 })
|
||||||
|
|
||||||
/** Formato monetario MXN sin centavos: $1,250,000 */
|
/** Formato monetario MXN sin centavos: $1,250,000 */
|
||||||
|
|||||||
@@ -36,11 +36,21 @@ export interface CapturaMiDia {
|
|||||||
capturadoEn: string // ISO
|
capturadoEn: string // ISO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClienteMiDia {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
proyecto: string // '' = alta aún en captura
|
||||||
|
unidadCodigo: string
|
||||||
|
completado: boolean
|
||||||
|
capturadoEn: string // ISO
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResumenMiDia {
|
export interface ResumenMiDia {
|
||||||
generadoEn: string
|
generadoEn: string
|
||||||
sinMapeoMonday: boolean
|
sinMapeoMonday: boolean
|
||||||
misLeads: LeadMiDia[]
|
misLeads: LeadMiDia[]
|
||||||
misCapturas: CapturaMiDia[]
|
misCapturas: CapturaMiDia[]
|
||||||
|
misClientes: ClienteMiDia[]
|
||||||
misActividadesHoy: ActividadMiDia[]
|
misActividadesHoy: ActividadMiDia[]
|
||||||
apartadosPorVencer: ApartadoPorVencer[]
|
apartadosPorVencer: ApartadoPorVencer[]
|
||||||
misOportunidades: { porEtapa: Record<string, number> } | null
|
misOportunidades: { porEtapa: Record<string, number> } | null
|
||||||
|
|||||||
@@ -398,6 +398,40 @@ export default function MiDiaPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Mis clientes" className="mt-12">
|
||||||
|
<TituloSeccion>Mis clientes</TituloSeccion>
|
||||||
|
{resumen.misClientes.length === 0 ? (
|
||||||
|
<EstadoVacio mensaje="Aún no registras clientes" />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{resumen.misClientes.map((c) => (
|
||||||
|
<li key={c.id} 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.proyecto, c.unidadCodigo].filter((p) => p !== '').join(' · ') || 'Alta sin proyecto'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`border px-2 py-1 text-xs font-bold uppercase tracking-widest ${
|
||||||
|
c.completado
|
||||||
|
? 'border-neutral-300 text-neutral-500'
|
||||||
|
: 'border-amber-700 text-amber-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.completado ? 'Cliente' : 'En captura'}
|
||||||
|
</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