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:
2026-08-27 09:05:09 +00:00
parent de11af985e
commit 04568a5603
34 changed files with 6279 additions and 8 deletions

View File

@@ -64,3 +64,8 @@ NAN_CHAT_MODEL="qwen3.6"
NAN_EMBED_MODEL="qwen3-embedding"
# Umbral mínimo de similitud coseno para recuperar contexto (0-1)
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=

View File

@@ -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/alta-clientes` | `/client/` | Alta de clientes (wizard, firma digital, PDFs PLD) |
| `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 |
## Desarrollo local

View File

@@ -41,6 +41,10 @@ export const config = {
// IDs de usuario Monday separados por coma; vacío = auto-detectar usuarios no-guest del workspace
rrUserIds: env('MONDAY_RR_USER_IDS', ''),
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 * * * *'),
recordatorioCron: env('RECORDATORIO_CRON', '0 9 * * *'),
},

View File

@@ -36,7 +36,14 @@ type PlanCompleto = PlanPago & {
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 {
id: plan.id,
ventaRef: plan.ventaRef,

View 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())
}

View File

@@ -8,7 +8,9 @@ const app = crearApp()
// Crons de respaldo (el webhook público aún no existe: no hay SSL ni DNS).
// Se desactivan con MONDAY_AUTOMATIONS_ENABLED=false.
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, () => {
automatizacionesService
.runRoundRobin()
@@ -19,6 +21,29 @@ if (config.automatizaciones.enabled) {
}
})
.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)

View File

@@ -39,6 +39,7 @@ import {
subirDocumento,
} from '../controllers/ai.controller.js'
import { loginEndpoint, meEndpoint } from '../controllers/auth.controller.js'
import { resumenReportes } from '../controllers/reportes.controller.js'
export const apiRouter = Router()
@@ -70,6 +71,8 @@ apiRouter.patch('/planes/:id/monday', ligarMondayItem)
apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
apiRouter.get('/reportes/resumen', resumenReportes)
apiRouter.post('/webhooks/monday', webhookMonday)
apiRouter.get('/automatizaciones/estado', estadoAutomatizaciones)

View File

