feat: modulo WhatsApp (WA CRM) con round-robin de conversaciones
- Servicio wacrm en api-gateway (pg pool lazy): round-robin de conversaciones sin agente entre vendedores vinculados y miembro-agentes de la cuenta wacrm, cron cada 30 s; bandeja del vendedor; estado/config/reasignar para admin. - Endpoints /api/wacrm/* con guarda de rol admin/manager. - Usuario.wacrmUserId en schema Prisma (vinculo CRM<->wacrm). - App apps/wa-crm (/wa-crm/): bandeja + administracion del round-robin. - Landing: tarjeta WhatsApp; clave 'whatsapp' en MATRIZ de roles. - Plan de nuevas reglas de negocio del cotizador (docs/PLAN-REGLAS-COTIZADOR.md). - WACRM_PG_URL apunta a Postgres de Supabase por 127.0.0.1:5433. - Tests de integracion wacrm (3) y suite completa 764/764 en verde.
This commit is contained in:
160
api-gateway/package-lock.json
generated
160
api-gateway/package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.15.0",
|
"@types/node": "^22.15.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"prisma": "^6.14.0",
|
"prisma": "^6.14.0",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
@@ -1086,6 +1088,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||||
|
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||||
@@ -2658,6 +2672,95 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.23.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||||
|
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.14.0",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.16.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2719,6 +2822,45 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
||||||
@@ -3098,6 +3240,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ssf": {
|
"node_modules/ssf": {
|
||||||
"version": "0.11.2",
|
"version": "0.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
@@ -3600,6 +3751,15 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.15.0",
|
"@types/node": "^22.15.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"prisma": "^6.14.0",
|
"prisma": "^6.14.0",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
|
|||||||
@@ -318,6 +318,9 @@ model Usuario {
|
|||||||
// User ID de Monday (people). null = el usuario aún no tiene cuenta Monday;
|
// User ID de Monday (people). null = el usuario aún no tiene cuenta Monday;
|
||||||
// los filtros "míos" de /api/mi-dia responden vacíos con sinMapeoMonday: true
|
// los filtros "míos" de /api/mi-dia responden vacíos con sinMapeoMonday: true
|
||||||
mondayUserId Int?
|
mondayUserId Int?
|
||||||
|
// ID del usuario en WA CRM (auth.users del Supabase de wacrm). null = sin
|
||||||
|
// vínculo; el módulo WhatsApp del CRM lo usa para la bandeja y el round-robin
|
||||||
|
wacrmUserId String?
|
||||||
activo Boolean @default(true)
|
activo Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const MODULOS = [
|
|||||||
'cobranza',
|
'cobranza',
|
||||||
'contactos',
|
'contactos',
|
||||||
'chat',
|
'chat',
|
||||||
|
'whatsapp',
|
||||||
'clientes-maestro',
|
'clientes-maestro',
|
||||||
'captura',
|
'captura',
|
||||||
'soporte',
|
'soporte',
|
||||||
@@ -39,6 +40,7 @@ const MODULOS_VENDEDOR: readonly Modulo[] = [
|
|||||||
'mi-tablero',
|
'mi-tablero',
|
||||||
'contactos',
|
'contactos',
|
||||||
'chat',
|
'chat',
|
||||||
|
'whatsapp',
|
||||||
'clientes-maestro',
|
'clientes-maestro',
|
||||||
'captura',
|
'captura',
|
||||||
'soporte',
|
'soporte',
|
||||||
@@ -49,7 +51,7 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
|
|||||||
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
|
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||||
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
||||||
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
|
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'whatsapp', 'clientes-maestro', 'captura', 'soporte'],
|
||||||
// manager: mismos módulos que admin, sin poder crear usuarios ni editar permisos
|
// manager: mismos módulos que admin, sin poder crear usuarios ni editar permisos
|
||||||
// ni dar de alta desarrollos (módulo exclusivo del admin)
|
// ni dar de alta desarrollos (módulo exclusivo del admin)
|
||||||
manager: MODULOS.filter((m) => m !== 'desarrollos'),
|
manager: MODULOS.filter((m) => m !== 'desarrollos'),
|
||||||
|
|||||||
67
api-gateway/src/controllers/wacrm.controller.ts
Normal file
67
api-gateway/src/controllers/wacrm.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Endpoints del módulo WhatsApp (WA CRM): bandeja del vendedor, estado del
|
||||||
|
* round-robin para administración, configuración y reasignación manual.
|
||||||
|
*/
|
||||||
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { AppError } from '../utils/errors.js'
|
||||||
|
import {
|
||||||
|
configurarWacrm,
|
||||||
|
estadoWacrm,
|
||||||
|
ejecutarRoundRobinWacrm,
|
||||||
|
miBandeja,
|
||||||
|
reasignarConversacion,
|
||||||
|
} from '../services/wacrm.service.js'
|
||||||
|
import * as auditoria from '../services/auditoria.service.js'
|
||||||
|
|
||||||
|
/** Admin/manager: estado, configuración y reasignación. */
|
||||||
|
export function requireRolWacrmAdmin(req: Request, _res: Response, next: NextFunction): void {
|
||||||
|
const rol = req.usuarioToken?.rol
|
||||||
|
if (rol !== 'admin' && rol !== 'manager') {
|
||||||
|
throw new AppError(403, 'Requiere rol de administrador')
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bandeja de WhatsApp del vendedor logueado (vínculo vía wacrmUserId). */
|
||||||
|
export async function miBandejaEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const usuarioId = req.usuarioToken?.sub
|
||||||
|
if (!usuarioId) throw new AppError(401, 'Sesión requerida')
|
||||||
|
const resultado = await miBandeja(usuarioId)
|
||||||
|
res.json(resultado)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function estadoWacrmEndpoint(_req: Request, res: Response): Promise<void> {
|
||||||
|
res.json(await estadoWacrm())
|
||||||
|
}
|
||||||
|
|
||||||
|
const configSchema = z
|
||||||
|
.object({
|
||||||
|
cuentaId: z.string().max(64).optional(),
|
||||||
|
activo: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.refine((o) => Object.keys(o).length > 0, { message: 'Nada que actualizar' })
|
||||||
|
|
||||||
|
export async function configurarWacrmEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const body = configSchema.parse(req.body)
|
||||||
|
const resultado = await configurarWacrm(body)
|
||||||
|
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CONFIG_WACRM', 'configuracion', 'wacrm', JSON.stringify(body))
|
||||||
|
res.json(resultado)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasignarSchema = z.object({
|
||||||
|
conversacionId: z.string().min(10),
|
||||||
|
vendedorId: z.string().min(10),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function reasignarWacrmEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const body = reasignarSchema.parse(req.body)
|
||||||
|
await reasignarConversacion(body)
|
||||||
|
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'REASIGNAR_WACRM', 'conversacion', body.conversacionId, body.vendedorId)
|
||||||
|
res.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispara el round-robin a demanda (admin) — útil para pruebas. */
|
||||||
|
export async function roundRobinWacrmEndpoint(_req: Request, res: Response): Promise<void> {
|
||||||
|
res.json(await ejecutarRoundRobinWacrm())
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { crearApp } from './app.js'
|
|||||||
import { config } from './config/index.js'
|
import { config } from './config/index.js'
|
||||||
import { automatizacionesService } from './services/automatizaciones.service.js'
|
import { automatizacionesService } from './services/automatizaciones.service.js'
|
||||||
import * as configService from './services/config.service.js'
|
import * as configService from './services/config.service.js'
|
||||||
|
import { ejecutarRoundRobinWacrm } from './services/wacrm.service.js'
|
||||||
import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js'
|
import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js'
|
||||||
|
|
||||||
const app = crearApp()
|
const app = crearApp()
|
||||||
@@ -64,6 +65,18 @@ if (config.automatizaciones.enabled) {
|
|||||||
})
|
})
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`Recordatorios de cobranza activos (cron: "${config.recordatoriosCobranza.cron}")`)
|
console.log(`Recordatorios de cobranza activos (cron: "${config.recordatoriosCobranza.cron}")`)
|
||||||
|
|
||||||
|
// Round-robin de conversaciones entrantes de WhatsApp (WA CRM). Cada 30 s
|
||||||
|
// revisa conversaciones sin agente de la cuenta configurada y las turna
|
||||||
|
// entre los vendedores vinculados. Silencioso cuando no hay nada que hacer.
|
||||||
|
cron.schedule('*/30 * * * * *', () => {
|
||||||
|
void ejecutarRoundRobinWacrm().catch((err: unknown) => {
|
||||||
|
// 503 = wacrm aún no configurado: se espera, no es error de log ruidoso
|
||||||
|
const e = err as { status?: number }
|
||||||
|
if (e?.status && e.status < 500) return
|
||||||
|
console.error('[cron wacrm] error en round-robin WhatsApp:', err)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Precarga de configuraciones desde DB (modo_local_forzado, etc.)
|
// Precarga de configuraciones desde DB (modo_local_forzado, etc.)
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ import {
|
|||||||
subirDocumento,
|
subirDocumento,
|
||||||
} from '../controllers/ai.controller.js'
|
} from '../controllers/ai.controller.js'
|
||||||
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
|
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
|
||||||
|
import {
|
||||||
|
configurarWacrmEndpoint,
|
||||||
|
estadoWacrmEndpoint,
|
||||||
|
miBandejaEndpoint,
|
||||||
|
reasignarWacrmEndpoint,
|
||||||
|
requireRolWacrmAdmin,
|
||||||
|
roundRobinWacrmEndpoint,
|
||||||
|
} from '../controllers/wacrm.controller.js'
|
||||||
import { resumenReportes, scoringEndpoint, moraEndpoint } from '../controllers/reportes.controller.js'
|
import { resumenReportes, scoringEndpoint, moraEndpoint } from '../controllers/reportes.controller.js'
|
||||||
import {
|
import {
|
||||||
requireRolReportes,
|
requireRolReportes,
|
||||||
@@ -286,6 +294,15 @@ apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
|
|||||||
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
|
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
|
||||||
// Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día)
|
// Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día)
|
||||||
apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint)
|
apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint)
|
||||||
|
|
||||||
|
// ── Módulo WhatsApp (WA CRM) ─────────────────────────────────────────
|
||||||
|
// Bandeja del vendedor logueado (vínculo Usuario.wacrmUserId)
|
||||||
|
apiRouter.get('/wacrm/mi-bandeja', miBandejaEndpoint)
|
||||||
|
// Administración del round-robin (admin/manager)
|
||||||
|
apiRouter.get('/wacrm/estado', requireRolWacrmAdmin, estadoWacrmEndpoint)
|
||||||
|
apiRouter.post('/wacrm/config', requireRolWacrmAdmin, configurarWacrmEndpoint)
|
||||||
|
apiRouter.post('/wacrm/reasignar', requireRolWacrmAdmin, reasignarWacrmEndpoint)
|
||||||
|
apiRouter.post('/wacrm/round-robin', requireRolWacrmAdmin, roundRobinWacrmEndpoint)
|
||||||
// Precarga de documentos del expediente desde "Mis apartados": se guardan en
|
// Precarga de documentos del expediente desde "Mis apartados": se guardan en
|
||||||
// el cliente ligado al apartado (se crea la ficha mínima si aún no existe)
|
// el cliente ligado al apartado (se crea la ficha mínima si aún no existe)
|
||||||
apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado)
|
apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado)
|
||||||
|
|||||||
155
api-gateway/src/services/wacrm.service.test.ts
Normal file
155
api-gateway/src/services/wacrm.service.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Tests de integración del servicio wacrm contra el Supabase real.
|
||||||
|
*
|
||||||
|
* Se saltan automáticamente si WACRM_PG_URL no está definida (CI/entornos
|
||||||
|
* sin wacrm). Crean datos de prueba en la base de wacrm y los limpian al
|
||||||
|
* final (afterAll) aunque fallen las assertions.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { Pool } from 'pg'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { prisma } from './prisma.js'
|
||||||
|
import { ejecutarRoundRobinWacrm, miBandeja, setWacrmPoolForTesting } from './wacrm.service.js'
|
||||||
|
import { actualizarConfigs, obtenerConfig } from './config.service.js'
|
||||||
|
|
||||||
|
const HABILITADO = Boolean(process.env.WACRM_PG_URL)
|
||||||
|
|
||||||
|
describe.skipIf(!HABILITADO)('wacrm.service (integración)', () => {
|
||||||
|
let pool: Pool
|
||||||
|
let cuentaId: string
|
||||||
|
let authUsers: string[] = []
|
||||||
|
const idsUsuariosCrm: string[] = []
|
||||||
|
const idsConversaciones: string[] = []
|
||||||
|
const CLAVES_CONFIG = ['wacrm_cuenta_id', 'wacrm_rr_activo', 'wacrm_rr_idx']
|
||||||
|
const configPrev: Record<string, string | null> = {}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
pool = new Pool({ connectionString: process.env.WACRM_PG_URL, max: 2 })
|
||||||
|
setWacrmPoolForTesting(pool as unknown as never)
|
||||||
|
|
||||||
|
// Usuarios auth de prueba (2) + cuenta + perfiles agente
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const uid = randomUUID()
|
||||||
|
authUsers.push(uid)
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, created_at, updated_at)
|
||||||
|
VALUES ($1, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', $2, 'x', now(), now())`,
|
||||||
|
[uid, `wacrm-test-${i}-${randomUUID()}@test.local`],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// handle_new_user crea automáticamente una cuenta personal + profile por
|
||||||
|
// usuario: reusamos la cuenta del primer usuario y movemos al segundo.
|
||||||
|
const { rows: cuentas } = await pool.query(
|
||||||
|
`SELECT id FROM accounts WHERE owner_user_id = $1`,
|
||||||
|
[authUsers[0]],
|
||||||
|
)
|
||||||
|
cuentaId = cuentas[0].id
|
||||||
|
// Primero mover el profile ( CASCADE del account borraría el profile ),
|
||||||
|
// luego eliminar la cuenta personal sobrante del segundo usuario.
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE profiles SET account_id = $1, account_role = 'agent' WHERE user_id = $2`,
|
||||||
|
[cuentaId, authUsers[1]],
|
||||||
|
)
|
||||||
|
await pool.query(`DELETE FROM accounts WHERE owner_user_id = $1`, [authUsers[1]])
|
||||||
|
// Vendedores CRM vinculados a esos usuarios auth
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const u = await prisma.usuario.create({
|
||||||
|
data: {
|
||||||
|
username: `wacrmtest${i}-${randomUUID().slice(0, 8)}`,
|
||||||
|
nombre: `Vendedor Test ${i}`,
|
||||||
|
passwordHash: 'x',
|
||||||
|
rol: 'vendedor',
|
||||||
|
wacrmUserId: authUsers[i],
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
idsUsuariosCrm.push(u.id)
|
||||||
|
}
|
||||||
|
// Guarda config previa y activa round-robin sobre la cuenta de prueba
|
||||||
|
for (const k of CLAVES_CONFIG) configPrev[k] = await obtenerConfig(k)
|
||||||
|
await actualizarConfigs({ 'wacrm_cuenta_id': cuentaId, 'wacrm_rr_activo': '1', 'wacrm_rr_idx': '0' })
|
||||||
|
}, 60_000)
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await pool.query('DELETE FROM conversations WHERE account_id = $1', [cuentaId])
|
||||||
|
await pool.query('DELETE FROM contacts WHERE account_id = $1', [cuentaId])
|
||||||
|
await pool.query('DELETE FROM profiles WHERE user_id = ANY($1)', [authUsers])
|
||||||
|
await pool.query('DELETE FROM accounts WHERE id = $1', [cuentaId])
|
||||||
|
await pool.query('DELETE FROM auth.users WHERE id = ANY($1)', [authUsers])
|
||||||
|
await prisma.usuario.deleteMany({ where: { id: { in: idsUsuariosCrm } } })
|
||||||
|
const restaurar: Record<string, string> = {}
|
||||||
|
for (const k of CLAVES_CONFIG) if (configPrev[k] !== null) restaurar[k] = configPrev[k] as string
|
||||||
|
if (Object.keys(restaurar).length > 0) await actualizarConfigs(restaurar)
|
||||||
|
for (const k of CLAVES_CONFIG) {
|
||||||
|
if (configPrev[k] === null) await prisma.configuracionSistema.deleteMany({ where: { clave: k } })
|
||||||
|
}
|
||||||
|
setWacrmPoolForTesting(null)
|
||||||
|
await pool.end()
|
||||||
|
}, 60_000)
|
||||||
|
|
||||||
|
async function crearConversacion(telefono: string): Promise<string> {
|
||||||
|
const contactId = randomUUID()
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO contacts (id, user_id, phone, name, account_id, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, now(), now())`,
|
||||||
|
[contactId, authUsers[0], telefono, `Contacto ${telefono}`, cuentaId],
|
||||||
|
)
|
||||||
|
const convId = randomUUID()
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO conversations (id, user_id, contact_id, status, account_id, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, 'open', $4, now(), now())`,
|
||||||
|
[convId, authUsers[0], contactId, cuentaId],
|
||||||
|
)
|
||||||
|
idsConversaciones.push(convId)
|
||||||
|
return convId
|
||||||
|
}
|
||||||
|
|
||||||
|
it('round-robin turna conversaciones sin asignar entre los vendedores', async () => {
|
||||||
|
await crearConversacion('521550000001')
|
||||||
|
await crearConversacion('521550000002')
|
||||||
|
await crearConversacion('521550000003')
|
||||||
|
|
||||||
|
const r = await ejecutarRoundRobinWacrm()
|
||||||
|
expect(r.asignados).toBe(3)
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT assigned_agent_id FROM conversations WHERE account_id = $1 ORDER BY created_at`,
|
||||||
|
[cuentaId],
|
||||||
|
)
|
||||||
|
const asignados = rows.map((r2: { assigned_agent_id: string }) => r2.assigned_agent_id)
|
||||||
|
// 3 conversaciones, 2 vendedores: reparte 2-1 y todos quedan asignados
|
||||||
|
expect(asignados.every((a: string | null) => a !== null)).toBe(true)
|
||||||
|
expect(new Set(asignados).size).toBe(2)
|
||||||
|
// Segunda pasada: nada nuevo que asignar
|
||||||
|
const r2 = await ejecutarRoundRobinWacrm()
|
||||||
|
expect(r2.asignados).toBe(0)
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
it('miBandeja devuelve solo las conversaciones del vendedor', async () => {
|
||||||
|
const bandeja0 = await miBandeja(idsUsuariosCrm[0])
|
||||||
|
const bandeja1 = await miBandeja(idsUsuariosCrm[1])
|
||||||
|
expect(bandeja0.vinculado).toBe(true)
|
||||||
|
expect(bandeja1.vinculado).toBe(true)
|
||||||
|
expect(bandeja0.conversaciones.length + bandeja1.conversaciones.length).toBe(3)
|
||||||
|
// Sin solapamiento
|
||||||
|
const ids0 = new Set(bandeja0.conversaciones.map((c) => c.id))
|
||||||
|
for (const c of bandeja1.conversaciones) expect(ids0.has(c.id)).toBe(false)
|
||||||
|
expect(bandeja0.conversaciones[0]?.contacto.telefono).toBeTruthy()
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
it('miBandeja marca como no vinculado a un usuario sin wacrmUserId', async () => {
|
||||||
|
const u = await prisma.usuario.create({
|
||||||
|
data: {
|
||||||
|
username: `wacrmsinvinc-${randomUUID().slice(0, 8)}`,
|
||||||
|
nombre: 'Sin Vinculo',
|
||||||
|
passwordHash: 'x',
|
||||||
|
rol: 'vendedor',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
idsUsuariosCrm.push(u.id)
|
||||||
|
const bandeja = await miBandeja(u.id)
|
||||||
|
expect(bandeja.vinculado).toBe(false)
|
||||||
|
expect(bandeja.conversaciones).toEqual([])
|
||||||
|
}, 30_000)
|
||||||
|
})
|
||||||
284
api-gateway/src/services/wacrm.service.ts
Normal file
284
api-gateway/src/services/wacrm.service.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Servicio de integración con WA CRM (wacrm — WhatsApp).
|
||||||
|
*
|
||||||
|
* wacrm vive en su propia base (Supabase Postgres self-hosted, esquema
|
||||||
|
* public) y comparte usuarios con el CRM mediante la columna
|
||||||
|
* `Usuario.wacrmUserId` (id de auth.users en Supabase).
|
||||||
|
*
|
||||||
|
* Responsabilidades:
|
||||||
|
* - Round-robin: cada conversación entrante sin `assigned_agent_id` se
|
||||||
|
* asigna al siguiente vendedor del pool (rol 'vendedor' vinculado Y
|
||||||
|
* miembro agente/admin/owner de la cuenta wacrm configurada).
|
||||||
|
* - Bandeja: exponer "mis conversaciones" al vendedor logueado.
|
||||||
|
* - Admin: estado general, configuración de cuenta/activo y reasignación.
|
||||||
|
*
|
||||||
|
* La conexión es lazy: si WACRM_PG_URL no está definida o la base no
|
||||||
|
* responde, las funciones lanzan AppError 503 (el resto del CRM sigue
|
||||||
|
* funcionando igual).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg'
|
||||||
|
import { prisma } from './prisma.js'
|
||||||
|
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
|
||||||
|
import { obtenerConfig, actualizarConfigs } from './config.service.js'
|
||||||
|
|
||||||
|
// ── Config en ConfiguracionSistema ─────────────────────────────────────
|
||||||
|
export const CLAVE_CUENTA = 'wacrm_cuenta_id'
|
||||||
|
export const CLAVE_RR_ACTIVO = 'wacrm_rr_activo'
|
||||||
|
const CLAVE_RR_IDX = 'wacrm_rr_idx'
|
||||||
|
|
||||||
|
// ── Pool lazy ──────────────────────────────────────────────────────────
|
||||||
|
let pool: Pool | null = null
|
||||||
|
let poolUrlUsada = ''
|
||||||
|
|
||||||
|
function obtenerPool(): Pool {
|
||||||
|
const url = process.env.WACRM_PG_URL ?? ''
|
||||||
|
if (!url) throw new AppError(503, 'WA CRM no configurado (falta WACRM_PG_URL)')
|
||||||
|
if (!pool || poolUrlUsada !== url) {
|
||||||
|
pool?.end().catch(() => undefined)
|
||||||
|
pool = new Pool({ connectionString: url, max: 3, idleTimeoutMillis: 30_000 })
|
||||||
|
poolUrlUsada = url
|
||||||
|
// No dejar que un error de conexión tumbe el proceso
|
||||||
|
pool.on('error', (err) => console.error('[wacrm] error en pool pg:', err.message))
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Para tests: inyectar un pool falso / resetear al real. */
|
||||||
|
export function setWacrmPoolForTesting(fake: Pool | null): void {
|
||||||
|
pool = fake
|
||||||
|
// Marcar la URL como "ya cargada" para que obtenerPool() no cierre el pool
|
||||||
|
// inyectado al comparar contra la URL del entorno.
|
||||||
|
poolUrlUsada = fake ? (process.env.WACRM_PG_URL ?? '') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversacionBandeja {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
lastMessage: string | null
|
||||||
|
lastMessageAt: string | null
|
||||||
|
unreadCount: number
|
||||||
|
contacto: { nombre: string | null; telefono: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers internos ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Vendedores CRM vinculados a wacrm que además son agentes de la cuenta. */
|
||||||
|
async function poolVendedoresCuenta(cuentaId: string) {
|
||||||
|
const vendedores = await prisma.usuario.findMany({
|
||||||
|
where: { rol: 'vendedor', activo: true, wacrmUserId: { not: null } },
|
||||||
|
orderBy: { nombre: 'asc' },
|
||||||
|
select: { id: true, nombre: true, wacrmUserId: true },
|
||||||
|
})
|
||||||
|
if (vendedores.length === 0) return []
|
||||||
|
const ids = vendedores.map((v) => v.wacrmUserId as string)
|
||||||
|
const { rows } = await obtenerPool().query(
|
||||||
|
`SELECT user_id FROM profiles
|
||||||
|
WHERE account_id = $1 AND account_role IN ('owner','admin','agent')
|
||||||
|
AND user_id = ANY($2::uuid[])`,
|
||||||
|
[cuentaId, ids],
|
||||||
|
)
|
||||||
|
const miembros = new Set(rows.map((r: { user_id: string }) => r.user_id))
|
||||||
|
return vendedores.filter((v) => miembros.has(v.wacrmUserId as string))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Round-robin ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asigna las conversaciones sin agente de la cuenta configurada,
|
||||||
|
* turnándose entre los vendedores del pool. Devuelve cuántas asignó.
|
||||||
|
*/
|
||||||
|
export async function ejecutarRoundRobinWacrm(): Promise<{ asignados: number }> {
|
||||||
|
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
|
||||||
|
const activo = (await obtenerConfig(CLAVE_RR_ACTIVO)) === '1'
|
||||||
|
if (!activo || !cuentaId) return { asignados: 0 }
|
||||||
|
|
||||||
|
const poolVendedores = await poolVendedoresCuenta(cuentaId)
|
||||||
|
if (poolVendedores.length === 0) return { asignados: 0 }
|
||||||
|
|
||||||
|
const db = obtenerPool()
|
||||||
|
const { rows: sinAsignar } = await db.query(
|
||||||
|
`SELECT id FROM conversations
|
||||||
|
WHERE account_id = $1 AND assigned_agent_id IS NULL AND status = 'open'
|
||||||
|
ORDER BY created_at ASC LIMIT 50`,
|
||||||
|
[cuentaId],
|
||||||
|
)
|
||||||
|
|
||||||
|
let idx = Number.parseInt((await obtenerConfig(CLAVE_RR_IDX)) ?? '0', 10)
|
||||||
|
if (!Number.isFinite(idx) || idx < 0) idx = 0
|
||||||
|
|
||||||
|
let asignados = 0
|
||||||
|
for (const conv of sinAsignar as { id: string }[]) {
|
||||||
|
const vend = poolVendedores[idx % poolVendedores.length]!
|
||||||
|
idx += 1
|
||||||
|
const r = await db.query(
|
||||||
|
`UPDATE conversations SET assigned_agent_id = $2, updated_at = now()
|
||||||
|
WHERE id = $1 AND assigned_agent_id IS NULL`,
|
||||||
|
[conv.id, vend.wacrmUserId],
|
||||||
|
)
|
||||||
|
if (r.rowCount === 1) {
|
||||||
|
asignados += 1
|
||||||
|
console.log(`[wacrm round-robin] conversación ${conv.id} → ${vend.nombre}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (asignados > 0 || sinAsignar.length > 0) {
|
||||||
|
await actualizarConfigs({ [CLAVE_RR_IDX]: String(idx) })
|
||||||
|
}
|
||||||
|
return { asignados }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bandeja del vendedor ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Conversaciones de WhatsApp asignadas al usuario del CRM (por su vínculo). */
|
||||||
|
export async function miBandeja(usuarioCrmId: string): Promise<{
|
||||||
|
vinculado: boolean
|
||||||
|
conversaciones: ConversacionBandeja[]
|
||||||
|
}> {
|
||||||
|
const usuario = await prisma.usuario.findUnique({ where: { id: usuarioCrmId } })
|
||||||
|
if (!usuario?.wacrmUserId) return { vinculado: false, conversaciones: [] }
|
||||||
|
|
||||||
|
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
|
||||||
|
const params: string[] = [usuario.wacrmUserId]
|
||||||
|
let filtroCuenta = ''
|
||||||
|
if (cuentaId) {
|
||||||
|
params.push(cuentaId)
|
||||||
|
filtroCuenta = 'AND c.account_id = $2'
|
||||||
|
}
|
||||||
|
const { rows } = await obtenerPool().query(
|
||||||
|
`SELECT c.id, c.status, c.last_message_text, c.last_message_at, c.unread_count,
|
||||||
|
ct.name AS contacto_nombre, ct.phone AS contacto_telefono
|
||||||
|
FROM conversations c JOIN contacts ct ON ct.id = c.contact_id
|
||||||
|
WHERE c.assigned_agent_id = $1 ${filtroCuenta}
|
||||||
|
ORDER BY c.last_message_at DESC NULLS LAST, c.created_at DESC
|
||||||
|
LIMIT 100`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
vinculado: true,
|
||||||
|
conversaciones: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
status: r.status,
|
||||||
|
lastMessage: r.last_message_text,
|
||||||
|
lastMessageAt: r.last_message_at ? new Date(r.last_message_at).toISOString() : null,
|
||||||
|
unreadCount: r.unread_count ?? 0,
|
||||||
|
contacto: { nombre: r.contacto_nombre, telefono: r.contacto_telefono },
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin: estado / configuración / reasignación ───────────────────────
|
||||||
|
|
||||||
|
export async function estadoWacrm(): Promise<{
|
||||||
|
cuentaId: string | null
|
||||||
|
roundRobinActivo: boolean
|
||||||
|
sinAsignar: number
|
||||||
|
totalConversaciones: number
|
||||||
|
vendedores: Array<{
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
wacrmUserId: string
|
||||||
|
esMiembro: boolean
|
||||||
|
asignadas: number
|
||||||
|
}>
|
||||||
|
}> {
|
||||||
|
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
|
||||||
|
const roundRobinActivo = (await obtenerConfig(CLAVE_RR_ACTIVO)) === '1'
|
||||||
|
if (!cuentaId) {
|
||||||
|
return { cuentaId: null, roundRobinActivo, sinAsignar: 0, totalConversaciones: 0, vendedores: [] }
|
||||||
|
}
|
||||||
|
const db = obtenerPool()
|
||||||
|
const { rows: conteos } = await db.query(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) FILTER (WHERE assigned_agent_id IS NULL AND status = 'open') AS sin_asignar,
|
||||||
|
COUNT(*) AS total
|
||||||
|
FROM conversations WHERE account_id = $1`,
|
||||||
|
[cuentaId],
|
||||||
|
)
|
||||||
|
const candidatos = await prisma.usuario.findMany({
|
||||||
|
where: { rol: 'vendedor', activo: true, wacrmUserId: { not: null } },
|
||||||
|
orderBy: { nombre: 'asc' },
|
||||||
|
select: { id: true, nombre: true, wacrmUserId: true },
|
||||||
|
})
|
||||||
|
const { rows: miembros } = await db.query(
|
||||||
|
`SELECT user_id FROM profiles
|
||||||
|
WHERE account_id = $1 AND account_role IN ('owner','admin','agent')
|
||||||
|
AND user_id = ANY($2::uuid[])`,
|
||||||
|
[cuentaId, candidatos.map((v) => v.wacrmUserId as string)],
|
||||||
|
)
|
||||||
|
const setMiembros = new Set(miembros.map((r: { user_id: string }) => r.user_id))
|
||||||
|
const { rows: asignadas } = await db.query(
|
||||||
|
`SELECT assigned_agent_id AS uid, COUNT(*)::int AS n
|
||||||
|
FROM conversations WHERE account_id = $1 AND assigned_agent_id IS NOT NULL
|
||||||
|
GROUP BY assigned_agent_id`,
|
||||||
|
[cuentaId],
|
||||||
|
)
|
||||||
|
const mapAsignadas = new Map(asignadas.map((r: { uid: string; n: number }) => [r.uid, r.n]))
|
||||||
|
return {
|
||||||
|
cuentaId,
|
||||||
|
roundRobinActivo,
|
||||||
|
sinAsignar: Number(conteos[0]?.sin_asignar ?? 0),
|
||||||
|
totalConversaciones: Number(conteos[0]?.total ?? 0),
|
||||||
|
vendedores: candidatos.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
nombre: v.nombre,
|
||||||
|
wacrmUserId: v.wacrmUserId as string,
|
||||||
|
esMiembro: setMiembros.has(v.wacrmUserId as string),
|
||||||
|
asignadas: mapAsignadas.get(v.wacrmUserId as string) ?? 0,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function configurarWacrm(datos: {
|
||||||
|
cuentaId?: string | undefined
|
||||||
|
activo?: boolean | undefined
|
||||||
|
}): Promise<{ cuentaId: string | null; activo: boolean }> {
|
||||||
|
const cambios: Record<string, string> = {}
|
||||||
|
if (datos.cuentaId !== undefined) {
|
||||||
|
if (datos.cuentaId && !/^[0-9a-f-]{36}$/i.test(datos.cuentaId)) {
|
||||||
|
throw new BadRequestError('cuentaId debe ser un UUID válido (o vacío para quitar)')
|
||||||
|
}
|
||||||
|
if (datos.cuentaId) {
|
||||||
|
// Verificar que la cuenta exista en wacrm antes de guardarla
|
||||||
|
const { rows } = await obtenerPool().query(`SELECT id FROM accounts WHERE id = $1`, [
|
||||||
|
datos.cuentaId,
|
||||||
|
])
|
||||||
|
if (rows.length === 0) throw new NotFoundError('La cuenta no existe en WA CRM')
|
||||||
|
}
|
||||||
|
cambios[CLAVE_CUENTA] = datos.cuentaId
|
||||||
|
}
|
||||||
|
if (datos.activo !== undefined) cambios[CLAVE_RR_ACTIVO] = datos.activo ? '1' : '0'
|
||||||
|
if (Object.keys(cambios).length > 0) await actualizarConfigs(cambios)
|
||||||
|
const [cuentaId, activo] = await Promise.all([
|
||||||
|
obtenerConfig(CLAVE_CUENTA),
|
||||||
|
obtenerConfig(CLAVE_RR_ACTIVO),
|
||||||
|
])
|
||||||
|
return { cuentaId, activo: activo === '1' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reasignarConversacion(datos: {
|
||||||
|
conversacionId: string
|
||||||
|
vendedorId: string
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!/^[0-9a-f-]{36}$/i.test(datos.conversacionId)) {
|
||||||
|
throw new BadRequestError('conversacionId debe ser un UUID')
|
||||||
|
}
|
||||||
|
const vendedor = await prisma.usuario.findFirst({
|
||||||
|
where: { id: datos.vendedorId, rol: 'vendedor', activo: true },
|
||||||
|
})
|
||||||
|
if (!vendedor?.wacrmUserId) throw new NotFoundError('Vendedor sin vínculo a WA CRM')
|
||||||
|
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
|
||||||
|
const db = obtenerPool()
|
||||||
|
if (cuentaId) {
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT 1 FROM profiles WHERE account_id = $1 AND user_id = $2
|
||||||
|
AND account_role IN ('owner','admin','agent')`,
|
||||||
|
[cuentaId, vendedor.wacrmUserId],
|
||||||
|
)
|
||||||
|
if (rows.length === 0) throw new BadRequestError('El vendedor no es miembro de la cuenta WA CRM')
|
||||||
|
}
|
||||||
|
const r = await db.query(
|
||||||
|
`UPDATE conversations SET assigned_agent_id = $2, updated_at = now() WHERE id = $1`,
|
||||||
|
[datos.conversacionId, vendedor.wacrmUserId],
|
||||||
|
)
|
||||||
|
if (r.rowCount === 0) throw new NotFoundError('Conversación no encontrada')
|
||||||
|
}
|
||||||
@@ -39,6 +39,13 @@ export const MODULOS: Modulo[] = [
|
|||||||
claves: ['chat'],
|
claves: ['chat'],
|
||||||
url: '/ai/',
|
url: '/ai/',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'whatsapp',
|
||||||
|
nombre: 'WhatsApp',
|
||||||
|
descripcion: 'Bandeja compartida de WhatsApp: conversaciones asignadas y round-robin',
|
||||||
|
claves: ['whatsapp'],
|
||||||
|
url: '/wa-crm/',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'tableros',
|
id: 'tableros',
|
||||||
nombre: 'Tableros',
|
nombre: 'Tableros',
|
||||||
|
|||||||
13
apps/wa-crm/index.html
Normal file
13
apps/wa-crm/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#1B4332" />
|
||||||
|
<title>CRM360 FRASA — WhatsApp</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3883
apps/wa-crm/package-lock.json
generated
Normal file
3883
apps/wa-crm/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
apps/wa-crm/package.json
Normal file
29
apps/wa-crm/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "wa-crm",
|
||||||
|
"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",
|
||||||
|
"lucide-react": "^1.41.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hot-toast": "^2.6.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/wa-crm/src/components/DarkModeToggle.tsx
Normal file
32
apps/wa-crm/src/components/DarkModeToggle.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Sun, Moon } from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
function getStoredTheme(): boolean {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("frasa_theme")
|
||||||
|
if (stored) return stored === "dark"
|
||||||
|
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DarkModeToggle() {
|
||||||
|
const [dark, setDark] = useState(getStoredTheme)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle("dark", dark)
|
||||||
|
try {
|
||||||
|
localStorage.setItem("frasa_theme", dark ? "dark" : "light")
|
||||||
|
} catch {
|
||||||
|
// localStorage no disponible (entorno de test)
|
||||||
|
}
|
||||||
|
}, [dark])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={() => setDark(!dark)} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#1E293B]" aria-label="Cambiar tema">
|
||||||
|
{dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
apps/wa-crm/src/components/SinSesion.tsx
Normal file
27
apps/wa-crm/src/components/SinSesion.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pantalla cuando no hay sesión: se indica que el login vive en la landing
|
||||||
|
* principal (misma sesión compartida vía localStorage 'frasa_auth').
|
||||||
|
*/
|
||||||
|
export default function SinSesion({ children }: { children?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[#F8FAFC] px-4 dark:bg-[#0F172A]">
|
||||||
|
<div className="w-full max-w-md rounded-xl border border-[#E5E7EB] bg-white p-8 text-center shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
|
<h1 className="text-xl font-bold text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
Inicia sesión desde la página principal
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-base text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Usa el mismo usuario y contraseña del CRM para entrar a tu bandeja de WhatsApp.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="mt-6 inline-block rounded-lg bg-accent px-6 py-3 text-base font-semibold text-white transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Ir a la página principal
|
||||||
|
</a>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
apps/wa-crm/src/index.css
Normal file
20
apps/wa-crm/src/index.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--color-accent: #2563EB;
|
||||||
|
--color-accent-hover: #1D4ED8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-[#F8FAFC] text-[#111827] antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/wa-crm/src/main.tsx
Normal file
16
apps/wa-crm/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
import '@fontsource/inter/300.css'
|
||||||
|
import '@fontsource/inter/400.css'
|
||||||
|
import '@fontsource/inter/500.css'
|
||||||
|
import '@fontsource/inter/700.css'
|
||||||
|
import './index.css'
|
||||||
|
import WaCrmPage from './pages/WaCrmPage'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<WaCrmPage />
|
||||||
|
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
525
apps/wa-crm/src/pages/WaCrmPage.tsx
Normal file
525
apps/wa-crm/src/pages/WaCrmPage.tsx
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { RefreshCw, ExternalLink, MessageSquare, Send, Users } from 'lucide-react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import SinSesion from '../components/SinSesion'
|
||||||
|
import DarkModeToggle from '../components/DarkModeToggle'
|
||||||
|
import { getSesion, logout, type Sesion } from '../services/auth'
|
||||||
|
import {
|
||||||
|
dispararRoundRobin,
|
||||||
|
guardarConfigWacrm,
|
||||||
|
obtenerEstadoWacrm,
|
||||||
|
obtenerMiBandeja,
|
||||||
|
reasignarConversacion,
|
||||||
|
type EstadoWacrm,
|
||||||
|
type MiBandeja,
|
||||||
|
} from '../services/wacrm'
|
||||||
|
|
||||||
|
function esAdmin(sesion: Sesion): boolean {
|
||||||
|
return sesion.usuario.rol === 'admin' || sesion.usuario.rol === 'manager'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fecha relativa en español corto: "hace 5 min", "ayer 14:30", "12/sep". */
|
||||||
|
export function fechaRelativa(iso: string | null): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
const fecha = new Date(iso)
|
||||||
|
if (Number.isNaN(fecha.getTime())) return ''
|
||||||
|
const ahora = Date.now()
|
||||||
|
const diffMs = ahora - fecha.getTime()
|
||||||
|
const min = Math.floor(diffMs / 60000)
|
||||||
|
if (min < 1) return 'ahora'
|
||||||
|
if (min < 60) return `hace ${min} min`
|
||||||
|
const horas = Math.floor(min / 60)
|
||||||
|
if (horas < 24) return `hace ${horas} h`
|
||||||
|
const dias = Math.floor(horas / 24)
|
||||||
|
if (dias === 1) return 'ayer'
|
||||||
|
if (dias < 7) return `hace ${dias} días`
|
||||||
|
return fecha.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'bandeja' | 'admin'
|
||||||
|
|
||||||
|
export default function WaCrmPage() {
|
||||||
|
const [sesion] = useState<Sesion | null>(() => getSesion())
|
||||||
|
const [tab, setTab] = useState<Tab>('bandeja')
|
||||||
|
|
||||||
|
if (!sesion) {
|
||||||
|
return <SinSesion />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8FAFC] text-[#111827] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
|
||||||
|
<div className="border-b border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center justify-between gap-2 px-4 py-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<MessageSquare className="h-6 w-6 shrink-0 text-accent" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-lg font-bold">WhatsApp</h1>
|
||||||
|
<p className="truncate text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DarkModeToggle />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
logout()
|
||||||
|
window.location.reload()
|
||||||
|
}}
|
||||||
|
className="rounded-md px-3 py-2 text-sm font-medium text-accent transition-colors hover:text-accent-hover"
|
||||||
|
>
|
||||||
|
Salir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto flex max-w-5xl gap-1 px-4 pb-0">
|
||||||
|
<TabBoton activo={tab === 'bandeja'} onClick={() => setTab('bandeja')}>
|
||||||
|
Mi bandeja
|
||||||
|
</TabBoton>
|
||||||
|
{esAdmin(sesion) && (
|
||||||
|
<TabBoton activo={tab === 'admin'} onClick={() => setTab('admin')}>
|
||||||
|
Administración
|
||||||
|
</TabBoton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<main className="mx-auto max-w-5xl px-4 py-6">
|
||||||
|
{tab === 'bandeja' ? <VistaBandeja /> : <VistaAdmin />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabBoton({
|
||||||
|
activo,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
activo: boolean
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-t-lg border-b-2 px-4 py-2.5 text-base font-semibold transition-colors ${
|
||||||
|
activo
|
||||||
|
? 'border-accent text-accent'
|
||||||
|
: 'border-transparent text-[#64748B] hover:text-[#111827] dark:text-[#94A3B8] dark:hover:text-[#E2E8F0]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Vista: Mi bandeja (todos los roles)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function VistaBandeja() {
|
||||||
|
const [bandeja, setBandeja] = useState<MiBandeja | null>(null)
|
||||||
|
const [cargando, setCargando] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
setCargando(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
setBandeja(await obtenerMiBandeja())
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'No se pudo cargar la bandeja')
|
||||||
|
} finally {
|
||||||
|
setCargando(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void cargar()
|
||||||
|
}, [cargar])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold">Mis conversaciones</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void cargar()}
|
||||||
|
disabled={cargando}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-[#E5E7EB] bg-white px-4 py-2.5 text-base font-semibold text-[#111827] transition-colors hover:bg-[#F1F5F9] disabled:opacity-50 dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#E2E8F0] dark:hover:bg-[#334155]"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${cargando ? 'animate-spin' : ''}`} />
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cargando && bandeja === null && (
|
||||||
|
<p className="py-8 text-center text-base text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Cargando tu bandeja…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-base text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!cargando && bandeja && !bandeja.vinculado && (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-5 text-base text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
||||||
|
<p className="font-semibold">Tu usuario no está vinculado a WA CRM.</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
Para ver tus conversaciones de WhatsApp aquí, un administrador debe vincular tu
|
||||||
|
usuario del CRM con tu usuario de WA CRM. Avísale a administración.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!cargando && bandeja && bandeja.vinculado && bandeja.conversaciones.length === 0 && (
|
||||||
|
<p className="rounded-lg border border-[#E5E7EB] bg-white p-6 text-center text-base text-[#64748B] dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#94A3B8]">
|
||||||
|
No tienes conversaciones asignadas por ahora.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bandeja && bandeja.vinculado && bandeja.conversaciones.length > 0 && (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{bandeja.conversaciones.map((conv) => (
|
||||||
|
<li
|
||||||
|
key={conv.id}
|
||||||
|
className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#334155] dark:bg-[#1E293B]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-lg font-semibold">
|
||||||
|
{conv.contacto.nombre || conv.contacto.telefono}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
{conv.contacto.telefono}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{conv.unreadCount > 0 && (
|
||||||
|
<span className="rounded-full bg-accent px-2.5 py-0.5 text-sm font-bold text-white">
|
||||||
|
{conv.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{conv.lastMessage && (
|
||||||
|
<p className="mt-2 line-clamp-2 text-base text-[#374151] dark:text-[#CBD5E1]">
|
||||||
|
{conv.lastMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
{conv.status} · {fechaRelativa(conv.lastMessageAt)}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="/wa/inbox"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-base font-semibold text-white transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Abrir en WA CRM
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Vista: Administración (admin/manager)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function VistaAdmin() {
|
||||||
|
const [estado, setEstado] = useState<EstadoWacrm | null>(null)
|
||||||
|
const [sinPermiso, setSinPermiso] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [cuentaId, setCuentaId] = useState('')
|
||||||
|
const [guardandoCuenta, setGuardandoCuenta] = useState(false)
|
||||||
|
const [cambiandoToggle, setCambiandoToggle] = useState(false)
|
||||||
|
const [asignando, setAsignando] = useState(false)
|
||||||
|
const [convReasignar, setConvReasignar] = useState('')
|
||||||
|
const [vendedorReasignar, setVendedorReasignar] = useState('')
|
||||||
|
const [reasignando, setReasignando] = useState(false)
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await obtenerEstadoWacrm()
|
||||||
|
setEstado(data)
|
||||||
|
setCuentaId(data.cuentaId ?? '')
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message.includes('403')) {
|
||||||
|
setSinPermiso(true)
|
||||||
|
} else {
|
||||||
|
setError(e instanceof Error ? e.message : 'No se pudo cargar el estado')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void cargar()
|
||||||
|
}, [cargar])
|
||||||
|
|
||||||
|
if (sinPermiso) {
|
||||||
|
return (
|
||||||
|
<p className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-base text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
||||||
|
Tu rol no tiene permisos de administración de WA CRM.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRoundRobin(activo: boolean) {
|
||||||
|
setCambiandoToggle(true)
|
||||||
|
try {
|
||||||
|
const res = await guardarConfigWacrm({ activo })
|
||||||
|
setEstado((prev) => (prev ? { ...prev, roundRobinActivo: res.activo } : prev))
|
||||||
|
toast.success(activo ? 'Round-robin activado' : 'Round-robin desactivado')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo guardar')
|
||||||
|
} finally {
|
||||||
|
setCambiandoToggle(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardarCuenta() {
|
||||||
|
const valor = cuentaId.trim()
|
||||||
|
if (!valor) {
|
||||||
|
toast.error('Escribe el ID de la cuenta (UUID de wacrm)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGuardandoCuenta(true)
|
||||||
|
try {
|
||||||
|
const res = await guardarConfigWacrm({ cuentaId: valor })
|
||||||
|
setEstado((prev) => (prev ? { ...prev, cuentaId: res.cuentaId } : prev))
|
||||||
|
toast.success('Cuenta guardada')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo guardar la cuenta')
|
||||||
|
} finally {
|
||||||
|
setGuardandoCuenta(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function asignarAhora() {
|
||||||
|
setAsignando(true)
|
||||||
|
try {
|
||||||
|
const res = await dispararRoundRobin()
|
||||||
|
toast.success(`Asignadas ${res.asignados} conversaciones`)
|
||||||
|
await cargar()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo ejecutar la asignación')
|
||||||
|
} finally {
|
||||||
|
setAsignando(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reasignar() {
|
||||||
|
const conv = convReasignar.trim()
|
||||||
|
if (!conv || !vendedorReasignar) {
|
||||||
|
toast.error('Escribe el ID de la conversación y elige un vendedor')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setReasignando(true)
|
||||||
|
try {
|
||||||
|
await reasignarConversacion(conv, vendedorReasignar)
|
||||||
|
toast.success('Conversación reasignada')
|
||||||
|
setConvReasignar('')
|
||||||
|
setVendedorReasignar('')
|
||||||
|
await cargar()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo reasignar')
|
||||||
|
} finally {
|
||||||
|
setReasignando(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-base text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!estado) {
|
||||||
|
return (
|
||||||
|
<p className="py-8 text-center text-base text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Cargando estado…
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-6">
|
||||||
|
{/* Resumen */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<TarjetaResumen titulo="Sin asignar" valor={estado.sinAsignar} />
|
||||||
|
<TarjetaResumen titulo="Total conversaciones" valor={estado.totalConversaciones} />
|
||||||
|
<TarjetaResumen
|
||||||
|
titulo="Round-robin"
|
||||||
|
valor={estado.roundRobinActivo ? 'Activo' : 'Inactivo'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuración */}
|
||||||
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#334155] dark:bg-[#1E293B]">
|
||||||
|
<h3 className="flex items-center gap-2 text-lg font-bold">
|
||||||
|
<Users className="h-5 w-5 text-accent" />
|
||||||
|
Configuración
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-semibold">Round-robin automático</p>
|
||||||
|
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Asigna las conversaciones nuevas entre los vendedores de la cuenta.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={estado.roundRobinActivo}
|
||||||
|
disabled={cambiandoToggle}
|
||||||
|
onClick={() => void toggleRoundRobin(!estado.roundRobinActivo)}
|
||||||
|
className={`relative h-8 w-14 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
|
||||||
|
estado.roundRobinActivo ? 'bg-accent' : 'bg-[#CBD5E1] dark:bg-[#475569]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-1 h-6 w-6 rounded-full bg-white shadow transition-all ${
|
||||||
|
estado.roundRobinActivo ? 'left-7' : 'left-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<label htmlFor="cuenta-id" className="text-base font-semibold">
|
||||||
|
Cuenta de wacrm (ID / UUID)
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Es el identificador de la cuenta de WA CRM donde viven las conversaciones.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
|
||||||
|
<input
|
||||||
|
id="cuenta-id"
|
||||||
|
type="text"
|
||||||
|
value={cuentaId}
|
||||||
|
onChange={(e) => setCuentaId(e.target.value)}
|
||||||
|
placeholder="p. ej. 3f2b8c1a-…"
|
||||||
|
className="w-full flex-1 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-base dark:border-[#475569] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void guardarCuenta()}
|
||||||
|
disabled={guardandoCuenta}
|
||||||
|
className="rounded-lg bg-accent px-5 py-3 text-base font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{guardandoCuenta ? 'Guardando…' : 'Guardar cuenta'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Asigna ahora las conversaciones sin vendedor según el round-robin.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void asignarAhora()}
|
||||||
|
disabled={asignando}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-accent px-5 py-3 text-base font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
{asignando ? 'Asignando…' : 'Asignar ahora'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vendedores */}
|
||||||
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#334155] dark:bg-[#1E293B]">
|
||||||
|
<h3 className="text-lg font-bold">Vendedores</h3>
|
||||||
|
<div className="mt-3 overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[480px] text-left text-base">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#E5E7EB] text-sm text-[#64748B] dark:border-[#334155] dark:text-[#94A3B8]">
|
||||||
|
<th className="px-2 py-2 font-semibold">Nombre</th>
|
||||||
|
<th className="px-2 py-2 font-semibold">En la cuenta</th>
|
||||||
|
<th className="px-2 py-2 font-semibold">Asignadas</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{estado.vendedores.map((v) => (
|
||||||
|
<tr
|
||||||
|
key={v.id}
|
||||||
|
className="border-b border-[#F1F5F9] last:border-0 dark:border-[#0F172A]"
|
||||||
|
>
|
||||||
|
<td className="px-2 py-2.5">{v.nombre}</td>
|
||||||
|
<td className="px-2 py-2.5">
|
||||||
|
{v.esMiembro ? (
|
||||||
|
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-sm font-semibold text-green-700 dark:bg-green-900/50 dark:text-green-300">
|
||||||
|
Sí
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-[#F1F5F9] px-2.5 py-0.5 text-sm font-semibold text-[#64748B] dark:bg-[#334155] dark:text-[#94A3B8]">
|
||||||
|
No
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2.5">{v.asignadas}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reasignar */}
|
||||||
|
<div className="mt-5 rounded-lg border border-[#E5E7EB] p-4 dark:border-[#334155]">
|
||||||
|
<p className="text-base font-semibold">Reasignar conversación</p>
|
||||||
|
<div className="mt-2 grid gap-2 sm:grid-cols-[1fr_1fr_auto]">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={convReasignar}
|
||||||
|
onChange={(e) => setConvReasignar(e.target.value)}
|
||||||
|
placeholder="ID de la conversación"
|
||||||
|
className="rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-base dark:border-[#475569] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={vendedorReasignar}
|
||||||
|
onChange={(e) => setVendedorReasignar(e.target.value)}
|
||||||
|
className="rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-base dark:border-[#475569] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
|
||||||
|
>
|
||||||
|
<option value="">Elige vendedor…</option>
|
||||||
|
{estado.vendedores.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void reasignar()}
|
||||||
|
disabled={reasignando}
|
||||||
|
className="rounded-lg bg-accent px-5 py-3 text-base font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{reasignando ? 'Reasignando…' : 'Reasignar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TarjetaResumen({ titulo, valor }: { titulo: string; valor: number | string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#334155] dark:bg-[#1E293B]">
|
||||||
|
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{valor}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
apps/wa-crm/src/services/auth.test.ts
Normal file
86
apps/wa-crm/src/services/auth.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { conAuth, getSesion, getToken, logout, manejar401 } from './auth'
|
||||||
|
|
||||||
|
/** Stub sencillo de localStorage para correr en entorno node */
|
||||||
|
function crearStorageStub(): Storage {
|
||||||
|
const mapa = new Map<string, string>()
|
||||||
|
return {
|
||||||
|
getItem: (k: string) => mapa.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void mapa.set(k, v),
|
||||||
|
removeItem: (k: string) => void mapa.delete(k),
|
||||||
|
clear: () => mapa.clear(),
|
||||||
|
key: () => null,
|
||||||
|
get length() {
|
||||||
|
return mapa.size
|
||||||
|
},
|
||||||
|
} as Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
const sesionOk = {
|
||||||
|
token: 'jwt-de-prueba',
|
||||||
|
usuario: { nombre: 'MARIA LOPEZ', rol: 'vendedor', asignacion: 'MONTEBELLO' },
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('localStorage', crearStorageStub())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('almacenamiento de sesión', () => {
|
||||||
|
it('sin sesión: getSesion/getToken devuelven null', () => {
|
||||||
|
expect(getSesion()).toBeNull()
|
||||||
|
expect(getToken()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('JSON corrupto en storage se trata como "sin sesión"', () => {
|
||||||
|
localStorage.setItem('frasa_auth', '{basura')
|
||||||
|
expect(getSesion()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sesión válida: devuelve token y usuario', () => {
|
||||||
|
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
|
||||||
|
expect(getToken()).toBe('jwt-de-prueba')
|
||||||
|
expect(getSesion()?.usuario.rol).toBe('vendedor')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logout limpia la sesión', () => {
|
||||||
|
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
|
||||||
|
logout()
|
||||||
|
expect(getSesion()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('conAuth', () => {
|
||||||
|
it('sin token devuelve el init tal cual', () => {
|
||||||
|
const init = { method: 'POST', headers: { 'Content-Type': 'application/json' } }
|
||||||
|
expect(conAuth(init)).toBe(init)
|
||||||
|
expect(conAuth()).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('con token agrega Authorization: Bearer conservando headers', () => {
|
||||||
|
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
|
||||||
|
const resultado = conAuth({ headers: { 'Content-Type': 'application/json' } })
|
||||||
|
expect(resultado.headers).toEqual({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer jwt-de-prueba',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('manejar401', () => {
|
||||||
|
it('con 401 limpia la sesión', () => {
|
||||||
|
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
|
||||||
|
manejar401(new Response('', { status: 401 }))
|
||||||
|
expect(getSesion()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('con otro status no toca la sesión', () => {
|
||||||
|
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
|
||||||
|
manejar401(new Response('', { status: 403 }))
|
||||||
|
expect(getSesion()).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
52
apps/wa-crm/src/services/auth.ts
Normal file
52
apps/wa-crm/src/services/auth.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Sesión de autenticación contra el API Gateway (JWT guardado en localStorage)
|
||||||
|
// (la URL base del API la usa services/wacrm.ts vía VITE_API_URL)
|
||||||
|
const STORAGE_KEY = 'frasa_auth'
|
||||||
|
|
||||||
|
export interface UsuarioSesion {
|
||||||
|
nombre: string
|
||||||
|
rol: string
|
||||||
|
asignacion: string | null
|
||||||
|
acciones?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sesion {
|
||||||
|
token: string
|
||||||
|
usuario: UsuarioSesion
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrega Authorization: Bearer a un RequestInit si hay sesión.
|
||||||
|
* Sin token devuelve el init tal cual.
|
||||||
|
*/
|
||||||
|
export function conAuth(init: RequestInit = {}): RequestInit {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return init
|
||||||
|
return { ...init, cache: "no-store", 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()
|
||||||
|
}
|
||||||
106
apps/wa-crm/src/services/wacrm.ts
Normal file
106
apps/wa-crm/src/services/wacrm.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Llamadas al API Gateway para el módulo WhatsApp (WA CRM)
|
||||||
|
import { conAuth, manejar401 } from './auth'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||||
|
|
||||||
|
export interface ConversacionBandeja {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
lastMessage: string | null
|
||||||
|
lastMessageAt: string | null
|
||||||
|
unreadCount: number
|
||||||
|
contacto: { nombre: string | null; telefono: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MiBandeja {
|
||||||
|
vinculado: boolean
|
||||||
|
conversaciones: ConversacionBandeja[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendedorWacrm {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
wacrmUserId: string | null
|
||||||
|
esMiembro: boolean
|
||||||
|
asignadas: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstadoWacrm {
|
||||||
|
cuentaId: string | null
|
||||||
|
roundRobinActivo: boolean
|
||||||
|
sinAsignar: number
|
||||||
|
totalConversaciones: number
|
||||||
|
vendedores: VendedorWacrm[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigWacrm {
|
||||||
|
cuentaId: string | null
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorDeApi(res: Response, fallback: string): Error {
|
||||||
|
return new Error(`${fallback} (error ${res.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseError(res: Response, fallback: string): Promise<Error> {
|
||||||
|
const body = (await res.json().catch(() => null)) as { error?: string; message?: string } | null
|
||||||
|
return new Error(body?.error ?? body?.message ?? `${fallback} (error ${res.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function obtenerMiBandeja(): Promise<MiBandeja> {
|
||||||
|
const res = await fetch(`${API_URL}/wacrm/mi-bandeja`, conAuth())
|
||||||
|
manejar401(res)
|
||||||
|
if (!res.ok) throw await parseError(res, 'No se pudo cargar la bandeja')
|
||||||
|
return (await res.json()) as MiBandeja
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function obtenerEstadoWacrm(): Promise<EstadoWacrm> {
|
||||||
|
const res = await fetch(`${API_URL}/wacrm/estado`, conAuth())
|
||||||
|
manejar401(res)
|
||||||
|
if (res.status === 403) throw errorDeApi(res, 'Sin permisos de administración')
|
||||||
|
if (!res.ok) throw await parseError(res, 'No se pudo cargar el estado')
|
||||||
|
return (await res.json()) as EstadoWacrm
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function guardarConfigWacrm(cambios: {
|
||||||
|
cuentaId?: string
|
||||||
|
activo?: boolean
|
||||||
|
}): Promise<ConfigWacrm> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/wacrm/config`,
|
||||||
|
conAuth({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(cambios),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
manejar401(res)
|
||||||
|
if (!res.ok) throw await parseError(res, 'No se pudo guardar la configuración')
|
||||||
|
return (await res.json()) as ConfigWacrm
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispararRoundRobin(): Promise<{ asignados: number }> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/wacrm/round-robin`,
|
||||||
|
conAuth({ method: 'POST' }),
|
||||||
|
)
|
||||||
|
manejar401(res)
|
||||||
|
if (!res.ok) throw await parseError(res, 'No se pudo ejecutar la asignación')
|
||||||
|
return (await res.json()) as { asignados: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reasignarConversacion(
|
||||||
|
conversacionId: string,
|
||||||
|
vendedorId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/wacrm/reasignar`,
|
||||||
|
conAuth({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ conversacionId, vendedorId }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
manejar401(res)
|
||||||
|
if (!res.ok) throw await parseError(res, 'No se pudo reasignar la conversación')
|
||||||
|
}
|
||||||
22
apps/wa-crm/tsconfig.app.json
Normal file
22
apps/wa-crm/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/wa-crm/tsconfig.json
Normal file
7
apps/wa-crm/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
19
apps/wa-crm/tsconfig.node.json
Normal file
19
apps/wa-crm/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/wa-crm/vite.config.ts
Normal file
9
apps/wa-crm/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 /wa-crm
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/wa-crm/',
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
})
|
||||||
8
apps/wa-crm/vitest.config.ts
Normal file
8
apps/wa-crm/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',
|
||||||
|
},
|
||||||
|
})
|
||||||
54
docs/MODULO-WHATSAPP.md
Normal file
54
docs/MODULO-WHATSAPP.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Módulo WhatsApp (WA CRM) — CRM360 FRASA
|
||||||
|
|
||||||
|
Integración entre el CRM y [wacrm](https://wacrm.tech) (WhatsApp CRM
|
||||||
|
self-hosted en `/root/wacrm`, datos en Supabase self-hosted `/root/supabase`).
|
||||||
|
|
||||||
|
## Cómo funciona
|
||||||
|
|
||||||
|
- **Vínculo de usuarios**: cada `Usuario` del CRM tiene `wacrmUserId` (id de
|
||||||
|
`auth.users` en Supabase). Los 30 usuarios activos del CRM están dados de
|
||||||
|
alta en wacrm y su perfil pertenece a la **cuenta compartida**
|
||||||
|
(`7b17a9ab-6723-43aa-a9da-28e97f0071ae`, del usuario administrador).
|
||||||
|
Vendedores = rol `agent`, managers/admins = rol `admin`.
|
||||||
|
- **Round-robin**: el API Gateway (`frasa-api`) revisa cada 30 s las
|
||||||
|
conversaciones de la cuenta compartida que llegan sin asignar
|
||||||
|
(`assigned_agent_id IS NULL`, estatus `open`) y las turna entre los
|
||||||
|
vendedores del pool (rol `vendedor`, activos, vinculados y miembros agente
|
||||||
|
de la cuenta). El índice del turno vive en `ConfiguracionSistema`
|
||||||
|
(`wacrm_rr_idx`), así que sobrevive reinicios.
|
||||||
|
- **Bandeja del vendedor**: `GET /api/wacrm/mi-bandeja` devuelve las
|
||||||
|
conversaciones asignadas al usuario logueado (contacto, último mensaje,
|
||||||
|
no leídos). La app `apps/wa-crm` (`/wa-crm/`) las muestra con liga a
|
||||||
|
`/wa/inbox` para responder desde wacrm.
|
||||||
|
- **Administración** (rol admin/manager, pestaña en `/wa-crm/`):
|
||||||
|
- Estado: sin asignar, total, pool con "es miembro" y cuántas tiene cada uno.
|
||||||
|
- Activar/desactivar round-robin y cambiar la cuenta (`POST /api/wacrm/config`).
|
||||||
|
- "Asignar ahora" dispara el reparto a demanda (`POST /api/wacrm/round-robin`).
|
||||||
|
- Reasignar una conversación a cualquier vendedor (`POST /api/wacrm/reasignar`).
|
||||||
|
|
||||||
|
## Conexión técnica
|
||||||
|
|
||||||
|
- `WACRM_PG_URL` en `/root/frasa-ecosystem/.env` apunta al Postgres de
|
||||||
|
Supabase por `127.0.0.1:5433` (puerto publicado solo en localhost del
|
||||||
|
contenedor `supabase-db`; el 5432 es Supavisor y exige tenant).
|
||||||
|
- El servicio `api-gateway/src/services/wacrm.service.ts` usa `pg` (Pool lazy;
|
||||||
|
sin URL lanza 503 y el resto del CRM sigue igual).
|
||||||
|
- Al escribir (`UPDATE conversations`) se hace como superusuario postgres,
|
||||||
|
bypass RLS — solo el API lo hace, nunca el cliente.
|
||||||
|
|
||||||
|
## Puesta en marcha del número
|
||||||
|
|
||||||
|
El round-robin está activo sobre la cuenta compartida. Falta conectar el
|
||||||
|
número de WhatsApp (API oficial de Meta) en wacrm: credenciales y pasos en
|
||||||
|
`/root/wacrm/ENV-PENDIENTES.md`. Cuando Meta entregue el número, conectarlo
|
||||||
|
desde **wacrm → Settings → WhatsApp** con la cuenta del administrador.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`api-gateway/src/services/wacrm.service.test.ts` — integración real contra
|
||||||
|
Supabase (se auto-salta si no hay `WACRM_PG_URL`); crea usuarios/cuenta/
|
||||||
|
conversaciones de prueba y los limpia. Correr:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api-gateway && npx vitest run src/services/wacrm.service.test.ts
|
||||||
|
```
|
||||||
125
docs/PLAN-REGLAS-COTIZADOR.md
Normal file
125
docs/PLAN-REGLAS-COTIZADOR.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Plan — Nuevas reglas de negocio del Cotizador FRASA
|
||||||
|
|
||||||
|
Fuente: `Reglas de negocio Cotizador FRASA .xlsx` (entregado por el cliente).
|
||||||
|
Alcance: aplicar estas reglas al cotizador web (`apps/cotizador`) y a su espejo
|
||||||
|
del lado del API (`api-gateway/src/services/cotizacion-esquema.service.ts`).
|
||||||
|
|
||||||
|
## 1. Lectura de las reglas (interpretación)
|
||||||
|
|
||||||
|
### Globales (todos los tipos)
|
||||||
|
|
||||||
|
| Concepto | Regla |
|
||||||
|
|---|---|
|
||||||
|
| Apartado | $15,000 (cantidad fija, ya confirmada por el cliente) |
|
||||||
|
| Gastos de escrituración | 5.5 % del precio de venta |
|
||||||
|
| Gastos — opción del cliente | Puede **incluirlos** en el pago de firma (toggle). Si los incluye, se liquidan junto con el enganche para firma de contrato |
|
||||||
|
| Mensualidades a diferir | Número de **meses calendario completos** entre la fecha de cotización y la fecha de entrega de la vivienda (ya implementado vía captura) |
|
||||||
|
| Mensualidad a pagar | Enganche diferido ÷ mensualidades |
|
||||||
|
| Enganche total | El **% total que el cliente elige pagar** como enganche (incluye el apartado). Tope por tipo de crédito |
|
||||||
|
|
||||||
|
### Tipos de crédito
|
||||||
|
|
||||||
|
| Tipo | Rango enganche **para firma** | Tope enganche **total** | Aforo | Notaría |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Bancario | 2.5 % – 15 % | 15 % | 84.5 % | No aplica |
|
||||||
|
| Infonavit-Cofinavit | 2.5 % – 15 % | 15 % | 89.5 % | No aplica |
|
||||||
|
| Semi Contado | 50 % – 85 % | 85 % | No aplica | Debe quedar **15 %** para notaría |
|
||||||
|
| Contado | 86 % – 95 % | 95 % | No aplica | Debe quedar **mínimo 5 %** para notaría |
|
||||||
|
|
||||||
|
### Fórmulas
|
||||||
|
|
||||||
|
```
|
||||||
|
gastos = 0.055 × precio
|
||||||
|
firma = % enganche firma elegido (dentro del rango del tipo)
|
||||||
|
totalEng = % enganche total elegido (≤ tope del tipo; firma ≤ totalEng)
|
||||||
|
diferido = totalEng − firma (lo que se difiere en mensualidades)
|
||||||
|
mensualidad = diferido × precio ÷ N_meses
|
||||||
|
saldoPorPagar = precio + gastos − (firma% + diferido%) × precio
|
||||||
|
(= precio + gastos − totalEng% × precio)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Bancario / Infonavit-Cofinavit**: `monto a financiar = saldoPorPagar`.
|
||||||
|
Validación de **aforo**: `saldoPorPagar ≤ aforo × precio`; si se rebasa, la
|
||||||
|
combinación elegida no es viable y hay que pedir más enganche.
|
||||||
|
- **Semi Contado**: no hay financiamiento. Restricción: el saldo por pagar debe
|
||||||
|
dejar **≥ 15 %** para el pago en notaría (`saldoPorPagar ≥ 0.15 × precio`,
|
||||||
|
equivalente a `totalEng ≤ 0.85 + gastos%`). El "monto pago en notaría" = 15 %.
|
||||||
|
- **Contado**: sin mensualidades (diferido = 0; todo el enganche se paga a la
|
||||||
|
firma). Restricción: saldo ≥ 5 % para notaría (`totalEng ≤ 0.95 + gastos%`).
|
||||||
|
|
||||||
|
### Diferencias contra el esquema actual
|
||||||
|
|
||||||
|
| Aspecto | Esquema actual (FRASA) | Nuevo |
|
||||||
|
|---|---|---|
|
||||||
|
| Enganche firma | fijo 2.5 % | rango por tipo (2.5–15 / 50–85 / 86–95 %) |
|
||||||
|
| Enganche diferible | fijo 7.5 % editable | **total elegido − firma** |
|
||||||
|
| Tope enganche | 90 % global | por tipo: 15 / 15 / 85 / 95 % |
|
||||||
|
| Aforo | no se valida | validación 84.5 / 89.5 % (bancario/infonavit) |
|
||||||
|
| Gastos 5.5 % | siempre aparte | **toggle**: incluidos en firma u aparte |
|
||||||
|
| Notaría | no existe | piso 15 % (semi) / 5 % (contado) |
|
||||||
|
| Anticipo adicional | campo extra (decisión previa: sin espacio separado) | se absorbe en "enganche total" |
|
||||||
|
|
||||||
|
## 2. Puntos a confirmar con el cliente (antes o durante la implementación)
|
||||||
|
|
||||||
|
1. **Apartado dentro del %**: se interpreta que el % de enganche total es
|
||||||
|
dinero total (el apartado de $15,000 forma parte de él), según la columna
|
||||||
|
K ("% de enganche elegido incluido el apartado"). Confirmar.
|
||||||
|
2. **Semi Contado / Contado**: ¿el "pago en notaría" (15 % / 5 %) es parte del
|
||||||
|
saldo por pagar que el cliente liquida al final? Se asume que sí.
|
||||||
|
3. **Aforo**: si el financiamiento calculado supera el aforo, ¿bloquear la
|
||||||
|
opción o mostrarla en rojo como "no viable"? Se propone mostrar no viable.
|
||||||
|
4. **Rango de enganche para firma en créditos**: ¿el 2.5 % mínimo sigue
|
||||||
|
incluyendo el apartado como parte del pago a firma (como hoy)? Se propone
|
||||||
|
que sí.
|
||||||
|
|
||||||
|
## 3. Plan de implementación
|
||||||
|
|
||||||
|
### 3.1 Motor de cálculo — `esquema-frasa.ts` (app) y `cotizacion-esquema.service.ts` (API, espejo)
|
||||||
|
|
||||||
|
1. Nuevo tipo `TipoCredito = 'bancario' | 'infonavit' | 'contado' | 'semicontado'`
|
||||||
|
(reemplaza/usa el catálogo actual de opciones de crédito).
|
||||||
|
2. Tabla de reglas por tipo (única fuente de verdad en ambos lados, misma
|
||||||
|
constante): `REGLAS_TIPO` con `{rangoFirmaMin, rangoFirmaMax, topeTotal,
|
||||||
|
aforo, pisoNotaria, permiteDiferir}`.
|
||||||
|
3. Entrada del motor: `{precio, apartado, tipo, pctFirma, pctTotal, incluirGastosEnFirma, meses}`.
|
||||||
|
4. Salida: `{gastos, montoFirma, montoDiferido, mensualidad, saldoPorPagar,
|
||||||
|
montoFinanciar, pagoNotaria, viable, motivos[]}` con validaciones:
|
||||||
|
- firma dentro de rango; total ≤ tope; total ≥ firma;
|
||||||
|
- si diferido > 0 → meses ≥ 1 (tope por fecha de entrega, ya existente);
|
||||||
|
- aforo (bancario/infonavit): `saldo ≤ aforo × precio`;
|
||||||
|
- piso notaría (semi/contado): `saldo ≥ piso × precio`.
|
||||||
|
5. Quitar del esquema actual lo que queda reemplazado: enganche fijo 2.5/7.5,
|
||||||
|
tope global 90 %, `engancheAdicional` (el "total elegido" lo absorbe).
|
||||||
|
|
||||||
|
### 3.2 UI del cotizador (`apps/cotizador`)
|
||||||
|
|
||||||
|
1. Selector de **tipo de crédito** con las 4 opciones y sus topes visibles.
|
||||||
|
2. **Slider/input de enganche total** (0 → tope del tipo) y **slider/input de
|
||||||
|
enganche a firma** (rango del tipo, ≤ total).
|
||||||
|
3. **Toggle "Incluir gastos de escrituración en el pago de firma"**.
|
||||||
|
4. Tabla de resultados: apartado, firma, diferido, mensualidad (×N meses),
|
||||||
|
gastos, saldo, monto a financiar (o pago en notaría), y badge **viable /
|
||||||
|
no viable** con motivo (aforo excedido, piso notaría, etc.).
|
||||||
|
5. Las 4 opciones de crédito del guardado existente pasan a ser las 4
|
||||||
|
combinaciones tipo+porcentajes que el vendedor guarde, no 4 presets fijos.
|
||||||
|
|
||||||
|
### 3.3 Persistencia y reportes
|
||||||
|
|
||||||
|
1. `Cotizacion`: columnas nuevas `tipoCredito`, `pctFirma`, `pctTotal`,
|
||||||
|
`incluirGastosFirma` (migración SQLite manual, estilo existente). Mantener
|
||||||
|
`opcionesCredito` JSON para compatibilidad de lectura histórica.
|
||||||
|
2. PDF: sección de desglose según nuevo esquema (firma / diferido / notaría /
|
||||||
|
financiamiento, viable o no).
|
||||||
|
|
||||||
|
### 3.4 Pruebas
|
||||||
|
|
||||||
|
- Unitarias del motor: casos dorados por tipo (límites de rango, tope, aforo
|
||||||
|
justo arriba/abajo, pisos de notaría, toggle de gastos).
|
||||||
|
- Smoke API: `POST` cotización con cada tipo → verificar montos y `viable`.
|
||||||
|
- Regresión: cotizaciones históricas existentes siguen leyéndose.
|
||||||
|
|
||||||
|
### 3.5 Entrega
|
||||||
|
|
||||||
|
- Migración + build de `api-gateway` y `apps/cotizador`.
|
||||||
|
- Actualización de guía de usuario del cotizador (sección reglas).
|
||||||
|
- Resumen de cambios para el cliente con los puntos 2 resueltos.
|
||||||
Reference in New Issue
Block a user