feat: automatizaciones ronda 2 + app de dashboards /dash
- Escalado: lead asignado sin contactar >48h genera actividad URGENTE al coordinador (LEAD_ESCALADO_HORAS, MONDAY_COORDINADOR_USER_ID) - Oportunidad en Aparto crea item en Apartado (fecha límite +7d); en Ganado crea la Venta; ambos con dedup - GET /api/reportes/resumen: leads, funnel, unidades por desarrollo, cobranza y actividades en una respuesta (cache 60s) - App dashboards (/dash): 8 KPIs + 3 gráficas Recharts, con auth - Landing: tarjeta Tableros; docs/AUTOMATIZACIONES-MONDAY.md con las recetas nativas para activar en la UI - Tests: api 158, dashboards 7, landing 5 — todo verde
This commit is contained in:
@@ -64,3 +64,8 @@ NAN_CHAT_MODEL="qwen3.6"
|
|||||||
NAN_EMBED_MODEL="qwen3-embedding"
|
NAN_EMBED_MODEL="qwen3-embedding"
|
||||||
# Umbral mínimo de similitud coseno para recuperar contexto (0-1)
|
# Umbral mínimo de similitud coseno para recuperar contexto (0-1)
|
||||||
AI_MIN_SCORE="0.3"
|
AI_MIN_SCORE="0.3"
|
||||||
|
|
||||||
|
# Ronda 2: horas antes de escalar un lead asignado que sigue en NUEVO/Intento de contacto
|
||||||
|
LEAD_ESCALADO_HORAS=48
|
||||||
|
# User ID Monday del coordinador para escalados; vacio = actividad al vendedor asignado
|
||||||
|
MONDAY_COORDINADOR_USER_ID=
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Ecosistema comercial de Frasa Desarrollos S.A.P.I. de C.V.
|
|||||||
| `apps/cotizador` | `/quote/` | Cotizador financiero (4 propuestas, PDF ANEXO A) |
|
| `apps/cotizador` | `/quote/` | Cotizador financiero (4 propuestas, PDF ANEXO A) |
|
||||||
| `apps/alta-clientes` | `/client/` | Alta de clientes (wizard, firma digital, PDFs PLD) |
|
| `apps/alta-clientes` | `/client/` | Alta de clientes (wizard, firma digital, PDFs PLD) |
|
||||||
| `apps/cobranza` | `/pay/` | Planes de enganche, vencidos, estados de cuenta |
|
| `apps/cobranza` | `/pay/` | Planes de enganche, vencidos, estados de cuenta |
|
||||||
|
| `apps/dashboards` | `/dash/` | Tableros KPI (leads, oportunidades, inventario, cobranza) |
|
||||||
| `api-gateway` | `/api/` | Express 5 + Prisma + integración Monday.com |
|
| `api-gateway` | `/api/` | Express 5 + Prisma + integración Monday.com |
|
||||||
|
|
||||||
## Desarrollo local
|
## Desarrollo local
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export const config = {
|
|||||||
// IDs de usuario Monday separados por coma; vacío = auto-detectar usuarios no-guest del workspace
|
// IDs de usuario Monday separados por coma; vacío = auto-detectar usuarios no-guest del workspace
|
||||||
rrUserIds: env('MONDAY_RR_USER_IDS', ''),
|
rrUserIds: env('MONDAY_RR_USER_IDS', ''),
|
||||||
inactividadDias: Number(env('INACTIVIDAD_DIAS', '10')),
|
inactividadDias: Number(env('INACTIVIDAD_DIAS', '10')),
|
||||||
|
// Horas que un lead asignado puede seguir en NUEVO/Intento de contacto antes de escalar
|
||||||
|
escaladoHoras: Number(env('LEAD_ESCALADO_HORAS', '48')),
|
||||||
|
// User ID de Monday del coordinador que recibe los escalados; vacío = al vendedor asignado
|
||||||
|
coordinadorUserId: Number(env('MONDAY_COORDINADOR_USER_ID', '0')),
|
||||||
pollCron: env('POLL_CRON', '*/5 * * * *'),
|
pollCron: env('POLL_CRON', '*/5 * * * *'),
|
||||||
recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'),
|
recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,7 +36,14 @@ type PlanCompleto = PlanPago & {
|
|||||||
notas: NotaPlan[]
|
notas: NotaPlan[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function aDominio(plan: PlanCompleto): PlanPagoDominio {
|
/** Todos los planes en forma de dominio (lo que necesita el agregador de reportes) */
|
||||||
|
export async function cargarPlanesDominio(): Promise<PlanPagoDominio[]> {
|
||||||
|
const planes = await prisma.planPago.findMany({ include: includeCompleto })
|
||||||
|
return planes.map(aDominio)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exportado también para reportes.controller (agregador de cobranza)
|
||||||
|
export function aDominio(plan: PlanCompleto): PlanPagoDominio {
|
||||||
return {
|
return {
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
ventaRef: plan.ventaRef,
|
ventaRef: plan.ventaRef,
|
||||||
|
|||||||
12
api-gateway/src/controllers/reportes.controller.ts
Normal file
12
api-gateway/src/controllers/reportes.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Request, Response } from 'express'
|
||||||
|
import { mondayService } from '../services/monday.service.js'
|
||||||
|
import { ReportesService } from '../services/reportes.service.js'
|
||||||
|
import { cargarPlanesDominio } from './planes.controller.js'
|
||||||
|
|
||||||
|
/** Singleton del agregador (cache en memoria de 60 s; los tests instancian el suyo) */
|
||||||
|
export const reportesService = new ReportesService(mondayService, cargarPlanesDominio)
|
||||||
|
|
||||||
|
/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta */
|
||||||
|
export async function resumenReportes(_req: Request, res: Response): Promise<void> {
|
||||||
|
res.json(await reportesService.resumen())
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ const app = crearApp()
|
|||||||
// Crons de respaldo (el webhook público aún no existe: no hay SSL ni DNS).
|
// Crons de respaldo (el webhook público aún no existe: no hay SSL ni DNS).
|
||||||
// Se desactivan con MONDAY_AUTOMATIONS_ENABLED=false.
|
// Se desactivan con MONDAY_AUTOMATIONS_ENABLED=false.
|
||||||
if (config.automatizaciones.enabled) {
|
if (config.automatizaciones.enabled) {
|
||||||
// Polling: asigna vendedor (round-robin) a leads nuevos sin persona asignada
|
// Polling: asigna vendedor (round-robin) a leads nuevos sin persona asignada,
|
||||||
|
// escala leads sin contactar y crea Apartados/Ventas al moverse Oportunidades.
|
||||||
|
// Cada regla tiene su propio catch: un fallo de Monday no rompe las demás.
|
||||||
cron.schedule(config.automatizaciones.pollCron, () => {
|
cron.schedule(config.automatizaciones.pollCron, () => {
|
||||||
automatizacionesService
|
automatizacionesService
|
||||||
.runRoundRobin()
|
.runRoundRobin()
|
||||||
@@ -19,6 +21,29 @@ if (config.automatizaciones.enabled) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => console.error('[cron poll] error en round-robin:', err))
|
.catch((err: unknown) => console.error('[cron poll] error en round-robin:', err))
|
||||||
|
|
||||||
|
automatizacionesService
|
||||||
|
.escaneoEscalado()
|
||||||
|
.then((r) => {
|
||||||
|
if (r.escalados > 0) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[cron poll] escalado: ${r.escalados} lead(s) escalado(s)`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => console.error('[cron poll] error en escalado:', err))
|
||||||
|
|
||||||
|
automatizacionesService
|
||||||
|
.escaneoOportunidades()
|
||||||
|
.then((r) => {
|
||||||
|
if (r.apartadosCreados.length > 0 || r.ventasCreadas.length > 0 || r.errores.length > 0) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[cron poll] oportunidades: ${r.apartadosCreados.length} apartado(s), ` +
|
||||||
|
`${r.ventasCreadas.length} venta(s), ${r.errores.length} error(es)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => console.error('[cron poll] error en oportunidades:', err))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Escaneo diario de leads inactivos (recordatorio vía update en el item)
|
// Escaneo diario de leads inactivos (recordatorio vía update en el item)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
subirDocumento,
|
subirDocumento,
|
||||||
} from '../controllers/ai.controller.js'
|
} from '../controllers/ai.controller.js'
|
||||||
import { loginEndpoint, meEndpoint } from '../controllers/auth.controller.js'
|
import { loginEndpoint, meEndpoint } from '../controllers/auth.controller.js'
|
||||||
|
import { resumenReportes } from '../controllers/reportes.controller.js'
|
||||||
|
|
||||||
export const apiRouter = Router()
|
export const apiRouter = Router()
|
||||||
|
|
||||||
@@ -70,6 +71,8 @@ apiRouter.patch('/planes/:id/monday', ligarMondayItem)
|
|||||||
|
|
||||||
apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
|
apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
|
||||||
|
|
||||||
|
apiRouter.get('/reportes/resumen', resumenReportes)
|
||||||
|
|
||||||
apiRouter.post('/webhooks/monday', webhookMonday)
|
apiRouter.post('/webhooks/monday', webhookMonday)
|
||||||
|
|
||||||
apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones)
|
apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones)
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { config } from '../config/index.js'
|
||||||
import {
|
import {
|
||||||
AutomatizacionesService,
|
AutomatizacionesService,
|
||||||
|
claveApartadoCreado,
|
||||||
|
claveEscalado,
|
||||||
claveRecordatorio,
|
claveRecordatorio,
|
||||||
|
claveVentaCreada,
|
||||||
esEstadoTerminal,
|
esEstadoTerminal,
|
||||||
|
esEtapa,
|
||||||
leadsInactivos,
|
leadsInactivos,
|
||||||
|
leadsSinContactar,
|
||||||
parseRrUserIds,
|
parseRrUserIds,
|
||||||
vendedorEnTurno,
|
vendedorEnTurno,
|
||||||
type StateStore,
|
type StateStore,
|
||||||
} from './automatizaciones.service.js'
|
} from './automatizaciones.service.js'
|
||||||
import type { LeadMonday, UsuarioMonday } from './monday.service.js'
|
import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js'
|
||||||
|
|
||||||
// ---------- Fixtures ----------
|
// ---------- Fixtures ----------
|
||||||
|
|
||||||
@@ -22,13 +28,27 @@ function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
nombre: `Lead ${id}`,
|
nombre: `Lead ${id}`,
|
||||||
|
createdAt: '2026-08-01T09:00:00Z',
|
||||||
updatedAt: '2026-08-01T10:00:00Z',
|
updatedAt: '2026-08-01T10:00:00Z',
|
||||||
estado: 'NUEVO',
|
estado: 'NUEVO',
|
||||||
|
origen: '',
|
||||||
vendedorIds: [],
|
vendedorIds: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function oportunidad(id: string, overrides: Partial<OportunidadMonday> = {}): OportunidadMonday {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
nombre: `Cliente ${id}`,
|
||||||
|
createdAt: '2026-08-01T09:00:00Z',
|
||||||
|
etapa: 'Cotizador',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
precioFinal: 2500000,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** StateStore en memoria para tests (sin Prisma) */
|
/** StateStore en memoria para tests (sin Prisma) */
|
||||||
function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
|
function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
|
||||||
const mapa = new Map<string, string>()
|
const mapa = new Map<string, string>()
|
||||||
@@ -44,7 +64,7 @@ function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Monday mockeado: leads fijos, registra asignaciones, updates y actividades */
|
/** Monday mockeado: leads fijos, registra asignaciones, updates y actividades */
|
||||||
function mondayMock(leads: LeadMonday[]) {
|
function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = []) {
|
||||||
return {
|
return {
|
||||||
asignaciones: [] as { itemId: string; userId: number }[],
|
asignaciones: [] as { itemId: string; userId: number }[],
|
||||||
updates: [] as { itemId: string; body: string }[],
|
updates: [] as { itemId: string; body: string }[],
|
||||||
@@ -56,9 +76,14 @@ function mondayMock(leads: LeadMonday[]) {
|
|||||||
estadoLabel: string
|
estadoLabel: string
|
||||||
leadItemId: string
|
leadItemId: string
|
||||||
}[],
|
}[],
|
||||||
|
apartados: [] as Record<string, unknown>[],
|
||||||
|
ventas: [] as Record<string, unknown>[],
|
||||||
async getLeads() {
|
async getLeads() {
|
||||||
return leads
|
return leads
|
||||||
},
|
},
|
||||||
|
async getOportunidades() {
|
||||||
|
return oportunidades
|
||||||
|
},
|
||||||
async asignarVendedorALead(itemId: string, userId: number) {
|
async asignarVendedorALead(itemId: string, userId: number) {
|
||||||
this.asignaciones.push({ itemId, userId })
|
this.asignaciones.push({ itemId, userId })
|
||||||
},
|
},
|
||||||
@@ -77,6 +102,14 @@ function mondayMock(leads: LeadMonday[]) {
|
|||||||
this.actividades.push(datos)
|
this.actividades.push(datos)
|
||||||
return 'act-1'
|
return 'act-1'
|
||||||
},
|
},
|
||||||
|
async crearApartado(datos: Record<string, unknown>) {
|
||||||
|
this.apartados.push(datos)
|
||||||
|
return `apt-${this.apartados.length}`
|
||||||
|
},
|
||||||
|
async crearVenta(datos: Record<string, unknown>) {
|
||||||
|
this.ventas.push(datos)
|
||||||
|
return `vta-${this.ventas.length}`
|
||||||
|
},
|
||||||
async getUsuariosWorkspace() {
|
async getUsuariosWorkspace() {
|
||||||
return VENDEDORES
|
return VENDEDORES
|
||||||
},
|
},
|
||||||
@@ -339,3 +372,208 @@ describe('ESTADOS_TERMINALES_LEAD con vocabulario CRM', () => {
|
|||||||
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
|
expect(esEstadoTerminal('Intento de contacto')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------- Ronda 2: escalado de leads sin contactar ----------
|
||||||
|
|
||||||
|
describe('leadsSinContactar (regla de escalado por horas)', () => {
|
||||||
|
const ahora = new Date('2026-08-22T12:00:00Z')
|
||||||
|
|
||||||
|
it('incluye leads asignados en NUEVO / Intento de contacto que superan el umbral', () => {
|
||||||
|
const leads = [
|
||||||
|
lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }), // 72 h → escala
|
||||||
|
lead('2', { vendedorIds: [102], estado: 'Intento de contacto', updatedAt: '2026-08-20T12:00:00Z' }), // 48 h justo → no
|
||||||
|
lead('3', { vendedorIds: [103], estado: 'CONTACTADO', updatedAt: '2026-08-01T12:00:00Z' }), // ya contactado
|
||||||
|
lead('4', { estado: 'NUEVO', updatedAt: '2026-08-01T12:00:00Z' }), // sin vendedor → no aplica
|
||||||
|
lead('5', { vendedorIds: [101], estado: 'DESCARTADO', updatedAt: '2026-08-01T12:00:00Z' }), // terminal
|
||||||
|
]
|
||||||
|
expect(leadsSinContactar(leads, ahora, 48).map((l) => l.id)).toEqual(['1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un lead con exactamente N horas aún no supera el umbral', () => {
|
||||||
|
const leads = [lead('1', { vendedorIds: [101], updatedAt: '2026-08-20T12:00:00Z' })]
|
||||||
|
expect(leadsSinContactar(leads, ahora, 48)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignora fechas inválidas', () => {
|
||||||
|
const leads = [lead('1', { vendedorIds: [101], updatedAt: 'no-es-fecha' })]
|
||||||
|
expect(leadsSinContactar(leads, ahora, 48)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('claves de dedup de ronda 2', () => {
|
||||||
|
it('una clave por lead/oportunidad', () => {
|
||||||
|
expect(claveEscalado('123')).toBe('escalado:123')
|
||||||
|
expect(claveApartadoCreado('456')).toBe('apartado-creado:456')
|
||||||
|
expect(claveVentaCreada('456')).toBe('venta-creada:456')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('esEtapa (detección de transición de etapa)', () => {
|
||||||
|
it('compara tolerando mayúsculas y espacios', () => {
|
||||||
|
expect(esEtapa('Aparto', 'APARTO')).toBe(true)
|
||||||
|
expect(esEtapa(' ganado ', 'Ganado')).toBe(true)
|
||||||
|
expect(esEtapa('Cotizador', 'Aparto')).toBe(false)
|
||||||
|
expect(esEtapa('', 'Ganado')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AutomatizacionesService.escaneoEscalado', () => {
|
||||||
|
const ahora = new Date('2026-08-22T12:00:00Z')
|
||||||
|
const configAuto = config.automatizaciones as Record<string, unknown>
|
||||||
|
|
||||||
|
it('escala al vendedor asignado cuando no hay coordinador configurado', async () => {
|
||||||
|
const anterior = configAuto['coordinadorUserId']
|
||||||
|
configAuto['coordinadorUserId'] = 0
|
||||||
|
try {
|
||||||
|
const monday = mondayMock([lead('1', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
|
||||||
|
const state = stateEnMemoria()
|
||||||
|
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
|
||||||
|
|
||||||
|
const r = await svc.escaneoEscalado({ ahora })
|
||||||
|
expect(r.escalados).toBe(1)
|
||||||
|
expect(r.candidatos[0]).toMatchObject({ itemId: '1', horasSinContactar: 72, responsableId: 102, yaEscalado: false })
|
||||||
|
expect(monday.actividades[0]).toMatchObject({
|
||||||
|
nombre: 'URGENTE: lead sin contactar — Lead 1',
|
||||||
|
responsableId: 102,
|
||||||
|
estadoLabel: 'Abierto',
|
||||||
|
leadItemId: '1',
|
||||||
|
})
|
||||||
|
expect(monday.updates[0]?.body).toContain('72 h')
|
||||||
|
expect(state.mapa.get('escalado:1')).toBeDefined()
|
||||||
|
expect(state.mapa.get('contador:escalados')).toBe('1')
|
||||||
|
} finally {
|
||||||
|
configAuto['coordinadorUserId'] = anterior
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escala al coordinador cuando MONDAY_COORDINADOR_USER_ID está configurado', async () => {
|
||||||
|
const anterior = configAuto['coordinadorUserId']
|
||||||
|
configAuto['coordinadorUserId'] = 999
|
||||||
|
try {
|
||||||
|
const monday = mondayMock([lead('1', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' })])
|
||||||
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||||
|
const r = await svc.escaneoEscalado({ ahora })
|
||||||
|
expect(r.escalados).toBe(1)
|
||||||
|
expect(monday.actividades[0]?.responsableId).toBe(999)
|
||||||
|
expect(r.candidatos[0]?.responsableId).toBe(999)
|
||||||
|
} finally {
|
||||||
|
configAuto['coordinadorUserId'] = anterior
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedup: no vuelve a escalar un lead ya escalado', async () => {
|
||||||
|
const anterior = configAuto['coordinadorUserId']
|
||||||
|
configAuto['coordinadorUserId'] = 0
|
||||||
|
try {
|
||||||
|
const monday = mondayMock([lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' })])
|
||||||
|
const state = stateEnMemoria()
|
||||||
|
const svc = new AutomatizacionesService(monday, state, { actividadesEnabled: true })
|
||||||
|
|
||||||
|
const r1 = await svc.escaneoEscalado({ ahora })
|
||||||
|
expect(r1.escalados).toBe(1)
|
||||||
|
const r2 = await svc.escaneoEscalado({ ahora: new Date('2026-08-23T12:00:00Z') })
|
||||||
|
expect(r2.escalados).toBe(0)
|
||||||
|
expect(r2.candidatos[0]?.yaEscalado).toBe(true)
|
||||||
|
expect(monday.actividades).toHaveLength(1)
|
||||||
|
expect(state.mapa.get('contador:escalados')).toBe('1')
|
||||||
|
} finally {
|
||||||
|
configAuto['coordinadorUserId'] = anterior
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un fallo de Monday en un lead no detiene el escalado de los demás', async () => {
|
||||||
|
const anterior = configAuto['coordinadorUserId']
|
||||||
|
configAuto['coordinadorUserId'] = 0
|
||||||
|
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
try {
|
||||||
|
const monday = mondayMock([
|
||||||
|
lead('1', { vendedorIds: [101], updatedAt: '2026-08-19T12:00:00Z' }),
|
||||||
|
lead('2', { vendedorIds: [102], updatedAt: '2026-08-19T12:00:00Z' }),
|
||||||
|
])
|
||||||
|
vi.spyOn(monday, 'crearUpdateEnItem')
|
||||||
|
.mockRejectedValueOnce(new Error('Monday caído'))
|
||||||
|
.mockResolvedValue('upd-ok')
|
||||||
|
const svc = new AutomatizacionesService(monday, stateEnMemoria(), { actividadesEnabled: true })
|
||||||
|
|
||||||
|
const r = await svc.escaneoEscalado({ ahora })
|
||||||
|
expect(r.escalados).toBe(1) // el segundo lead sí se escaló
|
||||||
|
expect(r.candidatos).toHaveLength(2)
|
||||||
|
} finally {
|
||||||
|
configAuto['coordinadorUserId'] = anterior
|
||||||
|
spyError.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- Ronda 2: Oportunidad → Aparto / Ganado ----------
|
||||||
|
|
||||||
|
describe('AutomatizacionesService.escaneoOportunidades', () => {
|
||||||
|
it('una oportunidad en Aparto crea su item en Apartado (estado Pago Apartado)', async () => {
|
||||||
|
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' })])
|
||||||
|
const state = stateEnMemoria()
|
||||||
|
const svc = crearServicio(monday, state)
|
||||||
|
|
||||||
|
const r = await svc.escaneoOportunidades()
|
||||||
|
expect(r.apartadosCreados).toEqual([{ oportunidadId: '10', nombre: 'Cliente 10', apartadoItemId: 'apt-1' }])
|
||||||
|
expect(monday.apartados[0]).toMatchObject({
|
||||||
|
clienteNombre: 'Cliente 10',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
estadoLabel: 'Pago Apartado',
|
||||||
|
})
|
||||||
|
expect(state.mapa.get('apartado-creado:10')).toBe('apt-1')
|
||||||
|
expect(state.mapa.get('contador:apartadosCreados')).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('una oportunidad en Ganado crea su item en Ventas con unidad y valor', async () => {
|
||||||
|
const monday = mondayMock([], [oportunidad('11', { etapa: 'GANADO' })])
|
||||||
|
const state = stateEnMemoria()
|
||||||
|
const svc = crearServicio(monday, state)
|
||||||
|
|
||||||
|
const r = await svc.escaneoOportunidades()
|
||||||
|
expect(r.ventasCreadas).toEqual([{ oportunidadId: '11', nombre: 'Cliente 11', ventaItemId: 'vta-1' }])
|
||||||
|
expect(monday.ventas[0]).toMatchObject({ clienteNombre: 'Cliente 11', unidadCodigo: 'U-101', valorVenta: 2500000 })
|
||||||
|
expect(state.mapa.get('venta-creada:11')).toBe('vta-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedup: la segunda pasada no duplica apartados ni ventas', async () => {
|
||||||
|
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
|
||||||
|
const state = stateEnMemoria()
|
||||||
|
const svc = crearServicio(monday, state)
|
||||||
|
|
||||||
|
await svc.escaneoOportunidades()
|
||||||
|
const r2 = await svc.escaneoOportunidades()
|
||||||
|
expect(r2.apartadosCreados).toEqual([])
|
||||||
|
expect(r2.ventasCreadas).toEqual([])
|
||||||
|
expect(monday.apartados).toHaveLength(1)
|
||||||
|
expect(monday.ventas).toHaveLength(1)
|
||||||
|
expect(state.mapa.get('contador:apartadosCreados')).toBe('1')
|
||||||
|
expect(state.mapa.get('contador:ventasCreadas')).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignora oportunidades en otras etapas', async () => {
|
||||||
|
const monday = mondayMock([], [
|
||||||
|
oportunidad('10', { etapa: 'Cotizador' }),
|
||||||
|
oportunidad('11', { etapa: 'Perdido' }),
|
||||||
|
oportunidad('12', { etapa: 'Afluencia' }),
|
||||||
|
])
|
||||||
|
const svc = crearServicio(monday, stateEnMemoria())
|
||||||
|
const r = await svc.escaneoOportunidades()
|
||||||
|
expect(r.apartadosCreados).toEqual([])
|
||||||
|
expect(r.ventasCreadas).toEqual([])
|
||||||
|
expect(monday.apartados).toEqual([])
|
||||||
|
expect(monday.ventas).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un fallo de Monday en una oportunidad no detiene a las demás', async () => {
|
||||||
|
const monday = mondayMock([], [oportunidad('10', { etapa: 'Aparto' }), oportunidad('11', { etapa: 'Ganado' })])
|
||||||
|
vi.spyOn(monday, 'crearApartado').mockRejectedValueOnce(new Error('Monday caído'))
|
||||||
|
const state = stateEnMemoria()
|
||||||
|
const svc = crearServicio(monday, state)
|
||||||
|
|
||||||
|
const r = await svc.escaneoOportunidades()
|
||||||
|
expect(r.errores).toHaveLength(1)
|
||||||
|
expect(r.errores[0]?.oportunidadId).toBe('10')
|
||||||
|
expect(r.ventasCreadas).toHaveLength(1) // la segunda oportunidad sí se procesó
|
||||||
|
expect(state.mapa.get('apartado-creado:10')).toBeUndefined() // sin dedup: se reintenta en la próxima pasada
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -18,12 +18,26 @@ import { prisma } from './prisma.js'
|
|||||||
// Abandono...). Terminales: DESCARTADO y ABANDONO (fuera de seguimiento).
|
// Abandono...). Terminales: DESCARTADO y ABANDONO (fuera de seguimiento).
|
||||||
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'ABANDONO'] as const
|
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'ABANDONO'] as const
|
||||||
|
|
||||||
|
// Estados en los que un lead asignado aún no ha sido contactado de verdad;
|
||||||
|
// si permanece así más de LEAD_ESCALADO_HORAS horas se escala al coordinador.
|
||||||
|
export const ESTADOS_SIN_CONTACTAR = ['NUEVO', 'INTENTO DE CONTACTO'] as const
|
||||||
|
|
||||||
|
// Etapas del tablero Oportunidades que disparan las reglas de ronda 2
|
||||||
|
export const ETAPA_APARTO = 'APARTO'
|
||||||
|
export const ETAPA_GANADO = 'GANADO'
|
||||||
|
|
||||||
const CLAVE_RR_INDICE = 'rr:indice'
|
const CLAVE_RR_INDICE = 'rr:indice'
|
||||||
const CLAVE_RR_VENDEDORES = 'rr:vendedores'
|
const CLAVE_RR_VENDEDORES = 'rr:vendedores'
|
||||||
const CLAVE_RR_ULTIMA_PASADA = 'rr:ultimaPasada'
|
const CLAVE_RR_ULTIMA_PASADA = 'rr:ultimaPasada'
|
||||||
const CLAVE_INACTIVIDAD_ULTIMO_ESCANEO = 'inactividad:ultimoEscaneo'
|
const CLAVE_INACTIVIDAD_ULTIMO_ESCANEO = 'inactividad:ultimoEscaneo'
|
||||||
|
const CLAVE_ESCALADO_ULTIMO_ESCANEO = 'escalado:ultimoEscaneo'
|
||||||
|
const CLAVE_OPORTUNIDADES_ULTIMO_ESCANEO = 'oportunidades:ultimoEscaneo'
|
||||||
|
const CLAVE_CONTADOR_ESCALADOS = 'contador:escalados'
|
||||||
|
const CLAVE_CONTADOR_APARTADOS = 'contador:apartadosCreados'
|
||||||
|
const CLAVE_CONTADOR_VENTAS = 'contador:ventasCreadas'
|
||||||
|
|
||||||
const DIA_MS = 24 * 60 * 60 * 1000
|
const DIA_MS = 24 * 60 * 60 * 1000
|
||||||
|
const HORA_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
/** Store clave-valor inyectable (Prisma en prod, Map en tests) */
|
/** Store clave-valor inyectable (Prisma en prod, Map en tests) */
|
||||||
export interface StateStore {
|
export interface StateStore {
|
||||||
@@ -82,11 +96,55 @@ export function diasSinActividad(lead: LeadMonday, ahora: Date): number {
|
|||||||
return Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / DIA_MS)
|
return Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / DIA_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leads ASIGNADOS (con vendedor) que siguen en estado NUEVO / Intento de contacto
|
||||||
|
* y llevan más de `horasUmbral` horas sin actualizarse: candidatos a escalar.
|
||||||
|
* (El round-robin actualiza el item al asignar, así que el reloj arranca ahí.)
|
||||||
|
*/
|
||||||
|
export function leadsSinContactar(leads: readonly LeadMonday[], ahora: Date, horasUmbral: number): LeadMonday[] {
|
||||||
|
const limiteMs = horasUmbral * HORA_MS
|
||||||
|
return leads.filter((lead) => {
|
||||||
|
if (lead.vendedorIds.length === 0) return false
|
||||||
|
if (esEstadoTerminal(lead.estado)) return false
|
||||||
|
if (!ESTADOS_SIN_CONTACTAR.some((e) => e === lead.estado.trim().toUpperCase())) return false
|
||||||
|
const t = Date.parse(lead.updatedAt)
|
||||||
|
if (Number.isNaN(t)) return false
|
||||||
|
return ahora.getTime() - t > limiteMs
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clave de dedup del escalado: un escalado por lead (una sola vez) */
|
||||||
|
export function claveEscalado(itemId: string): string {
|
||||||
|
return `escalado:${itemId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dedup: un item en Apartado por oportunidad que entra a 'Aparto' */
|
||||||
|
export function claveApartadoCreado(oportunidadId: string): string {
|
||||||
|
return `apartado-creado:${oportunidadId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dedup: un item en Ventas por oportunidad que entra a 'Ganado' */
|
||||||
|
export function claveVentaCreada(oportunidadId: string): string {
|
||||||
|
return `venta-creada:${oportunidadId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Comparación de etapa tolerante a mayúsculas y espacios ('Aparto' === 'APARTO') */
|
||||||
|
export function esEtapa(etapa: string, esperada: string): boolean {
|
||||||
|
return etapa.trim().toUpperCase() === esperada.trim().toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Servicio ----------
|
// ---------- Servicio ----------
|
||||||
|
|
||||||
type MondayLeads = Pick<
|
type MondayLeads = Pick<
|
||||||
MondayService,
|
MondayService,
|
||||||
'getLeads' | 'asignarVendedorALead' | 'crearUpdateEnItem' | 'getUsuariosWorkspace' | 'crearActividad'
|
| 'getLeads'
|
||||||
|
| 'getOportunidades'
|
||||||
|
| 'asignarVendedorALead'
|
||||||
|
| 'crearUpdateEnItem'
|
||||||
|
| 'getUsuariosWorkspace'
|
||||||
|
| 'crearActividad'
|
||||||
|
| 'crearApartado'
|
||||||
|
| 'crearVenta'
|
||||||
>
|
>
|
||||||
|
|
||||||
export interface OpcionesAutomatizaciones {
|
export interface OpcionesAutomatizaciones {
|
||||||
@@ -122,6 +180,27 @@ export interface ResultadoInactividad {
|
|||||||
recordatoriosEnviados: number
|
recordatoriosEnviados: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CandidatoEscalado {
|
||||||
|
itemId: string
|
||||||
|
lead: string
|
||||||
|
estado: string
|
||||||
|
horasSinContactar: number
|
||||||
|
responsableId: number | null // coordinador o vendedor asignado; null si no hay a quién asignar
|
||||||
|
yaEscalado: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoEscalado {
|
||||||
|
horasUmbral: number
|
||||||
|
candidatos: CandidatoEscalado[]
|
||||||
|
escalados: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultadoOportunidades {
|
||||||
|
apartadosCreados: { oportunidadId: string; nombre: string; apartadoItemId: string }[]
|
||||||
|
ventasCreadas: { oportunidadId: string; nombre: string; ventaItemId: string }[]
|
||||||
|
errores: { oportunidadId: string; mensaje: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
export class AutomatizacionesService {
|
export class AutomatizacionesService {
|
||||||
private readonly actividadesEnabled: boolean
|
private readonly actividadesEnabled: boolean
|
||||||
|
|
||||||
@@ -274,6 +353,127 @@ export class AutomatizacionesService {
|
|||||||
return { preview: opciones.preview, diasUmbral, candidatos, recordatoriosEnviados }
|
return { preview: opciones.preview, diasUmbral, candidatos, recordatoriosEnviados }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Suma `n` a un contador acumulado en el estado (para el endpoint admin) */
|
||||||
|
private async incrementarContador(clave: string, n: number): Promise<void> {
|
||||||
|
if (n === 0) return
|
||||||
|
const actual = Number((await this.state.get(clave)) ?? '0')
|
||||||
|
await this.state.set(clave, String((Number.isFinite(actual) ? actual : 0) + n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Responsable del escalado: coordinador (env) o, si no hay, el vendedor asignado */
|
||||||
|
private responsableEscalado(lead: LeadMonday): number | null {
|
||||||
|
const coordinador = config.automatizaciones.coordinadorUserId
|
||||||
|
if (Number.isInteger(coordinador) && coordinador > 0) return coordinador
|
||||||
|
return lead.vendedorIds[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escalado de leads sin contactar: un lead asignado que sigue en
|
||||||
|
* NUEVO / Intento de contacto más de LEAD_ESCALADO_HORAS horas genera una
|
||||||
|
* actividad URGENTE al coordinador (o al vendedor) + update en el item.
|
||||||
|
* Un escalado por lead (dedup `escalado:<itemId>`); un fallo de Monday en
|
||||||
|
* un lead no detiene a los demás.
|
||||||
|
*/
|
||||||
|
async escaneoEscalado(opciones: { ahora?: Date } = {}): Promise<ResultadoEscalado> {
|
||||||
|
const ahora = opciones.ahora ?? new Date()
|
||||||
|
const horasUmbral = config.automatizaciones.escaladoHoras
|
||||||
|
const leads = await this.monday.getLeads()
|
||||||
|
const pendientes = leadsSinContactar(leads, ahora, horasUmbral)
|
||||||
|
|
||||||
|
const candidatos: CandidatoEscalado[] = []
|
||||||
|
let escalados = 0
|
||||||
|
for (const lead of pendientes) {
|
||||||
|
const responsableId = this.responsableEscalado(lead)
|
||||||
|
const horas = Math.floor((ahora.getTime() - Date.parse(lead.updatedAt)) / HORA_MS)
|
||||||
|
const yaEscalado = (await this.state.get(claveEscalado(lead.id))) !== null
|
||||||
|
candidatos.push({
|
||||||
|
itemId: lead.id,
|
||||||
|
lead: lead.nombre,
|
||||||
|
estado: lead.estado,
|
||||||
|
horasSinContactar: horas,
|
||||||
|
responsableId,
|
||||||
|
yaEscalado,
|
||||||
|
})
|
||||||
|
if (yaEscalado) continue
|
||||||
|
try {
|
||||||
|
if (this.actividadesEnabled && responsableId !== null) {
|
||||||
|
await this.monday.crearActividad({
|
||||||
|
nombre: `URGENTE: lead sin contactar — ${lead.nombre}`,
|
||||||
|
tipoLabel: 'Llamada',
|
||||||
|
responsableId,
|
||||||
|
fecha: AutomatizacionesService.fechaHoyMas(0),
|
||||||
|
estadoLabel: 'Abierto',
|
||||||
|
leadItemId: lead.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await this.monday.crearUpdateEnItem(
|
||||||
|
lead.id,
|
||||||
|
`🚨 Escalado automático: el lead lleva ${horas} h asignado en estado "${lead.estado}" sin contacto. ` +
|
||||||
|
`Se notificó a ${responsableId !== null && responsableId !== lead.vendedorIds[0] ? 'la coordinación' : 'el vendedor asignado'}.`,
|
||||||
|
)
|
||||||
|
await this.state.set(claveEscalado(lead.id), ahora.toISOString())
|
||||||
|
escalados++
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`[escalado] falló el escalado del lead ${lead.id} (${lead.nombre}):`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.incrementarContador(CLAVE_CONTADOR_ESCALADOS, escalados)
|
||||||
|
await this.state.set(CLAVE_ESCALADO_ULTIMO_ESCANEO, ahora.toISOString())
|
||||||
|
return { horasUmbral, candidatos, escalados }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oportunidades → Apartado / Ventas (polling):
|
||||||
|
* - etapa 'Aparto' sin item en Apartado → crea el apartado (estado inicial
|
||||||
|
* 'Pago Apartado', vigencia de 7 días). Dedup `apartado-creado:<id>`.
|
||||||
|
* - etapa 'Ganado' sin item en Ventas → crea la venta con unidad y valor.
|
||||||
|
* Dedup `venta-creada:<id>`.
|
||||||
|
* Un fallo de Monday en una oportunidad no detiene a las demás.
|
||||||
|
*/
|
||||||
|
async escaneoOportunidades(): Promise<ResultadoOportunidades> {
|
||||||
|
const oportunidades = await this.monday.getOportunidades()
|
||||||
|
const resultado: ResultadoOportunidades = { apartadosCreados: [], ventasCreadas: [], errores: [] }
|
||||||
|
|
||||||
|
for (const op of oportunidades) {
|
||||||
|
if (esEtapa(op.etapa, ETAPA_APARTO) && (await this.state.get(claveApartadoCreado(op.id))) === null) {
|
||||||
|
try {
|
||||||
|
const apartadoItemId = await this.monday.crearApartado({
|
||||||
|
clienteNombre: op.nombre,
|
||||||
|
unidadCodigo: op.unidadCodigo,
|
||||||
|
fechaApartado: AutomatizacionesService.fechaHoyMas(0),
|
||||||
|
fechaLimite: AutomatizacionesService.fechaHoyMas(7),
|
||||||
|
estadoLabel: 'Pago Apartado',
|
||||||
|
})
|
||||||
|
await this.state.set(claveApartadoCreado(op.id), apartadoItemId)
|
||||||
|
resultado.apartadosCreados.push({ oportunidadId: op.id, nombre: op.nombre, apartadoItemId })
|
||||||
|
} catch (err) {
|
||||||
|
resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) })
|
||||||
|
}
|
||||||
|
continue // una oportunidad no puede estar en dos etapas a la vez
|
||||||
|
}
|
||||||
|
if (esEtapa(op.etapa, ETAPA_GANADO) && (await this.state.get(claveVentaCreada(op.id))) === null) {
|
||||||
|
try {
|
||||||
|
const ventaItemId = await this.monday.crearVenta({
|
||||||
|
clienteNombre: op.nombre,
|
||||||
|
...(op.unidadCodigo !== '' ? { unidadCodigo: op.unidadCodigo } : {}),
|
||||||
|
...(op.precioFinal > 0 ? { valorVenta: op.precioFinal } : {}),
|
||||||
|
})
|
||||||
|
await this.state.set(claveVentaCreada(op.id), ventaItemId)
|
||||||
|
resultado.ventasCreadas.push({ oportunidadId: op.id, nombre: op.nombre, ventaItemId })
|
||||||
|
} catch (err) {
|
||||||
|
resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.incrementarContador(CLAVE_CONTADOR_APARTADOS, resultado.apartadosCreados.length)
|
||||||
|
await this.incrementarContador(CLAVE_CONTADOR_VENTAS, resultado.ventasCreadas.length)
|
||||||
|
await this.state.set(CLAVE_OPORTUNIDADES_ULTIMO_ESCANEO, new Date().toISOString())
|
||||||
|
return resultado
|
||||||
|
}
|
||||||
|
|
||||||
/** Estado actual para el endpoint admin */
|
/** Estado actual para el endpoint admin */
|
||||||
async estado(): Promise<Record<string, unknown>> {
|
async estado(): Promise<Record<string, unknown>> {
|
||||||
const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES)
|
const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES)
|
||||||
@@ -292,6 +492,17 @@ export class AutomatizacionesService {
|
|||||||
diasUmbral: config.automatizaciones.inactividadDias,
|
diasUmbral: config.automatizaciones.inactividadDias,
|
||||||
ultimoEscaneo: await this.state.get(CLAVE_INACTIVIDAD_ULTIMO_ESCANEO),
|
ultimoEscaneo: await this.state.get(CLAVE_INACTIVIDAD_ULTIMO_ESCANEO),
|
||||||
},
|
},
|
||||||
|
escalado: {
|
||||||
|
horasUmbral: config.automatizaciones.escaladoHoras,
|
||||||
|
coordinadorUserId: config.automatizaciones.coordinadorUserId > 0 ? config.automatizaciones.coordinadorUserId : null,
|
||||||
|
totalEscalados: Number((await this.state.get(CLAVE_CONTADOR_ESCALADOS)) ?? '0'),
|
||||||
|
ultimoEscaneo: await this.state.get(CLAVE_ESCALADO_ULTIMO_ESCANEO),
|
||||||
|
},
|
||||||
|
oportunidades: {
|
||||||
|
apartadosCreados: Number((await this.state.get(CLAVE_CONTADOR_APARTADOS)) ?? '0'),
|
||||||
|
ventasCreadas: Number((await this.state.get(CLAVE_CONTADOR_VENTAS)) ?? '0'),
|
||||||
|
ultimoEscaneo: await this.state.get(CLAVE_OPORTUNIDADES_ULTIMO_ESCANEO),
|
||||||
|
},
|
||||||
crons: {
|
crons: {
|
||||||
habilitados: config.automatizaciones.enabled,
|
habilitados: config.automatizaciones.enabled,
|
||||||
pollCron: config.automatizaciones.pollCron,
|
pollCron: config.automatizaciones.pollCron,
|
||||||
|
|||||||
@@ -32,11 +32,23 @@ interface ItemConFechas extends ItemMonday {
|
|||||||
export interface LeadMonday {
|
export interface LeadMonday {
|
||||||
id: string
|
id: string
|
||||||
nombre: string
|
nombre: string
|
||||||
|
createdAt: string // ISO (created_at del item en Monday)
|
||||||
updatedAt: string // ISO (updated_at del item en Monday)
|
updatedAt: string // ISO (updated_at del item en Monday)
|
||||||
estado: string // label de la columna status 'Estado' ('' si no tiene)
|
estado: string // label de la columna status 'Estado' ('' si no tiene)
|
||||||
|
origen: string // label de la columna status 'Origen' ('' si no tiene)
|
||||||
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
|
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Oportunidad del tablero Oportunidades (pipeline comercial) */
|
||||||
|
export interface OportunidadMonday {
|
||||||
|
id: string
|
||||||
|
nombre: string // nombre del cliente
|
||||||
|
createdAt: string // ISO
|
||||||
|
etapa: string // label de la columna status 'Estado'
|
||||||
|
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
|
||||||
|
precioFinal: number // columna numbers 'Precio final' (0 si no tiene)
|
||||||
|
}
|
||||||
|
|
||||||
/** Usuario del workspace (para la lista rotativa de vendedores) */
|
/** Usuario del workspace (para la lista rotativa de vendedores) */
|
||||||
export interface UsuarioMonday {
|
export interface UsuarioMonday {
|
||||||
id: number
|
id: number
|
||||||
@@ -521,16 +533,131 @@ export class MondayService {
|
|||||||
vendedorIds = []
|
vendedorIds = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const colOrigen = cols.get('Origen')
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
nombre: item.name,
|
nombre: item.name,
|
||||||
|
createdAt: item.created_at,
|
||||||
updatedAt: item.updated_at,
|
updatedAt: item.updated_at,
|
||||||
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
|
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
|
||||||
|
origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '',
|
||||||
vendedorIds,
|
vendedorIds,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Items del tablero Oportunidades con etapa, unidad y precio final
|
||||||
|
* (los consumen las automatizaciones Aparto/Ganado y los reportes).
|
||||||
|
*/
|
||||||
|
async getOportunidades(): Promise<OportunidadMonday[]> {
|
||||||
|
const boardId = config.monday.boards.oportunidades
|
||||||
|
const cols = await this.columnasDe(boardId)
|
||||||
|
const colEstado = cols.get('Estado')
|
||||||
|
if (!colEstado) throw new AppError(500, `Columna "Estado" no existe en el tablero Oportunidades (${boardId})`)
|
||||||
|
const colUnidad = cols.get('Unidad')
|
||||||
|
const colPrecio = cols.get('Precio final')
|
||||||
|
|
||||||
|
const items: ItemConFechas[] = []
|
||||||
|
let cursor: string | null = null
|
||||||
|
do {
|
||||||
|
const data: { boards: { items_page: { cursor: string | null; items: ItemConFechas[] } }[] } =
|
||||||
|
cursor === null
|
||||||
|
? await this.graphql(
|
||||||
|
`query ($ids: [ID!]) {
|
||||||
|
boards(ids: $ids) {
|
||||||
|
items_page(limit: 200) {
|
||||||
|
cursor
|
||||||
|
items { id name created_at updated_at column_values { id text value } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ ids: [boardId] },
|
||||||
|
)
|
||||||
|
: await this.graphql(
|
||||||
|
`query ($ids: [ID!], $cursor: String!) {
|
||||||
|
boards(ids: $ids) {
|
||||||
|
items_page(limit: 200, cursor: $cursor) {
|
||||||
|
cursor
|
||||||
|
items { id name created_at updated_at 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]))
|
||||||
|
const precio = colPrecio ? Number(porId.get(colPrecio.id)?.text ?? '') : NaN
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
nombre: item.name,
|
||||||
|
createdAt: item.created_at,
|
||||||
|
etapa: porId.get(colEstado.id)?.text ?? '',
|
||||||
|
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
|
||||||
|
precioFinal: Number.isFinite(precio) ? precio : 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
const columnValues = await this.construirColumnValues(boardId, {
|
||||||
|
Unidad: datos.unidadCodigo,
|
||||||
|
'Valor venta': datos.valorVenta,
|
||||||
|
})
|
||||||
|
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Número de actividades con Estado 'Abierto' en el tablero Actividades.
|
||||||
|
* Si el tablero no está configurado devuelve 0 (los reportes no deben caer por eso).
|
||||||
|
*/
|
||||||
|
async getActividadesAbiertas(): Promise<number> {
|
||||||
|
const boardId = config.monday.boards.actividades
|
||||||
|
if (boardId === '') return 0
|
||||||
|
const cols = await this.columnasDe(boardId)
|
||||||
|
const colEstado = cols.get('Estado')
|
||||||
|
if (!colEstado) return 0
|
||||||
|
|
||||||
|
let abiertas = 0
|
||||||
|
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 column_values { id text } } }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ ids: [boardId] },
|
||||||
|
)
|
||||||
|
: await this.graphql(
|
||||||
|
`query ($ids: [ID!], $cursor: String!) {
|
||||||
|
boards(ids: $ids) {
|
||||||
|
items_page(limit: 500, cursor: $cursor) { cursor items { id column_values { id text } } }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ ids: [boardId], cursor },
|
||||||
|
)
|
||||||
|
const page = data.boards[0]?.items_page
|
||||||
|
if (!page) break
|
||||||
|
for (const item of page.items) {
|
||||||
|
const estado = item.column_values.find((cv) => cv.id === colEstado.id)?.text ?? ''
|
||||||
|
if (estado.trim().toUpperCase() === 'ABIERTO') abiertas++
|
||||||
|
}
|
||||||
|
cursor = page.cursor
|
||||||
|
} while (cursor !== null)
|
||||||
|
return abiertas
|
||||||
|
}
|
||||||
|
|
||||||
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
|
/** Asigna un vendedor (user ID de Monday) a la columna people "Vendedor" de un lead */
|
||||||
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
|
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
|
||||||
const boardId = config.monday.boards.leads
|
const boardId = config.monday.boards.leads
|
||||||
@@ -615,6 +742,12 @@ export interface DatosApartado {
|
|||||||
estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
|
estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DatosVenta {
|
||||||
|
clienteNombre: string
|
||||||
|
unidadCodigo?: string
|
||||||
|
valorVenta?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface DatosMotivoNoCompra {
|
export interface DatosMotivoNoCompra {
|
||||||
nombre: string // '<motivo> — <nombre del item de origen>'
|
nombre: string // '<motivo> — <nombre del item de origen>'
|
||||||
categoria: string // una de MOTIVOS_NO_COMPRA
|
categoria: string // una de MOTIVOS_NO_COMPRA
|
||||||
|
|||||||
238
api-gateway/src/services/reportes.service.test.ts
Normal file
238
api-gateway/src/services/reportes.service.test.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { construirResumen, ReportesService, type DatosResumen } from './reportes.service.js'
|
||||||
|
import type { LeadMonday, OportunidadMonday, Unidad } from './monday.service.js'
|
||||||
|
import type { PlanPago } from './cobranza.service.js'
|
||||||
|
|
||||||
|
// ---------- Fixtures ----------
|
||||||
|
|
||||||
|
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
nombre: `Lead ${id}`,
|
||||||
|
createdAt: '2026-08-20T10:00:00Z',
|
||||||
|
updatedAt: '2026-08-20T10:00:00Z',
|
||||||
|
estado: 'NUEVO',
|
||||||
|
origen: 'Facebook',
|
||||||
|
vendedorIds: [],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function op(id: string, etapa: string): OportunidadMonday {
|
||||||
|
return { id, nombre: `Cliente ${id}`, createdAt: '2026-08-01T09:00:00Z', etapa, unidadCodigo: 'U-101', precioFinal: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function unidad(codigo: string, desarrollo: string, status: string): Unidad {
|
||||||
|
return {
|
||||||
|
codigo,
|
||||||
|
modelo: 'M1',
|
||||||
|
fechaEntrega: null,
|
||||||
|
supEstacionamiento: 0,
|
||||||
|
supConstruccion: 0,
|
||||||
|
supExcedente: 0,
|
||||||
|
precioSinAcabados: 0,
|
||||||
|
precioConAcabados: 0,
|
||||||
|
status,
|
||||||
|
desarrollo,
|
||||||
|
mesesParaFirma: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function plan(id: string, overrides: Partial<PlanPago> = {}): PlanPago {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
clienteNombre: `Cliente ${id}`,
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
proyecto: 'ATELIER',
|
||||||
|
fechaInicio: '2026-07-01',
|
||||||
|
engancheTotal: 100000,
|
||||||
|
numPagos: 2,
|
||||||
|
mensualidad: 50000,
|
||||||
|
status: 'ACTIVO',
|
||||||
|
pagos: [],
|
||||||
|
notas: [],
|
||||||
|
createdAt: '2026-07-01T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pago(id: string, fechaVencimiento: string, monto: number, montoPagado = 0): PlanPago['pagos'][number] {
|
||||||
|
return { id, numPago: 1, fechaVencimiento, monto, montoPagado, aplicaciones: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const AHORA = new Date('2026-08-22T12:00:00') // mediodía local
|
||||||
|
|
||||||
|
const DATOS_BASE: DatosResumen = { leads: [], oportunidades: [], unidades: [], planes: [], actividadesAbiertas: 0 }
|
||||||
|
|
||||||
|
// ---------- construirResumen (puro) ----------
|
||||||
|
|
||||||
|
describe('construirResumen', () => {
|
||||||
|
it('cuenta leads por estado y por origen', () => {
|
||||||
|
const r = construirResumen(
|
||||||
|
{
|
||||||
|
...DATOS_BASE,
|
||||||
|
leads: [
|
||||||
|
lead('1', { estado: 'NUEVO', origen: 'Facebook' }),
|
||||||
|
lead('2', { estado: 'NUEVO', origen: 'Facebook' }),
|
||||||
|
lead('3', { estado: 'CONTACTADO', origen: 'Referido' }),
|
||||||
|
lead('4', { estado: '', origen: '' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
AHORA,
|
||||||
|
)
|
||||||
|
expect(r.leads.total).toBe(4)
|
||||||
|
expect(r.leads.porEstado).toEqual({ NUEVO: 2, CONTACTADO: 1, '(sin dato)': 1 })
|
||||||
|
expect(r.leads.porOrigen).toEqual({ Facebook: 2, Referido: 1, '(sin dato)': 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cuenta leads nuevos de hoy y de los últimos 7 días (fecha local)', () => {
|
||||||
|
const r = construirResumen(
|
||||||
|
{
|
||||||
|
...DATOS_BASE,
|
||||||
|
leads: [
|
||||||
|
lead('1', { createdAt: '2026-08-22T08:00:00' }), // hoy
|
||||||
|
lead('2', { createdAt: '2026-08-18T23:00:00' }), // esta semana
|
||||||
|
lead('3', { createdAt: '2026-08-10T12:00:00' }), // hace 12 días
|
||||||
|
],
|
||||||
|
},
|
||||||
|
AHORA,
|
||||||
|
)
|
||||||
|
expect(r.leads.nuevosHoy).toBe(1)
|
||||||
|
expect(r.leads.nuevosSemana).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('agrega oportunidades por etapa y calcula tasas de Aparto y Ganado', () => {
|
||||||
|
const r = construirResumen(
|
||||||
|
{
|
||||||
|
...DATOS_BASE,
|
||||||
|
oportunidades: [
|
||||||
|
op('1', 'Afluencia'),
|
||||||
|
op('2', 'Cotizador'),
|
||||||
|
op('3', 'Aparto'),
|
||||||
|
op('4', 'Ganado'),
|
||||||
|
op('5', 'Perdido'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
AHORA,
|
||||||
|
)
|
||||||
|
expect(r.oportunidades.total).toBe(5)
|
||||||
|
expect(r.oportunidades.porEtapa).toEqual({ Afluencia: 1, Cotizador: 1, Aparto: 1, Ganado: 1, Perdido: 1 })
|
||||||
|
expect(r.oportunidades.activas).toBe(3) // Afluencia, Cotizador, Aparto (ni Ganado ni cerradas)
|
||||||
|
expect(r.oportunidades.tasaAparto).toBeCloseTo(2 / 5) // Aparto + Ganado
|
||||||
|
expect(r.oportunidades.tasaGanado).toBeCloseTo(1 / 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sin oportunidades las tasas son 0 (no divide entre cero)', () => {
|
||||||
|
const r = construirResumen(DATOS_BASE, AHORA)
|
||||||
|
expect(r.oportunidades.tasaAparto).toBe(0)
|
||||||
|
expect(r.oportunidades.tasaGanado).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('agrega unidades por desarrollo × status y cuenta disponibles', () => {
|
||||||
|
const r = construirResumen(
|
||||||
|
{
|
||||||
|
...DATOS_BASE,
|
||||||
|
unidades: [
|
||||||
|
unidad('U-101', 'ATELIER', 'DISPONIBLE'),
|
||||||
|
unidad('U-102', 'ATELIER', 'VENDIDO'),
|
||||||
|
unidad('U-201', 'SOLANA', 'DISPONIBLE'),
|
||||||
|
unidad('U-202', 'SOLANA', 'apartado'), // status se normaliza a mayúsculas
|
||||||
|
],
|
||||||
|
},
|
||||||
|
AHORA,
|
||||||
|
)
|
||||||
|
expect(r.unidades.total).toBe(4)
|
||||||
|
expect(r.unidades.disponibles).toBe(2)
|
||||||
|
expect(r.unidades.porDesarrollo).toEqual([
|
||||||
|
{ desarrollo: 'ATELIER', total: 2, porStatus: { DISPONIBLE: 1, VENDIDO: 1 } },
|
||||||
|
{ desarrollo: 'SOLANA', total: 2, porStatus: { APARTADO: 1, DISPONIBLE: 1 } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cobra: planes por status, pagos vencidos y monto en mora de planes activos', () => {
|
||||||
|
const r = construirResumen(
|
||||||
|
{
|
||||||
|
...DATOS_BASE,
|
||||||
|
planes: [
|
||||||
|
plan('a', {
|
||||||
|
pagos: [
|
||||||
|
pago('p1', '2026-08-01', 50000), // vencido sin abono
|
||||||
|
pago('p2', '2026-08-10', 50000, 20000), // parcial (no cuenta como VENCIDO)
|
||||||
|
pago('p3', '2026-09-01', 50000), // pendiente futuro
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
plan('b', { pagos: [pago('p4', '2026-07-15', 30000)] }), // otro vencido
|
||||||
|
plan('c', { status: 'CANCELADO', pagos: [pago('p5', '2026-07-01', 99999)] }), // cancelado: no cuenta
|
||||||
|
plan('d', { pagos: [pago('p6', '2026-08-01', 1000, 1000)] }), // liquidado (saldo 0)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
AHORA,
|
||||||
|
)
|
||||||
|
expect(r.cobranza.planes).toEqual({ activos: 2, liquidados: 1, cancelados: 1 })
|
||||||
|
expect(r.cobranza.pagosVencidos).toBe(2)
|
||||||
|
expect(r.cobranza.montoVencido).toBe(80000)
|
||||||
|
expect(r.cobranza.planesAlCorriente).toBe(0)
|
||||||
|
expect(r.cobranza.planesConVencidos).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- ReportesService (cache en memoria) ----------
|
||||||
|
|
||||||
|
describe('ReportesService', () => {
|
||||||
|
function mondayMock() {
|
||||||
|
return {
|
||||||
|
llamadas: { leads: 0, oportunidades: 0, unidades: 0, actividades: 0 },
|
||||||
|
async getLeads(): Promise<LeadMonday[]> {
|
||||||
|
this.llamadas.leads++
|
||||||
|
return [lead('1')]
|
||||||
|
},
|
||||||
|
async getOportunidades(): Promise<OportunidadMonday[]> {
|
||||||
|
this.llamadas.oportunidades++
|
||||||
|
return [op('1', 'Aparto')]
|
||||||
|
},
|
||||||
|
async getUnidades(): Promise<Unidad[]> {
|
||||||
|
this.llamadas.unidades++
|
||||||
|
return [unidad('U-101', 'ATELIER', 'DISPONIBLE')]
|
||||||
|
},
|
||||||
|
async getActividadesAbiertas(): Promise<number> {
|
||||||
|
this.llamadas.actividades++
|
||||||
|
return 7
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('agrega todas las fuentes en una sola respuesta', async () => {
|
||||||
|
const monday = mondayMock()
|
||||||
|
const svc = new ReportesService(monday, async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
|
||||||
|
const r = await svc.resumen()
|
||||||
|
expect(r.leads.total).toBe(1)
|
||||||
|
expect(r.oportunidades.total).toBe(1)
|
||||||
|
expect(r.unidades.total).toBe(1)
|
||||||
|
expect(r.cobranza.planes.activos).toBe(1)
|
||||||
|
expect(r.actividadesAbiertas).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cachea 60 s: la segunda llamada no vuelve a Monday ni a Prisma', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
vi.setSystemTime(new Date('2026-08-22T12:00:00'))
|
||||||
|
const monday = mondayMock()
|
||||||
|
const cargarPlanes = vi.fn(async () => [plan('a', { pagos: [pago('p1', '2026-09-01', 50000)] })])
|
||||||
|
const svc = new ReportesService(monday, cargarPlanes)
|
||||||
|
|
||||||
|
await svc.resumen()
|
||||||
|
await svc.resumen()
|
||||||
|
expect(monday.llamadas.leads).toBe(1)
|
||||||
|
expect(monday.llamadas.oportunidades).toBe(1)
|
||||||
|
expect(cargarPlanes).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// 61 s después el cache expira y se vuelve a consultar
|
||||||
|
vi.setSystemTime(new Date('2026-08-22T12:01:01'))
|
||||||
|
await svc.resumen()
|
||||||
|
expect(monday.llamadas.leads).toBe(2)
|
||||||
|
expect(cargarPlanes).toHaveBeenCalledTimes(2)
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
218
api-gateway/src/services/reportes.service.ts
Normal file
218
api-gateway/src/services/reportes.service.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
|
||||||
|
import type { LeadMonday, MondayService, OportunidadMonday, Unidad } from './monday.service.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agregador de reportes para la app de tableros (/dash/).
|
||||||
|
* En UNA respuesta junta: leads (Monday), oportunidades (Monday), unidades
|
||||||
|
* (Monday), cobranza (Prisma local) y actividades abiertas (Monday).
|
||||||
|
* Cache en memoria de 60 s para no martillar la API de Monday.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CACHE_REPORTES_MS = 60 * 1000
|
||||||
|
|
||||||
|
// Etapas del pipeline de Oportunidades en orden (para el funnel del dashboard)
|
||||||
|
export const ETAPAS_FUNNEL = [
|
||||||
|
'Afluencia',
|
||||||
|
'Cotizador',
|
||||||
|
'Agendo Cita',
|
||||||
|
'Aparto',
|
||||||
|
'Ganado',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Etapas que ya no cuentan como oportunidad activa */
|
||||||
|
const ETAPAS_CERRADAS = ['PERDIDO', 'ABANDONO']
|
||||||
|
|
||||||
|
export interface ResumenReportes {
|
||||||
|
generadoEn: string // ISO
|
||||||
|
leads: {
|
||||||
|
total: number
|
||||||
|
nuevosHoy: number
|
||||||
|
nuevosSemana: number // últimos 7 días naturales
|
||||||
|
porEstado: Record<string, number>
|
||||||
|
porOrigen: Record<string, number>
|
||||||
|
}
|
||||||
|
oportunidades: {
|
||||||
|
total: number
|
||||||
|
activas: number
|
||||||
|
porEtapa: Record<string, number>
|
||||||
|
/** Fracción 0..1: oportunidades que llegaron a Aparto (o más allá) sobre el total */
|
||||||
|
tasaAparto: number
|
||||||
|
/** Fracción 0..1: oportunidades Ganadas sobre el total */
|
||||||
|
tasaGanado: number
|
||||||
|
}
|
||||||
|
unidades: {
|
||||||
|
total: number
|
||||||
|
disponibles: number
|
||||||
|
porDesarrollo: { desarrollo: string; total: number; porStatus: Record<string, number> }[]
|
||||||
|
}
|
||||||
|
cobranza: {
|
||||||
|
planes: { activos: number; liquidados: number; cancelados: number }
|
||||||
|
planesAlCorriente: number
|
||||||
|
planesConVencidos: number
|
||||||
|
pagosVencidos: number
|
||||||
|
/** Suma de (monto - montoPagado) de los pagos vencidos de planes activos */
|
||||||
|
montoVencido: number
|
||||||
|
}
|
||||||
|
actividadesAbiertas: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Datos crudos que necesita el agregador (inyectables en tests) */
|
||||||
|
export interface DatosResumen {
|
||||||
|
leads: LeadMonday[]
|
||||||
|
oportunidades: OportunidadMonday[]
|
||||||
|
unidades: Unidad[]
|
||||||
|
planes: PlanPago[] // dominio de cobranza (con pagos)
|
||||||
|
actividadesAbiertas: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function contarPor(items: readonly string[]): Record<string, number> {
|
||||||
|
const out: Record<string, number> = {}
|
||||||
|
for (const item of items) {
|
||||||
|
const clave = item.trim() === '' ? '(sin dato)' : item.trim()
|
||||||
|
out[clave] = (out[clave] ?? 0) + 1
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inicio del día local de `ahora` (para "nuevos hoy") */
|
||||||
|
function inicioDelDia(d: Date): Date {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye el resumen a partir de los datos crudos. Función pura: toda la
|
||||||
|
* lógica de conteo/tasas se prueba sin red ni base de datos.
|
||||||
|
*/
|
||||||
|
export function construirResumen(datos: DatosResumen, ahora: Date): ResumenReportes {
|
||||||
|
const hoy = inicioDelDia(ahora)
|
||||||
|
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
const creadosEn = (desde: Date): number =>
|
||||||
|
datos.leads.filter((l) => {
|
||||||
|
const t = Date.parse(l.createdAt)
|
||||||
|
return !Number.isNaN(t) && t >= desde.getTime()
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const porEtapa = contarPor(datos.oportunidades.map((o) => o.etapa))
|
||||||
|
const totalOps = datos.oportunidades.length
|
||||||
|
const ganadas = datos.oportunidades.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length
|
||||||
|
const apartadosOMas = datos.oportunidades.filter((o) =>
|
||||||
|
['APARTO', 'GANADO'].includes(o.etapa.trim().toUpperCase()),
|
||||||
|
).length
|
||||||
|
const activas = datos.oportunidades.filter(
|
||||||
|
(o) =>
|
||||||
|
!ETAPAS_CERRADAS.includes(o.etapa.trim().toUpperCase()) &&
|
||||||
|
o.etapa.trim().toUpperCase() !== 'GANADO',
|
||||||
|
).length
|
||||||
|
|
||||||
|
const desarrollos = new Map<string, { total: number; porStatus: Record<string, number> }>()
|
||||||
|
for (const u of datos.unidades) {
|
||||||
|
const clave = u.desarrollo.trim() === '' ? '(sin desarrollo)' : u.desarrollo.trim()
|
||||||
|
const entrada = desarrollos.get(clave) ?? { total: 0, porStatus: {} }
|
||||||
|
entrada.total++
|
||||||
|
const status = u.status.trim() === '' ? '(sin status)' : u.status.trim().toUpperCase()
|
||||||
|
entrada.porStatus[status] = (entrada.porStatus[status] ?? 0) + 1
|
||||||
|
desarrollos.set(clave, entrada)
|
||||||
|
}
|
||||||
|
|
||||||
|
const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0 }
|
||||||
|
let planesAlCorriente = 0
|
||||||
|
let planesConVencidos = 0
|
||||||
|
let pagosVencidos = 0
|
||||||
|
let montoVencido = 0
|
||||||
|
for (const plan of datos.planes) {
|
||||||
|
const status = statusPlan(plan)
|
||||||
|
if (status === 'ACTIVO') planesPorStatus.activos++
|
||||||
|
else if (status === 'LIQUIDADO') planesPorStatus.liquidados++
|
||||||
|
else planesPorStatus.cancelados++
|
||||||
|
if (status !== 'ACTIVO') continue
|
||||||
|
const vencidos = plan.pagos.filter((p) => statusPago(p, ahora) === 'VENCIDO')
|
||||||
|
if (vencidos.length === 0) planesAlCorriente++
|
||||||
|
else planesConVencidos++
|
||||||
|
pagosVencidos += vencidos.length
|
||||||
|
montoVencido += vencidos.reduce((acc, p) => acc + (p.monto - p.montoPagado), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
generadoEn: ahora.toISOString(),
|
||||||
|
leads: {
|
||||||
|
total: datos.leads.length,
|
||||||
|
nuevosHoy: creadosEn(hoy),
|
||||||
|
nuevosSemana: creadosEn(hace7dias),
|
||||||
|
porEstado: contarPor(datos.leads.map((l) => l.estado)),
|
||||||
|
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
|
||||||
|
},
|
||||||
|
oportunidades: {
|
||||||
|
total: totalOps,
|
||||||
|
activas,
|
||||||
|
porEtapa,
|
||||||
|
tasaAparto: totalOps > 0 ? apartadosOMas / totalOps : 0,
|
||||||
|
tasaGanado: totalOps > 0 ? ganadas / totalOps : 0,
|
||||||
|
},
|
||||||
|
unidades: {
|
||||||
|
total: datos.unidades.length,
|
||||||
|
disponibles: datos.unidades.filter((u) => u.status.trim().toUpperCase() === 'DISPONIBLE').length,
|
||||||
|
porDesarrollo: [...desarrollos.entries()]
|
||||||
|
.map(([desarrollo, v]) => ({ desarrollo, total: v.total, porStatus: v.porStatus }))
|
||||||
|
.sort((a, b) => a.desarrollo.localeCompare(b.desarrollo, 'es')),
|
||||||
|
},
|
||||||
|
cobranza: {
|
||||||
|
planes: planesPorStatus,
|
||||||
|
planesAlCorriente,
|
||||||
|
planesConVencidos,
|
||||||
|
pagosVencidos,
|
||||||
|
montoVencido: Math.round(montoVencido * 100) / 100,
|
||||||
|
},
|
||||||
|
actividadesAbiertas: datos.actividadesAbiertas,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Servicio con cache ----------
|
||||||
|
|
||||||
|
type MondayReportes = Pick<MondayService, 'getLeads' | 'getOportunidades' | 'getUnidades' | 'getActividadesAbiertas'>
|
||||||
|
|
||||||
|
/** Carga los planes de cobranza en forma de dominio (Prisma en prod, fixture en tests) */
|
||||||
|
export type CargadorPlanes = () => Promise<PlanPago[]>
|
||||||
|
|
||||||
|
export interface OpcionesReportes {
|
||||||
|
/** Reloj inyectable para probar el cache (default Date.now) */
|
||||||
|
now?: () => number
|
||||||
|
/** TTL del cache en ms (default 60 s) */
|
||||||
|
cacheMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReportesService {
|
||||||
|
private readonly now: () => number
|
||||||
|
private readonly cacheMs: number
|
||||||
|
private cache: { resumen: ResumenReportes; expira: number } | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly monday: MondayReportes,
|
||||||
|
private readonly cargarPlanes: CargadorPlanes,
|
||||||
|
opciones: OpcionesReportes = {},
|
||||||
|
) {
|
||||||
|
this.now = opciones.now ?? Date.now
|
||||||
|
this.cacheMs = opciones.cacheMs ?? CACHE_REPORTES_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalida el cache (útil tras escrituras que cambian los números) */
|
||||||
|
invalidarCache(): void {
|
||||||
|
this.cache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumen(): Promise<ResumenReportes> {
|
||||||
|
const ahoraMs = this.now()
|
||||||
|
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
|
||||||
|
|
||||||
|
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
|
||||||
|
const [leads, oportunidades, unidades, actividadesAbiertas, planes] = await Promise.all([
|
||||||
|
this.monday.getLeads(),
|
||||||
|
this.monday.getOportunidades(),
|
||||||
|
this.monday.getUnidades(),
|
||||||
|
this.monday.getActividadesAbiertas(),
|
||||||
|
this.cargarPlanes(),
|
||||||
|
])
|
||||||
|
const resumen = construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
|
||||||
|
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
|
||||||
|
return resumen
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/dashboards/index.html
Normal file
12
apps/dashboards/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>FRASA Desarrollos — Tableros</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4280
apps/dashboards/package-lock.json
generated
Normal file
4280
apps/dashboards/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
apps/dashboards/package.json
Normal file
28
apps/dashboards/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "dashboards",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.3.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"recharts": "^2.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/react": "^19.0.2",
|
||||||
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "~5.6.3",
|
||||||
|
"vite": "^6.0.7",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
38
apps/dashboards/src/components/AuthGate.tsx
Normal file
38
apps/dashboards/src/components/AuthGate.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { getSesion, logout, type Sesion } from '../services/auth'
|
||||||
|
import LoginScreen from './LoginScreen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
|
||||||
|
* app con una barra discreta "nombre · rol" y botón "Salir".
|
||||||
|
*/
|
||||||
|
export default function AuthGate({ children }: { children: ReactNode }) {
|
||||||
|
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
|
||||||
|
|
||||||
|
if (!sesion) {
|
||||||
|
return <LoginScreen onLogin={() => setSesion(getSesion())} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white text-neutral-900">
|
||||||
|
<div className="border-b border-neutral-200">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
|
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
logout()
|
||||||
|
setSesion(null)
|
||||||
|
}}
|
||||||
|
className="text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:text-accent-hover"
|
||||||
|
>
|
||||||
|
Salir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
apps/dashboards/src/components/KpiCard.tsx
Normal file
11
apps/dashboards/src/components/KpiCard.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="border border-neutral-200 bg-white p-6">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">{titulo}</p>
|
||||||
|
<p className="mt-2 text-3xl font-light text-neutral-900">{valor}</p>
|
||||||
|
{detalle && <p className="mt-2 text-xs text-neutral-500">{detalle}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
apps/dashboards/src/components/LoginScreen.tsx
Normal file
88
apps/dashboards/src/components/LoginScreen.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { login, ErrorLogin, type UsuarioSesion } from '../services/auth'
|
||||||
|
|
||||||
|
/** Pantalla de login StudioBlank: marca FRASA, usuario + contraseña, Enter envía */
|
||||||
|
export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSesion) => void }) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [cargando, setCargando] = useState(false)
|
||||||
|
|
||||||
|
async function enviar(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setCargando(true)
|
||||||
|
try {
|
||||||
|
onLogin(await login(username, password))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ErrorLogin ? err.message : 'Usuario o contraseña incorrectos')
|
||||||
|
} finally {
|
||||||
|
setCargando(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-white px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="mb-10 text-center">
|
||||||
|
<p className="text-lg font-bold tracking-widest">FRASA</p>
|
||||||
|
<p className="mt-2 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
|
Acceso al ecosistema
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={enviar} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="login-usuario"
|
||||||
|
className="mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500"
|
||||||
|
>
|
||||||
|
Usuario
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="login-usuario"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
className="w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="login-password"
|
||||||
|
className="mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500"
|
||||||
|
>
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
className="w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="border border-red-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-red-700"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={cargando}
|
||||||
|
className="w-full bg-accent px-5 py-3 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{cargando ? 'Entrando…' : 'Entrar'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
apps/dashboards/src/index.css
Normal file
23
apps/dashboards/src/index.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--color-accent: #1B4332;
|
||||||
|
--radius-sm: 0;
|
||||||
|
--radius-md: 0;
|
||||||
|
--radius-lg: 0;
|
||||||
|
--radius-xl: 0;
|
||||||
|
--radius-2xl: 0;
|
||||||
|
--radius-3xl: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-white text-neutral-900 antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid #1B4332;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
apps/dashboards/src/lib/reportes.test.ts
Normal file
91
apps/dashboards/src/lib/reportes.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
datosCobranza,
|
||||||
|
datosFunnel,
|
||||||
|
formatoMoneda,
|
||||||
|
formatoPorcentaje,
|
||||||
|
unidadesApiladas,
|
||||||
|
type ResumenReportes,
|
||||||
|
} from './reportes'
|
||||||
|
|
||||||
|
function resumen(overrides: Partial<ResumenReportes> = {}): ResumenReportes {
|
||||||
|
return {
|
||||||
|
generadoEn: '2026-08-22T12:00:00.000Z',
|
||||||
|
leads: { total: 40, nuevosHoy: 3, nuevosSemana: 12, porEstado: {}, porOrigen: {} },
|
||||||
|
oportunidades: {
|
||||||
|
total: 20,
|
||||||
|
activas: 15,
|
||||||
|
porEtapa: { Afluencia: 8, Cotizador: 5, Aparto: 4, Ganado: 2, Perdido: 1 },
|
||||||
|
tasaAparto: 0.3,
|
||||||
|
tasaGanado: 0.1,
|
||||||
|
},
|
||||||
|
unidades: {
|
||||||
|
total: 5,
|
||||||
|
disponibles: 2,
|
||||||
|
porDesarrollo: [
|
||||||
|
{ desarrollo: 'ATELIER', total: 3, porStatus: { DISPONIBLE: 1, VENDIDO: 2 } },
|
||||||
|
{ desarrollo: 'SOLANA', total: 2, porStatus: { DISPONIBLE: 1, ESCRITURADO: 1 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
cobranza: {
|
||||||
|
planes: { activos: 6, liquidados: 2, cancelados: 1 },
|
||||||
|
planesAlCorriente: 4,
|
||||||
|
planesConVencidos: 2,
|
||||||
|
pagosVencidos: 3,
|
||||||
|
montoVencido: 125000,
|
||||||
|
},
|
||||||
|
actividadesAbiertas: 7,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('unidadesApiladas', () => {
|
||||||
|
it('mapea desarrollo × status con columnas fijas y agrupa lo desconocido en OTROS', () => {
|
||||||
|
expect(unidadesApiladas(resumen())).toEqual([
|
||||||
|
{ desarrollo: 'ATELIER', DISPONIBLE: 1, APARTADO: 0, VENDIDO: 2, BLOQUEADO: 0, OTROS: 0 },
|
||||||
|
{ desarrollo: 'SOLANA', DISPONIBLE: 1, APARTADO: 0, VENDIDO: 0, BLOQUEADO: 0, OTROS: 1 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('con inventario vacío devuelve arreglo vacío', () => {
|
||||||
|
expect(unidadesApiladas(resumen({ unidades: { total: 0, disponibles: 0, porDesarrollo: [] } }))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('datosFunnel', () => {
|
||||||
|
it('ordena Leads → Oportunidades → Aparto o más → Ganado sin subir', () => {
|
||||||
|
const funnel = datosFunnel(resumen())
|
||||||
|
expect(funnel.map((e) => e.etapa)).toEqual(['Leads', 'Oportunidades', 'Aparto o más', 'Ganado'])
|
||||||
|
expect(funnel.map((e) => e.valor)).toEqual([40, 20, 6, 2]) // Aparto(4) + Ganado(2) acumulados
|
||||||
|
const valores = funnel.map((e) => e.valor)
|
||||||
|
for (let i = 1; i < valores.length; i++) expect(valores[i]!).toBeLessThanOrEqual(valores[i - 1]!)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tolera etiquetas con otra capitalización', () => {
|
||||||
|
const r = resumen()
|
||||||
|
r.oportunidades.porEtapa = { aparto: 1, ' GANADO ': 3 }
|
||||||
|
const funnel = datosFunnel(r)
|
||||||
|
expect(funnel[2]?.valor).toBe(4)
|
||||||
|
expect(funnel[3]?.valor).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('datosCobranza', () => {
|
||||||
|
it('devuelve al corriente vs con vencidos', () => {
|
||||||
|
expect(datosCobranza(resumen())).toEqual([
|
||||||
|
{ nombre: 'Al corriente', valor: 4 },
|
||||||
|
{ nombre: 'Con vencidos', valor: 2 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatos', () => {
|
||||||
|
it('moneda MXN sin centavos', () => {
|
||||||
|
expect(formatoMoneda(125000)).toBe('$125,000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('porcentaje desde fracción', () => {
|
||||||
|
expect(formatoPorcentaje(0.4)).toBe('40%')
|
||||||
|
expect(formatoPorcentaje(0.333)).toBe('33.3%')
|
||||||
|
})
|
||||||
|
})
|
||||||
119
apps/dashboards/src/lib/reportes.ts
Normal file
119
apps/dashboards/src/lib/reportes.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Tipos del endpoint GET /api/reportes/resumen y transformaciones puras para
|
||||||
|
* las gráficas del tablero. Toda la lógica de forma de datos vive aquí para
|
||||||
|
* probarla sin renderizar componentes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ResumenReportes {
|
||||||
|
generadoEn: string
|
||||||
|
leads: {
|
||||||
|
total: number
|
||||||
|
nuevosHoy: number
|
||||||
|
nuevosSemana: number
|
||||||
|
porEstado: Record<string, number>
|
||||||
|
porOrigen: Record<string, number>
|
||||||
|
}
|
||||||
|
oportunidades: {
|
||||||
|
total: number
|
||||||
|
activas: number
|
||||||
|
porEtapa: Record<string, number>
|
||||||
|
tasaAparto: number
|
||||||
|
tasaGanado: number
|
||||||
|
}
|
||||||
|
unidades: {
|
||||||
|
total: number
|
||||||
|
disponibles: number
|
||||||
|
porDesarrollo: { desarrollo: string; total: number; porStatus: Record<string, number> }[]
|
||||||
|
}
|
||||||
|
cobranza: {
|
||||||
|
planes: { activos: number; liquidados: number; cancelados: number }
|
||||||
|
planesAlCorriente: number
|
||||||
|
planesConVencidos: number
|
||||||
|
pagosVencidos: number
|
||||||
|
montoVencido: number
|
||||||
|
}
|
||||||
|
actividadesAbiertas: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status de inventario que se grafican por separado; el resto se agrupa en OTROS
|
||||||
|
export const STATUS_UNIDAD = ['DISPONIBLE', 'APARTADO', 'VENDIDO', 'BLOQUEADO'] as const
|
||||||
|
|
||||||
|
export interface RenglonUnidades {
|
||||||
|
desarrollo: string
|
||||||
|
DISPONIBLE: number
|
||||||
|
APARTADO: number
|
||||||
|
VENDIDO: number
|
||||||
|
BLOQUEADO: number
|
||||||
|
OTROS: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filas para la barras apiladas: unidades por desarrollo × status */
|
||||||
|
export function unidadesApiladas(resumen: ResumenReportes): RenglonUnidades[] {
|
||||||
|
return resumen.unidades.porDesarrollo.map((d) => {
|
||||||
|
const renglon: RenglonUnidades = {
|
||||||
|
desarrollo: d.desarrollo,
|
||||||
|
DISPONIBLE: 0,
|
||||||
|
APARTADO: 0,
|
||||||
|
VENDIDO: 0,
|
||||||
|
BLOQUEADO: 0,
|
||||||
|
OTROS: 0,
|
||||||
|
}
|
||||||
|
for (const [status, n] of Object.entries(d.porStatus)) {
|
||||||
|
if ((STATUS_UNIDAD as readonly string[]).includes(status)) {
|
||||||
|
renglon[status as (typeof STATUS_UNIDAD)[number]] = n
|
||||||
|
} else {
|
||||||
|
renglon.OTROS += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return renglon
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EtapaFunnel {
|
||||||
|
etapa: string
|
||||||
|
valor: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datos del funnel comercial: Leads → Oportunidades → Aparto o más → Ganado.
|
||||||
|
* "Aparto o más" es acumulado (Aparto + Ganado) para que el funnel no suba.
|
||||||
|
*/
|
||||||
|
export function datosFunnel(resumen: ResumenReportes): EtapaFunnel[] {
|
||||||
|
const porEtapa = resumen.oportunidades.porEtapa
|
||||||
|
const conteo = (etapa: string): number =>
|
||||||
|
Object.entries(porEtapa)
|
||||||
|
.filter(([k]) => k.trim().toUpperCase() === etapa)
|
||||||
|
.reduce((acc, [, v]) => acc + v, 0)
|
||||||
|
const apartoOMas = conteo('APARTO') + conteo('GANADO')
|
||||||
|
return [
|
||||||
|
{ etapa: 'Leads', valor: resumen.leads.total },
|
||||||
|
{ etapa: 'Oportunidades', valor: resumen.oportunidades.total },
|
||||||
|
{ etapa: 'Aparto o más', valor: apartoOMas },
|
||||||
|
{ etapa: 'Ganado', valor: conteo('GANADO') },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Rebanada {
|
||||||
|
nombre: string
|
||||||
|
valor: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebanadas de la gráfica de cobranza: planes activos al corriente vs con vencidos */
|
||||||
|
export function datosCobranza(resumen: ResumenReportes): Rebanada[] {
|
||||||
|
return [
|
||||||
|
{ nombre: 'Al corriente', valor: resumen.cobranza.planesAlCorriente },
|
||||||
|
{ nombre: 'Con vencidos', valor: resumen.cobranza.planesConVencidos },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatoMXN = new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 })
|
||||||
|
|
||||||
|
/** Formato monetario MXN sin centavos: $1,250,000 */
|
||||||
|
export function formatoMoneda(valor: number): string {
|
||||||
|
return formatoMXN.format(valor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fracción (0.4) a texto porcentaje (40%) */
|
||||||
|
export function formatoPorcentaje(fraccion: number): string {
|
||||||
|
return `${(fraccion * 100).toLocaleString('es-MX', { maximumFractionDigits: 1 })}%`
|
||||||
|
}
|
||||||
17
apps/dashboards/src/main.tsx
Normal file
17
apps/dashboards/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import '@fontsource/inter/300.css'
|
||||||
|
import '@fontsource/inter/400.css'
|
||||||
|
import '@fontsource/inter/500.css'
|
||||||
|
import '@fontsource/inter/700.css'
|
||||||
|
import './index.css'
|
||||||
|
import AuthGate from './components/AuthGate'
|
||||||
|
import DashboardPage from './pages/DashboardPage'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<AuthGate>
|
||||||
|
<DashboardPage />
|
||||||
|
</AuthGate>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
227
apps/dashboards/src/pages/DashboardPage.tsx
Normal file
227
apps/dashboards/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Funnel,
|
||||||
|
FunnelChart,
|
||||||
|
LabelList,
|
||||||
|
Legend,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts'
|
||||||
|
import KpiCard from '../components/KpiCard'
|
||||||
|
import {
|
||||||
|
datosCobranza,
|
||||||
|
datosFunnel,
|
||||||
|
formatoMoneda,
|
||||||
|
formatoPorcentaje,
|
||||||
|
unidadesApiladas,
|
||||||
|
type ResumenReportes,
|
||||||
|
} from '../lib/reportes'
|
||||||
|
import { fetchResumen } from '../services/reportes'
|
||||||
|
|
||||||
|
// Paleta StudioBlank: acento verde + neutros (sin gradientes ni sombras)
|
||||||
|
const COLORES_STATUS: Record<string, string> = {
|
||||||
|
DISPONIBLE: '#1B4332',
|
||||||
|
APARTADO: '#b45309',
|
||||||
|
VENDIDO: '#525252',
|
||||||
|
BLOQUEADO: '#b91c1c',
|
||||||
|
OTROS: '#d4d4d4',
|
||||||
|
}
|
||||||
|
const COLORES_FUNNEL = ['#a3a3a3', '#737373', '#52796f', '#1B4332']
|
||||||
|
const COLORES_COBRANZA = ['#1B4332', '#b45309']
|
||||||
|
|
||||||
|
function EstadoVacio({ mensaje }: { mensaje: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center border border-dashed border-neutral-300">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-neutral-400">{mensaje}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TituloSeccion({ children }: { children: string }) {
|
||||||
|
return <h2 className="mb-4 text-xs font-bold uppercase tracking-widest text-neutral-500">{children}</h2>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [resumen, setResumen] = useState<ResumenReportes | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [cargando, setCargando] = useState(true)
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
setCargando(true)
|
||||||
|
try {
|
||||||
|
setResumen(await fetchResumen())
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
|
||||||
|
} finally {
|
||||||
|
setCargando(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void cargar()
|
||||||
|
}, [cargar])
|
||||||
|
|
||||||
|
const unidades = resumen ? unidadesApiladas(resumen) : []
|
||||||
|
const funnel = resumen ? datosFunnel(resumen) : []
|
||||||
|
const cobranza = resumen ? datosCobranza(resumen) : []
|
||||||
|
const sinCobranza = resumen !== null && resumen.cobranza.planesAlCorriente + resumen.cobranza.planesConVencidos === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-8">
|
||||||
|
<div className="mb-8 flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-accent">FRASA Desarrollos</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-light text-neutral-900">Tablero comercial</h1>
|
||||||
|
{resumen && (
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">
|
||||||
|
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void cargar()}
|
||||||
|
disabled={cargando}
|
||||||
|
className="border border-accent px-5 py-2 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{cargando ? 'Actualizando…' : 'Actualizar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mb-8 border border-amber-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-amber-700">
|
||||||
|
Sin datos del API — {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!resumen && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
|
||||||
|
{!resumen && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
|
||||||
|
|
||||||
|
{resumen && (
|
||||||
|
<>
|
||||||
|
<section aria-label="Indicadores clave" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
titulo="Leads totales"
|
||||||
|
valor={resumen.leads.total}
|
||||||
|
detalle={`${resumen.leads.nuevosHoy} hoy · ${resumen.leads.nuevosSemana} esta semana`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
titulo="Oportunidades activas"
|
||||||
|
valor={resumen.oportunidades.activas}
|
||||||
|
detalle={`${resumen.oportunidades.total} en total`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
titulo="Conversión Aparto / Ganado"
|
||||||
|
valor={`${formatoPorcentaje(resumen.oportunidades.tasaAparto)} / ${formatoPorcentaje(resumen.oportunidades.tasaGanado)}`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
titulo="Unidades disponibles"
|
||||||
|
valor={resumen.unidades.disponibles}
|
||||||
|
detalle={`${resumen.unidades.total} en inventario`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
titulo="Cartera vencida"
|
||||||
|
valor={formatoMoneda(resumen.cobranza.montoVencido)}
|
||||||
|
detalle={`${resumen.cobranza.pagosVencidos} pago(s) vencido(s)`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
titulo="Planes activos"
|
||||||
|
valor={resumen.cobranza.planes.activos}
|
||||||
|
detalle={`${resumen.cobranza.planes.liquidados} liquidados · ${resumen.cobranza.planes.cancelados} cancelados`}
|
||||||
|
/>
|
||||||
|
<KpiCard titulo="Actividades abiertas" valor={resumen.actividadesAbiertas} />
|
||||||
|
<KpiCard
|
||||||
|
titulo="Leads por contactar"
|
||||||
|
valor={resumen.leads.porEstado['NUEVO'] ?? 0}
|
||||||
|
detalle="en estado NUEVO"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Inventario por desarrollo" className="mt-12">
|
||||||
|
<TituloSeccion>Unidades por desarrollo y status</TituloSeccion>
|
||||||
|
{unidades.length === 0 ? (
|
||||||
|
<EstadoVacio mensaje="Sin inventario" />
|
||||||
|
) : (
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={unidades} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="desarrollo" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
{Object.keys(COLORES_STATUS).map((status) => (
|
||||||
|
<Bar key={status} dataKey={status} stackId="unidades" fill={COLORES_STATUS[status]} />
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="mt-12 grid gap-12 lg:grid-cols-2">
|
||||||
|
<section aria-label="Embudo comercial">
|
||||||
|
<TituloSeccion>Embudo: leads → oportunidades → ganado</TituloSeccion>
|
||||||
|
{funnel.every((e) => e.valor === 0) ? (
|
||||||
|
<EstadoVacio mensaje="Sin movimiento comercial" />
|
||||||
|
) : (
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<FunnelChart>
|
||||||
|
<Tooltip />
|
||||||
|
<Funnel dataKey="valor" data={funnel} nameKey="etapa" isAnimationActive={false}>
|
||||||
|
{funnel.map((etapa, i) => (
|
||||||
|
<Cell key={etapa.etapa} fill={COLORES_FUNNEL[i % COLORES_FUNNEL.length]} />
|
||||||
|
))}
|
||||||
|
<LabelList position="right" dataKey="etapa" stroke="none" fontSize={11} />
|
||||||
|
<LabelList position="center" dataKey="valor" stroke="none" fill="#fff" fontSize={12} />
|
||||||
|
</Funnel>
|
||||||
|
</FunnelChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Estado de la cobranza">
|
||||||
|
<TituloSeccion>Cobranza: al corriente vs vencidos</TituloSeccion>
|
||||||
|
{sinCobranza ? (
|
||||||
|
<EstadoVacio mensaje="Sin planes activos" />
|
||||||
|
) : (
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={cobranza}
|
||||||
|
dataKey="valor"
|
||||||
|
nameKey="nombre"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={90}
|
||||||
|
isAnimationActive={false}
|
||||||
|
label={({ name, value }) => `${name}: ${value}`}
|
||||||
|
fontSize={11}
|
||||||
|
>
|
||||||
|
{cobranza.map((rebanada, i) => (
|
||||||
|
<Cell key={rebanada.nombre} fill={COLORES_COBRANZA[i % COLORES_COBRANZA.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
apps/dashboards/src/services/auth.ts
Normal file
77
apps/dashboards/src/services/auth.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Sesión de autenticación contra el API Gateway (JWT guardado en localStorage)
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
|
||||||
|
const STORAGE_KEY = 'frasa_auth'
|
||||||
|
|
||||||
|
export interface UsuarioSesion {
|
||||||
|
nombre: string
|
||||||
|
rol: string
|
||||||
|
asignacion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sesion {
|
||||||
|
token: string
|
||||||
|
usuario: UsuarioSesion
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error de login con mensaje genérico (no filtra si el usuario existe) */
|
||||||
|
export class ErrorLogin extends Error {
|
||||||
|
constructor(message = 'Usuario o contraseña incorrectos') {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ErrorLogin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSesion(): Sesion | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const sesion = JSON.parse(raw) as Sesion
|
||||||
|
if (typeof sesion?.token !== 'string' || !sesion.usuario) return null
|
||||||
|
return sesion
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return getSesion()?.token ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(): void {
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /auth/login; guarda token + usuario en localStorage */
|
||||||
|
export async function login(username: string, password: string): Promise<UsuarioSesion> {
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new ErrorLogin('No se pudo conectar con el servidor')
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new ErrorLogin()
|
||||||
|
const data = (await res.json()) as Sesion
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: data.token, usuario: data.usuario }))
|
||||||
|
return data.usuario
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrega Authorization: Bearer a un RequestInit si hay sesión.
|
||||||
|
* Sin token devuelve el init tal cual (las llamadas siguen iguales que antes).
|
||||||
|
*/
|
||||||
|
export function conAuth(init: RequestInit = {}): RequestInit {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return init
|
||||||
|
return { ...init, headers: { ...(init.headers as Record<string, string>), Authorization: `Bearer ${token}` } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Si el API responde 401: cierra sesión y recarga para volver al login */
|
||||||
|
export function manejar401(res: Response): void {
|
||||||
|
if (res.status !== 401) return
|
||||||
|
logout()
|
||||||
|
if (typeof window !== 'undefined') window.location.reload()
|
||||||
|
}
|
||||||
18
apps/dashboards/src/services/reportes.ts
Normal file
18
apps/dashboards/src/services/reportes.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Cliente del endpoint de reportes del API Gateway (misma red, same-origin tras NGINX)
|
||||||
|
import { conAuth, manejar401 } from './auth'
|
||||||
|
import type { ResumenReportes } from '../lib/reportes'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
|
||||||
|
|
||||||
|
/** GET /reportes/resumen; lanza Error con mensaje legible si el API falla */
|
||||||
|
export async function fetchResumen(): Promise<ResumenReportes> {
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_URL}/reportes/resumen`, conAuth())
|
||||||
|
} catch {
|
||||||
|
throw new Error('No se pudo conectar con el servidor')
|
||||||
|
}
|
||||||
|
manejar401(res)
|
||||||
|
if (!res.ok) throw new Error(`El API respondió ${res.status}`)
|
||||||
|
return (await res.json()) as ResumenReportes
|
||||||
|
}
|
||||||
22
apps/dashboards/tsconfig.app.json
Normal file
22
apps/dashboards/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
apps/dashboards/tsconfig.json
Normal file
7
apps/dashboards/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
19
apps/dashboards/tsconfig.node.json
Normal file
19
apps/dashboards/tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
9
apps/dashboards/vite.config.ts
Normal file
9
apps/dashboards/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// La app se sirve detrás de NGINX bajo el prefijo /dash
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/dash/',
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
})
|
||||||
8
apps/dashboards/vitest.config.ts
Normal file
8
apps/dashboards/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
// Config de tests separada para no mezclar los tipos de Vite y Vitest
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { MODULOS } from './modulos'
|
import { MODULOS } from './modulos'
|
||||||
|
|
||||||
describe('MODULOS', () => {
|
describe('MODULOS', () => {
|
||||||
it('expone los 4 módulos del ecosistema', () => {
|
it('expone los 5 módulos del ecosistema', () => {
|
||||||
expect(MODULOS).toHaveLength(4)
|
expect(MODULOS).toHaveLength(5)
|
||||||
expect(MODULOS.map((m) => m.id)).toEqual(['cotizador', 'alta-clientes', 'cobranza', 'agente-ia'])
|
expect(MODULOS.map((m) => m.id)).toEqual(['cotizador', 'alta-clientes', 'cobranza', 'agente-ia', 'tableros'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cada módulo tiene nombre, descripción y URL válida', () => {
|
it('cada módulo tiene nombre, descripción y URL válida', () => {
|
||||||
@@ -21,6 +21,7 @@ describe('MODULOS', () => {
|
|||||||
expect(urls['alta-clientes']).toBe('/client/')
|
expect(urls['alta-clientes']).toBe('/client/')
|
||||||
expect(urls['cobranza']).toBe('/pay/')
|
expect(urls['cobranza']).toBe('/pay/')
|
||||||
expect(urls['agente-ia']).toBe('/ai/')
|
expect(urls['agente-ia']).toBe('/ai/')
|
||||||
|
expect(urls['tableros']).toBe('/dash/')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('no hay ids ni URLs duplicadas', () => {
|
it('no hay ids ni URLs duplicadas', () => {
|
||||||
|
|||||||
@@ -31,4 +31,10 @@ export const MODULOS: Modulo[] = [
|
|||||||
descripcion: 'Asistente IA con la base de conocimiento de Frasa',
|
descripcion: 'Asistente IA con la base de conocimiento de Frasa',
|
||||||
url: '/ai/',
|
url: '/ai/',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'tableros',
|
||||||
|
nombre: 'Tableros',
|
||||||
|
descripcion: 'KPIs comerciales, inventario y cobranza en tiempo real',
|
||||||
|
url: '/dash/',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ server {
|
|||||||
try_files $uri $uri/ /ai/index.html;
|
try_files $uri $uri/ /ai/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /dash/ {
|
||||||
|
alias /root/frasa-ecosystem/apps/dashboards/dist/;
|
||||||
|
try_files $uri $uri/ /dash/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
# Landing (raíz)
|
# Landing (raíz)
|
||||||
location / {
|
location / {
|
||||||
root /root/frasa-ecosystem/apps/landing/dist;
|
root /root/frasa-ecosystem/apps/landing/dist;
|
||||||
|
|||||||
70
docs/AUTOMATIZACIONES-MONDAY.md
Normal file
70
docs/AUTOMATIZACIONES-MONDAY.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Automatizaciones nativas de Monday — checklist del admin
|
||||||
|
|
||||||
|
Recetas que se activan **desde la UI de Monday** (botón "Automatizar" en cada
|
||||||
|
tablero). Tiempo estimado total: ~10 minutos. Ninguna requiere código.
|
||||||
|
|
||||||
|
> **No duplicar**: el servidor (API Gateway, crons cada 5 min) ya cubre la
|
||||||
|
> asignación round-robin de vendedores, el recordatorio de leads inactivos
|
||||||
|
> (10 días), el escalado de leads sin contactar (48 h), y la creación de items
|
||||||
|
> en Apartado / Ventas cuando una oportunidad entra a **Aparto** o **Ganado**.
|
||||||
|
> Las recetas de abajo son solo *avisos y orden visual*; no repitas esas reglas
|
||||||
|
> como recetas nativas.
|
||||||
|
|
||||||
|
## 1. Tablero Leads — avisar al vendedor cuando se le asigna un lead
|
||||||
|
|
||||||
|
El round-robin del servidor asigna la columna **Vendedor**; esta receta solo
|
||||||
|
envía la notificación.
|
||||||
|
|
||||||
|
- Board: **Leads**
|
||||||
|
- Receta: *"When a person is assigned to someone, notify someone"* (categoría
|
||||||
|
**People**; en español: "Cuando se asigne una persona, notificar a alguien")
|
||||||
|
- Parámetros:
|
||||||
|
- person column: **Vendedor**
|
||||||
|
- notify: **the person assigned** (el vendedor asignado)
|
||||||
|
- mensaje: `Tienes un lead nuevo: {item.name}`
|
||||||
|
|
||||||
|
## 2. Tablero Oportunidades — avisar cuando algo se gana
|
||||||
|
|
||||||
|
El servidor ya crea el item en Ventas; esto solo avisa al equipo.
|
||||||
|
|
||||||
|
- Board: **Oportunidades**
|
||||||
|
- Receta: *"When status changes to something, notify someone"* (categoría
|
||||||
|
**Status**)
|
||||||
|
- Parámetros:
|
||||||
|
- status column: **Estado** → valor: **Ganado**
|
||||||
|
- notify: una persona o equipo de coordinación (elegir en la receta)
|
||||||
|
- mensaje: `Venta ganada: {item.name} — {item.text_mm6ezd0d}` (unidad)
|
||||||
|
|
||||||
|
## 3. Tablero Apartado — recordatorio de fecha límite
|
||||||
|
|
||||||
|
- Board: **Apartado**
|
||||||
|
- Receta: *"When a date arrives, notify someone"* (categoría **Date
|
||||||
|
reminders**)
|
||||||
|
- Parámetros:
|
||||||
|
- date column: **Fecha límite**
|
||||||
|
- when: **1 día antes**, a las **9:00**
|
||||||
|
- notify: **el responsable del expediente** (o coordinación)
|
||||||
|
- mensaje: `El apartado de {item.name} vence mañana`
|
||||||
|
|
||||||
|
## 4. Mover items entre grupos por estado (orden visual)
|
||||||
|
|
||||||
|
Si los tableros tienen grupos (p. ej. "Activos" / "Cerrados"), una receta por
|
||||||
|
tablero mantiene el orden sin trabajo manual:
|
||||||
|
|
||||||
|
- Board: **Oportunidades** (repetir en **Leads** si usa grupos)
|
||||||
|
- Receta: *"When status changes to something, move item to group"* (categoría
|
||||||
|
**Status**)
|
||||||
|
- Parámetros sugeridos:
|
||||||
|
- **Estado** = Ganado → mover al grupo **Cerrados**
|
||||||
|
- **Estado** = Perdido → mover al grupo **Cerrados**
|
||||||
|
- **Estado** = Abandono → mover al grupo **Cerrados**
|
||||||
|
|
||||||
|
> Si el tablero no tiene grupos, omite esta receta: no afecta nada del servidor.
|
||||||
|
|
||||||
|
## Verificación rápida (2 min)
|
||||||
|
|
||||||
|
1. Crea un item de prueba en **Leads**, asígnate como Vendedor → debe llegarte
|
||||||
|
la notificación de la receta 1. Bórralo después.
|
||||||
|
2. En **Oportunidades**, cambia un item de prueba a **Ganado** → notificación
|
||||||
|
de la receta 2 **y** (en ≤ 5 min, por el servidor) un item nuevo en
|
||||||
|
**Ventas**. Borra ambos después.
|
||||||
Reference in New Issue
Block a user