@@ -1,14 +1,20 @@
import { describe, expect, it, vi } from 'vitest'
import { config } from '../config/index.js'
import {
AutomatizacionesService,
claveApartadoCreado,
claveEscalado,
claveRecordatorio,
claveVentaCreada,
esEstadoTerminal,
esEtapa,
leadsInactivos,
leadsSinContactar,
parseRrUserIds,
vendedorEnTurno,
type StateStore,
} from './automatizaciones.service.js'
import type { LeadMonday, UsuarioMonday } from './monday.service.js'
import type { LeadMonday, OportunidadMonday, UsuarioMonday } from './monday.service.js'
// ---------- Fixtures ----------
@@ -22,13 +28,27 @@ function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
return {
id,
nombre: `Lead ${id}`,
createdAt: '2026-08-01T09:00:00Z',
updatedAt: '2026-08-01T10:00:00Z',
estado: 'NUEVO',
origen: '',
vendedorIds: [],
...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) */
function stateEnMemoria(): StateStore & { mapa: 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 */
function mondayMock(leads: LeadMonday[]) {
function mondayMock(leads: LeadMonday[], oportunidades: OportunidadMonday[] = []) {
return {
asignaciones: [] as { itemId: string; userId: number }[],
updates: [] as { itemId: string; body: string }[],
@@ -56,9 +76,14 @@ function mondayMock(leads: LeadMonday[]) {
estadoLabel: string
leadItemId: string
}[],
apartados: [] as Record<string, unknown>[],
ventas: [] as Record<string, unknown>[],
async getLeads() {
return leads
},
async getOportunidades() {
return oportunidades
},
async asignarVendedorALead(itemId: string, userId: number) {
this.asignaciones.push({ itemId, userId })
},
@@ -77,6 +102,14 @@ function mondayMock(leads: LeadMonday[]) {
this.actividades.push(datos)
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() {
return VENDEDORES
},
@@ -339,3 +372,208 @@ describe('ESTADOS_TERMINALES_LEAD con vocabulario CRM', () => {
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
})
})

View File

@@ -18,12 +18,26 @@ import { prisma } from './prisma.js'
// Abandono...). Terminales: DESCARTADO y ABANDONO (fuera de seguimiento).
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_VENDEDORES = 'rr:vendedores'
const CLAVE_RR_ULTIMA_PASADA = 'rr:ultimaPasada'
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 HORA_MS = 60 * 60 * 1000
/** Store clave-valor inyectable (Prisma en prod, Map en tests) */
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)
}
/**
* 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 ----------
type MondayLeads = Pick<
MondayService,
'getLeads' | 'asignarVendedorALead' | 'crearUpdateEnItem' | 'getUsuariosWorkspace' | 'crearActividad'
| 'getLeads'
| 'getOportunidades'
| 'asignarVendedorALead'
| 'crearUpdateEnItem'
| 'getUsuariosWorkspace'
| 'crearActividad'
| 'crearApartado'
| 'crearVenta'
>
export interface OpcionesAutomatizaciones {
@@ -122,6 +180,27 @@ export interface ResultadoInactividad {
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 {
private readonly actividadesEnabled: boolean
@@ -274,6 +353,127 @@ export class AutomatizacionesService {
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 */
async estado(): Promise<Record<string, unknown>> {
const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES)
@@ -292,6 +492,17 @@ export class AutomatizacionesService {
diasUmbral: config.automatizaciones.inactividadDias,
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: {
habilitados: config.automatizaciones.enabled,
pollCron: config.automatizaciones.pollCron,

View File

@@ -32,11 +32,23 @@ interface ItemConFechas extends ItemMonday {
export interface LeadMonday {
id: string
nombre: string
createdAt: string // ISO (created_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)
origen: string // label de la columna status 'Origen' ('' si no tiene)
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) */
export interface UsuarioMonday {
id: number
@@ -521,16 +533,131 @@ export class MondayService {
vendedorIds = []
}
}
const colOrigen = cols.get('Origen')
return {
id: item.id,
nombre: item.name,
createdAt: item.created_at,
updatedAt: item.updated_at,
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
origen: colOrigen ? (porId.get(colOrigen.id)?.text ?? '') : '',
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 */
async asignarVendedorALead(itemId: string, userId: number): Promise<void> {
const boardId = config.monday.boards.leads
@@ -615,6 +742,12 @@ export interface DatosApartado {
estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
}
export interface DatosVenta {
clienteNombre: string
unidadCodigo?: string
valorVenta?: number
}
export interface DatosMotivoNoCompra {
nombre: string // '<motivo> — <nombre del item de origen>'
categoria: string // una de MOTIVOS_NO_COMPRA

View 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()
}
})
})

View 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
}
}

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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;
}
}

View 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%')
})
})

View 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 })}%`
}

View 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>,
)

View 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>
)
}

View 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()
}

View 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
}

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View 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()],
})

View 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',
},
})

View File

@@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest'
import { MODULOS } from './modulos'
describe('MODULOS', () => {
it('expone los 4 módulos del ecosistema', () => {
expect(MODULOS).toHaveLength(4)
expect(MODULOS.map((m) => m.id)).toEqual(['cotizador', 'alta-clientes', 'cobranza', 'agente-ia'])
it('expone los 5 módulos del ecosistema', () => {
expect(MODULOS).toHaveLength(5)
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', () => {
@@ -21,6 +21,7 @@ describe('MODULOS', () => {
expect(urls['alta-clientes']).toBe('/client/')
expect(urls['cobranza']).toBe('/pay/')
expect(urls['agente-ia']).toBe('/ai/')
expect(urls['tableros']).toBe('/dash/')
})
it('no hay ids ni URLs duplicadas', () => {

View File

@@ -31,4 +31,10 @@ export const MODULOS: Modulo[] = [
descripcion: 'Asistente IA con la base de conocimiento de Frasa',
url: '/ai/',
},
{
id: 'tableros',
nombre: 'Tableros',
descripcion: 'KPIs comerciales, inventario y cobranza en tiempo real',
url: '/dash/',
},
]

View File

@@ -36,6 +36,11 @@ server {
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)
location / {
root /root/frasa-ecosystem/apps/landing/dist;

View 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.