feat(config): modulo Configuraciones admin + permisos por rol
- Tablas ConfiguracionSistema (7 configs) + RolConfiguracion (6 roles) - Endpoints: config/sistema GET/PATCH, config/roles GET/PATCH, config/usuarios GET/PATCH, config/reset-roles POST, config/acciones GET - modulosDeRol() async con fallback a MATRIZ estatica - auth/me devuelve acciones[] segun rol del usuario - saludMonday.estaDisponible() lee modo_local_forzado de DB (cache 30s) - Landing: vista Configuraciones con 4 tabs (Modo, Roles, Usuarios, Sistema) - Mi-dia: boton Descartar se deshabilita si !descartar_lead - 630 API tests + 24 landing tests + 20 mi-dia tests verdes Seed: npm run seed:config
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"test": "vitest run",
|
||||
"seed:ai": "tsx scripts/seed-ai.ts",
|
||||
"seed:usuarios": "tsx scripts/seed-usuarios.ts",
|
||||
"seed:config": "tsx scripts/seed-config.js",
|
||||
"seed:unidades": "tsx scripts/seed-unidades-locales.js",
|
||||
"setup:crm": "tsx scripts/setup-crm.ts",
|
||||
"sync:desarrollos": "tsx scripts/sync-desarrollos.ts",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Tablas de configuración del sistema y permisos por rol.
|
||||
-- Patrón manual: db execute + migrate resolve. NUNCA migrate dev.
|
||||
|
||||
-- CreateTable ConfiguracionSistema
|
||||
CREATE TABLE IF NOT EXISTS "ConfiguracionSistema" (
|
||||
"clave" TEXT NOT NULL PRIMARY KEY,
|
||||
"valor" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable RolConfiguracion
|
||||
CREATE TABLE IF NOT EXISTS "RolConfiguracion" (
|
||||
"rol" TEXT NOT NULL PRIMARY KEY,
|
||||
"modulos" TEXT NOT NULL,
|
||||
"acciones" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -307,6 +307,19 @@ model AIDocumento {
|
||||
chunks AIChunk[]
|
||||
}
|
||||
|
||||
model ConfiguracionSistema {
|
||||
clave String @id
|
||||
valor String
|
||||
updatedAt DateTime @updatedAt @default(now())
|
||||
}
|
||||
|
||||
model RolConfiguracion {
|
||||
rol String @id
|
||||
modulos String // JSON array
|
||||
acciones String // JSON object
|
||||
updatedAt DateTime @updatedAt @default(now())
|
||||
}
|
||||
|
||||
model AIChunk {
|
||||
id String @id @default(uuid())
|
||||
documentoId String
|
||||
|
||||
84
api-gateway/scripts/seed-config.js
Normal file
84
api-gateway/scripts/seed-config.js
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Siembra las tablas ConfiguracionSistema y RolConfiguracion con valores
|
||||
* iniciales. Idempotente: upsert por clave/rol.
|
||||
*
|
||||
* Lee config/acciones.ts (catálogo de acciones) y config/roles.ts (matriz de
|
||||
* módulos) como fuente de verdad.
|
||||
*
|
||||
* Uso: npx tsx scripts/seed-config.js
|
||||
*/
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import dotenv from 'dotenv'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { ACCIONES_POR_ROL_DEFAULT } from '../src/config/acciones.ts'
|
||||
import { MODULOS } from '../src/config/roles.ts'
|
||||
|
||||
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
// ── ConfiguracionSistema ───────────────────────────────────────────────
|
||||
const CONFIGS_DEFAULT = [
|
||||
{ clave: 'modo_local_forzado', valor: 'false' },
|
||||
{ clave: 'recordatorio_inactividad_dias', valor: '10' },
|
||||
{ clave: 'recordatorio_cobranza_hora', valor: '09:30' },
|
||||
{ clave: 'sesion_minutos', valor: '480' },
|
||||
{ clave: 'auto_logout', valor: 'true' },
|
||||
{ clave: 'mostrar_precios_lista', valor: 'true' },
|
||||
{ clave: 'max_upload_mb', valor: '10' },
|
||||
]
|
||||
|
||||
// ── RolConfiguracion: módulos por rol (matriz de config/roles.ts) ──────
|
||||
const MODULOS_VENDEDOR = [
|
||||
'mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat',
|
||||
]
|
||||
|
||||
const MATRIZ_MODULOS = {
|
||||
vendedor: MODULOS_VENDEDOR,
|
||||
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
||||
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat'],
|
||||
admin: [...MODULOS],
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('⚙️ Sembrando ConfiguracionSistema...')
|
||||
for (const { clave, valor } of CONFIGS_DEFAULT) {
|
||||
await prisma.configuracionSistema.upsert({
|
||||
where: { clave },
|
||||
create: { clave, valor },
|
||||
update: { valor },
|
||||
})
|
||||
console.log(` ✓ ${clave} = ${valor}`)
|
||||
}
|
||||
|
||||
console.log('\n⚙️ Sembrando RolConfiguracion...')
|
||||
for (const [rol, modulos] of Object.entries(MATRIZ_MODULOS)) {
|
||||
const acciones = ACCIONES_POR_ROL_DEFAULT[rol] ?? ACCIONES_POR_ROL_DEFAULT.vendedor
|
||||
await prisma.rolConfiguracion.upsert({
|
||||
where: { rol },
|
||||
create: {
|
||||
rol,
|
||||
modulos: JSON.stringify(modulos),
|
||||
acciones: JSON.stringify(acciones),
|
||||
},
|
||||
update: {
|
||||
modulos: JSON.stringify(modulos),
|
||||
acciones: JSON.stringify(acciones),
|
||||
},
|
||||
})
|
||||
console.log(` ✓ ${rol}: ${modulos.length} módulos, acciones: ${Object.keys(acciones).length}`)
|
||||
}
|
||||
|
||||
console.log('\n✅ Seed de configuración completado.')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('❌ Error en seed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(() => prisma.$disconnect())
|
||||
79
api-gateway/src/config/acciones.ts
Normal file
79
api-gateway/src/config/acciones.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Catálogo de acciones granulares disponibles por rol.
|
||||
// Cada acción controla un permiso específico en la UI y el backend.
|
||||
|
||||
export const ACCIONES_DISPONIBLES = [
|
||||
{ clave: 'descartar_lead', nombre: 'Descartar leads', descripcion: 'Puede descartar leads con motivo' },
|
||||
{ clave: 'editar_cotizacion', nombre: 'Editar cotizaciones', descripcion: 'Puede editar cotizaciones existentes' },
|
||||
{ clave: 'asignar_vendedor', nombre: 'Reasignar leads', descripcion: 'Puede reasignar leads a otros vendedores' },
|
||||
{ clave: 'ver_reportes', nombre: 'Ver reportes', descripcion: 'Puede ver dashboards y reportes' },
|
||||
{ clave: 'exportar_pdf', nombre: 'Exportar PDFs', descripcion: 'Puede generar y descargar PDFs' },
|
||||
{ clave: 'ver_precios', nombre: 'Ver precios completos', descripcion: 'Puede ver precios y descuentos en cotizador' },
|
||||
{ clave: 'gestionar_clientes', nombre: 'Gestionar clientes', descripcion: 'Puede gestionar fichas de clientes' },
|
||||
] as const
|
||||
|
||||
export type AccionClave = (typeof ACCIONES_DISPONIBLES)[number]['clave']
|
||||
|
||||
// Acciones por defecto por rol: true = habilitado, false = deshabilitado.
|
||||
// 'admin' tiene todo habilitado por convención.
|
||||
export const ACCIONES_POR_ROL_DEFAULT: Record<string, Record<AccionClave, boolean>> = {
|
||||
vendedor: {
|
||||
descartar_lead: true,
|
||||
editar_cotizacion: false,
|
||||
asignar_vendedor: false,
|
||||
ver_reportes: false,
|
||||
exportar_pdf: true,
|
||||
ver_precios: true,
|
||||
gestionar_clientes: false,
|
||||
},
|
||||
administracion_venta: {
|
||||
descartar_lead: true,
|
||||
editar_cotizacion: true,
|
||||
asignar_vendedor: false,
|
||||
ver_reportes: true,
|
||||
exportar_pdf: true,
|
||||
ver_precios: true,
|
||||
gestionar_clientes: true,
|
||||
},
|
||||
director_ventas: {
|
||||
descartar_lead: true,
|
||||
editar_cotizacion: true,
|
||||
asignar_vendedor: true,
|
||||
ver_reportes: true,
|
||||
exportar_pdf: true,
|
||||
ver_precios: true,
|
||||
gestionar_clientes: true,
|
||||
},
|
||||
escrituracion: {
|
||||
descartar_lead: false,
|
||||
editar_cotizacion: false,
|
||||
asignar_vendedor: false,
|
||||
ver_reportes: false,
|
||||
exportar_pdf: true,
|
||||
ver_precios: true,
|
||||
gestionar_clientes: true,
|
||||
},
|
||||
direccion_general: {
|
||||
descartar_lead: false,
|
||||
editar_cotizacion: false,
|
||||
asignar_vendedor: false,
|
||||
ver_reportes: true,
|
||||
exportar_pdf: true,
|
||||
ver_precios: true,
|
||||
gestionar_clientes: false,
|
||||
},
|
||||
admin: {
|
||||
descartar_lead: true,
|
||||
editar_cotizacion: true,
|
||||
asignar_vendedor: true,
|
||||
ver_reportes: true,
|
||||
exportar_pdf: true,
|
||||
ver_precios: true,
|
||||
gestionar_clientes: true,
|
||||
},
|
||||
}
|
||||
|
||||
/** Devuelve las acciones de un rol (o vacío si el rol no existe) */
|
||||
export function accionesPorRol(rol: string): Record<AccionClave, boolean> {
|
||||
const clave = rol.trim().toLowerCase()
|
||||
return (ACCIONES_POR_ROL_DEFAULT[clave] ?? ACCIONES_POR_ROL_DEFAULT.vendedor)!
|
||||
}
|
||||
@@ -39,22 +39,22 @@ const ESPERADOS: Record<string, string[]> = {
|
||||
}
|
||||
|
||||
describe('modulosDeRol', () => {
|
||||
it.each(Object.entries(ESPERADOS))('rol %s devuelve exactamente su lista', (rol, esperados) => {
|
||||
expect(modulosDeRol(rol)).toEqual(esperados)
|
||||
it.each(Object.entries(ESPERADOS))('rol %s devuelve exactamente su lista', async (rol, esperados) => {
|
||||
expect(await modulosDeRol(rol)).toEqual(esperados)
|
||||
})
|
||||
|
||||
it('roles legacy (coordinador, corporativo) se tratan como director_ventas', () => {
|
||||
expect(modulosDeRol('coordinador')).toEqual(ESPERADOS['director_ventas'])
|
||||
expect(modulosDeRol('corporativo')).toEqual(ESPERADOS['director_ventas'])
|
||||
it('roles legacy (coordinador, corporativo) se tratan como director_ventas', async () => {
|
||||
expect(await modulosDeRol('coordinador')).toEqual(ESPERADOS['director_ventas'])
|
||||
expect(await modulosDeRol('corporativo')).toEqual(ESPERADOS['director_ventas'])
|
||||
})
|
||||
|
||||
it('rol desconocido cae al fallback seguro (vendedor)', () => {
|
||||
expect(modulosDeRol('otro')).toEqual(ESPERADOS['vendedor'])
|
||||
expect(modulosDeRol('')).toEqual(ESPERADOS['vendedor'])
|
||||
it('rol desconocido cae al fallback seguro (vendedor)', async () => {
|
||||
expect(await modulosDeRol('otro')).toEqual(ESPERADOS['vendedor'])
|
||||
expect(await modulosDeRol('')).toEqual(ESPERADOS['vendedor'])
|
||||
})
|
||||
|
||||
it('tolera mayúsculas y espacios', () => {
|
||||
expect(modulosDeRol(' Admin ')).toEqual([...MODULOS])
|
||||
it('tolera mayúsculas y espacios', async () => {
|
||||
expect(await modulosDeRol(' Admin ')).toEqual([...MODULOS])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ const MODULOS_VENDEDOR: readonly Modulo[] = [
|
||||
'chat',
|
||||
]
|
||||
|
||||
const MATRIZ: Record<Rol, readonly Modulo[]> = {
|
||||
export const MATRIZ: Record<Rol, readonly Modulo[]> = {
|
||||
vendedor: MODULOS_VENDEDOR,
|
||||
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
||||
@@ -52,14 +52,28 @@ const ROLES_LEGACY: Record<string, Rol> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Módulos visibles para un rol. Roles legacy ('coordinador', 'corporativo') se
|
||||
* tratan como director_ventas; un rol desconocido cae a vendedor (fallback
|
||||
* seguro: es el conjunto más chico de módulos operativos).
|
||||
* Módulos visibles para un rol. Intenta leer de RolConfiguracion (DB) y
|
||||
* hace fallback a la MATRIZ estática si no existe registro.
|
||||
*
|
||||
* Roles legacy ('coordinador', 'corporativo') se tratan como director_ventas;
|
||||
* un rol desconocido cae a vendedor (fallback seguro: es el conjunto más chico
|
||||
* de módulos operativos).
|
||||
*/
|
||||
export function modulosDeRol(rol: string): Modulo[] {
|
||||
export async function modulosDeRol(rol: string): Promise<Modulo[]> {
|
||||
const clave = rol.trim().toLowerCase()
|
||||
const rolEfectivo: Rol =
|
||||
(ROLES as readonly string[]).includes(clave) ? (clave as Rol) : (ROLES_LEGACY[clave] ?? 'vendedor')
|
||||
|
||||
try {
|
||||
// Importación dinámica para evitar circular dependency con services
|
||||
const { prisma } = await import('../services/prisma.js')
|
||||
const row = await prisma.rolConfiguracion.findUnique({ where: { rol: clave } })
|
||||
if (row) {
|
||||
return JSON.parse(row.modulos) as Modulo[]
|
||||
}
|
||||
} catch {
|
||||
// Si la tabla aún no existe o hay error de conexión, fallback silencioso
|
||||
}
|
||||
return [...MATRIZ[rolEfectivo]]
|
||||
}
|
||||
|
||||
|
||||
206
api-gateway/src/controllers/config.controller.test.ts
Normal file
206
api-gateway/src/controllers/config.controller.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
import {
|
||||
requireAdmin,
|
||||
obtenerConfigsEndpoint,
|
||||
actualizarConfigsEndpoint,
|
||||
obtenerRolesConfigEndpoint,
|
||||
actualizarRolConfigEndpoint,
|
||||
obtenerUsuariosEndpoint,
|
||||
actualizarUsuarioEndpoint,
|
||||
resetRolesEndpoint,
|
||||
obtenerAccionesEndpoint,
|
||||
} from './config.controller.js'
|
||||
|
||||
// ---------- Servicio mockeado ----------
|
||||
const servicioMock = vi.hoisted(() => ({
|
||||
obtenerConfigs: vi.fn(),
|
||||
actualizarConfigs: vi.fn(),
|
||||
obtenerRolesConfig: vi.fn(),
|
||||
actualizarRolConfig: vi.fn(),
|
||||
obtenerUsuarios: vi.fn(),
|
||||
actualizarUsuario: vi.fn(),
|
||||
resetRoles: vi.fn(),
|
||||
obtenerAcciones: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/config.service.js', () => servicioMock)
|
||||
|
||||
function reqFake(overrides: Partial<Request> = {}): Request {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
usuarioToken: { sub: 'u-1', username: 'admin', rol: 'admin' },
|
||||
...overrides,
|
||||
} as Request
|
||||
}
|
||||
|
||||
function resFake() {
|
||||
const json = vi.fn()
|
||||
const end = vi.fn()
|
||||
const status = vi.fn().mockReturnValue({ json, end })
|
||||
return { res: { json, status, end } as unknown as Response, status, json, end }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ── requireAdmin ───────────────────────────────────────────────────────
|
||||
|
||||
describe('requireAdmin', () => {
|
||||
it('next() si rol es admin', () => {
|
||||
const next = vi.fn()
|
||||
requireAdmin(reqFake(), {} as Response, next)
|
||||
expect(next).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lanza 403 si rol no es admin', () => {
|
||||
expect(() =>
|
||||
requireAdmin(reqFake({ usuarioToken: { sub: 'u-2', username: 'x', rol: 'vendedor' } }), {} as Response, vi.fn()),
|
||||
).toThrow('Se requiere rol admin')
|
||||
})
|
||||
|
||||
it('lanza 403 si no hay token', () => {
|
||||
expect(() =>
|
||||
requireAdmin(reqFake({ usuarioToken: undefined }), {} as Response, vi.fn()),
|
||||
).toThrow('Se requiere rol admin')
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/config/sistema ────────────────────────────────────────────
|
||||
|
||||
describe('obtenerConfigsEndpoint', () => {
|
||||
it('200 con todas las configs', async () => {
|
||||
servicioMock.obtenerConfigs.mockResolvedValue({ a: '1' })
|
||||
const { res, json } = resFake()
|
||||
await obtenerConfigsEndpoint(reqFake(), res)
|
||||
expect(json).toHaveBeenCalledWith({ a: '1' })
|
||||
})
|
||||
})
|
||||
|
||||
// ── PATCH /api/config/sistema ──────────────────────────────────────────
|
||||
|
||||
describe('actualizarConfigsEndpoint', () => {
|
||||
it('204 tras actualizar configs parciales', async () => {
|
||||
servicioMock.actualizarConfigs.mockResolvedValue(undefined)
|
||||
const { res, end } = resFake()
|
||||
await actualizarConfigsEndpoint(reqFake({ body: { x: '1' } }), res)
|
||||
expect(servicioMock.actualizarConfigs).toHaveBeenCalledWith({ x: '1' })
|
||||
expect(end).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400 si body vacío', async () => {
|
||||
const { res } = resFake()
|
||||
await expect(actualizarConfigsEndpoint(reqFake({ body: {} }), res)).rejects.toThrow('Body vacío')
|
||||
})
|
||||
|
||||
it('400 si body inválido (ZodError)', async () => {
|
||||
const { res } = resFake()
|
||||
await expect(actualizarConfigsEndpoint(reqFake({ body: { x: 123 } }), res)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/config/roles ──────────────────────────────────────────────
|
||||
|
||||
describe('obtenerRolesConfigEndpoint', () => {
|
||||
it('200 con roles parseados', async () => {
|
||||
servicioMock.obtenerRolesConfig.mockResolvedValue([
|
||||
{ rol: 'admin', modulos: '["mi-dia"]', acciones: '{"x":true}', updatedAt: new Date() },
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
await obtenerRolesConfigEndpoint(reqFake(), res)
|
||||
expect(json).toHaveBeenCalledWith([
|
||||
{ rol: 'admin', modulos: ['mi-dia'], acciones: { x: true }, updatedAt: expect.any(Date) },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// ── PATCH /api/config/roles/:rol ───────────────────────────────────────
|
||||
|
||||
describe('actualizarRolConfigEndpoint', () => {
|
||||
it('204 tras actualizar un rol', async () => {
|
||||
servicioMock.actualizarRolConfig.mockResolvedValue(undefined)
|
||||
const { res, end } = resFake()
|
||||
await actualizarRolConfigEndpoint(
|
||||
reqFake({ params: { rol: 'vendedor' }, body: { modulos: ['a'], acciones: { b: true } } }),
|
||||
res,
|
||||
)
|
||||
expect(servicioMock.actualizarRolConfig).toHaveBeenCalledWith('vendedor', {
|
||||
modulos: ['a'],
|
||||
acciones: { b: true },
|
||||
})
|
||||
expect(end).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400 si body inválido (faltan campos)', async () => {
|
||||
const { res } = resFake()
|
||||
await expect(
|
||||
actualizarRolConfigEndpoint(reqFake({ params: { rol: 'x' }, body: { modulos: 'no' } }), res),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/config/usuarios ───────────────────────────────────────────
|
||||
|
||||
describe('obtenerUsuariosEndpoint', () => {
|
||||
it('200 con la lista de usuarios', async () => {
|
||||
servicioMock.obtenerUsuarios.mockResolvedValue([{ id: 'u1', nombre: 'A' }])
|
||||
const { res, json } = resFake()
|
||||
await obtenerUsuariosEndpoint(reqFake(), res)
|
||||
expect(json).toHaveBeenCalledWith([{ id: 'u1', nombre: 'A' }])
|
||||
})
|
||||
})
|
||||
|
||||
// ── PATCH /api/config/usuarios/:id ─────────────────────────────────────
|
||||
|
||||
describe('actualizarUsuarioEndpoint', () => {
|
||||
it('204 tras actualizar un usuario', async () => {
|
||||
servicioMock.actualizarUsuario.mockResolvedValue(undefined)
|
||||
const { res, end } = resFake()
|
||||
await actualizarUsuarioEndpoint(
|
||||
reqFake({ params: { id: 'u1' }, body: { rol: 'admin' } }),
|
||||
res,
|
||||
)
|
||||
expect(servicioMock.actualizarUsuario).toHaveBeenCalledWith('u1', { rol: 'admin' })
|
||||
expect(end).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400 si body vacío', async () => {
|
||||
const { res } = resFake()
|
||||
await expect(
|
||||
actualizarUsuarioEndpoint(reqFake({ params: { id: 'u1' }, body: {} }), res),
|
||||
).rejects.toThrow('Body vacío')
|
||||
})
|
||||
})
|
||||
|
||||
// ── POST /api/config/reset-roles ───────────────────────────────────────
|
||||
|
||||
describe('resetRolesEndpoint', () => {
|
||||
it('204 tras resetear roles', async () => {
|
||||
servicioMock.resetRoles.mockResolvedValue(undefined)
|
||||
const { res, end } = resFake()
|
||||
await resetRolesEndpoint(reqFake(), res)
|
||||
expect(servicioMock.resetRoles).toHaveBeenCalled()
|
||||
expect(end).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/config/acciones ───────────────────────────────────────────
|
||||
|
||||
describe('obtenerAccionesEndpoint', () => {
|
||||
it('200 con las acciones del rol del usuario', async () => {
|
||||
servicioMock.obtenerAcciones.mockResolvedValue({ descartar_lead: true })
|
||||
const { res, json } = resFake()
|
||||
await obtenerAccionesEndpoint(reqFake(), res)
|
||||
expect(servicioMock.obtenerAcciones).toHaveBeenCalledWith('admin')
|
||||
expect(json).toHaveBeenCalledWith({ descartar_lead: true })
|
||||
})
|
||||
|
||||
it('400 sin token verificado', async () => {
|
||||
const { res } = resFake()
|
||||
await expect(obtenerAccionesEndpoint(reqFake({ usuarioToken: undefined }), res)).rejects.toThrow(
|
||||
'Falta el token verificado',
|
||||
)
|
||||
})
|
||||
})
|
||||
111
api-gateway/src/controllers/config.controller.ts
Normal file
111
api-gateway/src/controllers/config.controller.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Endpoints de administración de configuración.
|
||||
*
|
||||
* Protegidos con requireAdmin (rol === 'admin') excepto /acciones que
|
||||
* cualquiera autenticado puede consultar.
|
||||
*/
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import * as configService from '../services/config.service.js'
|
||||
import { AppError, BadRequestError } from '../utils/errors.js'
|
||||
|
||||
// ── Middleware admin ────────────────────────────────────────────────────
|
||||
|
||||
export function requireAdmin(req: Request, _res: Response, next: NextFunction): void {
|
||||
if (req.usuarioToken?.rol !== 'admin') {
|
||||
throw new AppError(403, 'Se requiere rol admin')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// ── GET /api/config/sistema ────────────────────────────────────────────
|
||||
|
||||
export async function obtenerConfigsEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
const configs = await configService.obtenerConfigs()
|
||||
res.json(configs)
|
||||
}
|
||||
|
||||
// ── PATCH /api/config/sistema ──────────────────────────────────────────
|
||||
|
||||
const actualizarConfigsSchema = z.record(z.string(), z.string())
|
||||
|
||||
export async function actualizarConfigsEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const parcial = actualizarConfigsSchema.parse(req.body)
|
||||
if (Object.keys(parcial).length === 0) throw new BadRequestError('Body vacío')
|
||||
await configService.actualizarConfigs(parcial)
|
||||
res.status(204).end()
|
||||
}
|
||||
|
||||
// ── GET /api/config/roles ──────────────────────────────────────────────
|
||||
|
||||
export async function obtenerRolesConfigEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
const roles = await configService.obtenerRolesConfig()
|
||||
// Parsear JSON para que el frontend reciba objetos
|
||||
res.json(
|
||||
roles.map((r) => ({
|
||||
rol: r.rol,
|
||||
modulos: JSON.parse(r.modulos) as string[],
|
||||
acciones: JSON.parse(r.acciones) as Record<string, boolean>,
|
||||
updatedAt: r.updatedAt,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
// ── PATCH /api/config/roles/:rol ───────────────────────────────────────
|
||||
|
||||
const actualizarRolSchema = z.object({
|
||||
modulos: z.array(z.string()),
|
||||
acciones: z.record(z.string(), z.boolean()),
|
||||
})
|
||||
|
||||
export async function actualizarRolConfigEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const rol = req.params.rol as string
|
||||
if (!rol) throw new BadRequestError('Falta el parámetro :rol')
|
||||
const body = actualizarRolSchema.parse(req.body)
|
||||
await configService.actualizarRolConfig(rol, body)
|
||||
res.status(204).end()
|
||||
}
|
||||
|
||||
// ── GET /api/config/usuarios ───────────────────────────────────────────
|
||||
|
||||
export async function obtenerUsuariosEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
const usuarios = await configService.obtenerUsuarios()
|
||||
res.json(usuarios)
|
||||
}
|
||||
|
||||
// ── PATCH /api/config/usuarios/:id ─────────────────────────────────────
|
||||
|
||||
const actualizarUsuarioSchema = z.object({
|
||||
rol: z.string().optional(),
|
||||
asignacion: z.string().nullable().optional(),
|
||||
activo: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export async function actualizarUsuarioEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const id = req.params.id as string
|
||||
if (!id) throw new BadRequestError('Falta el parámetro :id')
|
||||
const parsed = actualizarUsuarioSchema.parse(req.body)
|
||||
// Filtra undefined para enviar solo campos con valor
|
||||
const body: { rol?: string; asignacion?: string | null; activo?: boolean } = {}
|
||||
if (parsed.rol !== undefined) body.rol = parsed.rol
|
||||
if (parsed.asignacion !== undefined) body.asignacion = parsed.asignacion
|
||||
if (parsed.activo !== undefined) body.activo = parsed.activo
|
||||
if (Object.keys(body).length === 0) throw new BadRequestError('Body vacío')
|
||||
await configService.actualizarUsuario(id, body)
|
||||
res.status(204).end()
|
||||
}
|
||||
|
||||
// ── POST /api/config/reset-roles ───────────────────────────────────────
|
||||
|
||||
export async function resetRolesEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
await configService.resetRoles()
|
||||
res.status(204).end()
|
||||
}
|
||||
|
||||
// ── GET /api/config/acciones ───────────────────────────────────────────
|
||||
|
||||
export async function obtenerAccionesEndpoint(req: Request, res: Response): Promise<void> {
|
||||
if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado')
|
||||
const acciones = await configService.obtenerAcciones(req.usuarioToken.rol)
|
||||
res.json(acciones)
|
||||
}
|
||||
@@ -93,6 +93,7 @@ describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () =
|
||||
asignacion: 'MONTEBELLO',
|
||||
mondayUserId: 13676,
|
||||
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
|
||||
acciones: [],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function actualizarVinculoMondayEndpoint(req: Request, res: Respons
|
||||
where: { id: req.usuarioToken.sub },
|
||||
data: { mondayUserId },
|
||||
})
|
||||
res.json(aPublico(usuario))
|
||||
res.json(await aPublico(usuario))
|
||||
}
|
||||
|
||||
const cambiarRolSchema = z.object({
|
||||
@@ -65,5 +65,5 @@ export async function actualizarRolEndpoint(req: Request, res: Response): Promis
|
||||
where: { id: req.params.id as string },
|
||||
data: { rol },
|
||||
})
|
||||
res.json(aPublico(usuario))
|
||||
res.json(await aPublico(usuario))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import cron from 'node-cron'
|
||||
import { crearApp } from './app.js'
|
||||
import { config } from './config/index.js'
|
||||
import { automatizacionesService } from './services/automatizaciones.service.js'
|
||||
import * as configService from './services/config.service.js'
|
||||
import { mondayService } from './services/monday.service.js'
|
||||
import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js'
|
||||
import { saludMonday } from './services/salud-monday.service.js'
|
||||
@@ -129,6 +130,11 @@ if (config.automatizaciones.enabled) {
|
||||
console.log(`Recordatorios de cobranza activos (cron: "${config.recordatoriosCobranza.cron}")`)
|
||||
}
|
||||
|
||||
// Precarga de configuraciones desde DB (modo_local_forzado, etc.)
|
||||
void configService.refrescarCache().catch((err: unknown) =>
|
||||
console.error('[startup] Error precargando config cache:', err),
|
||||
)
|
||||
|
||||
app.listen(config.port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`API Gateway FRASA escuchando en http://localhost:${config.port} (${config.nodeEnv})`)
|
||||
|
||||
@@ -82,6 +82,17 @@ import {
|
||||
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
|
||||
import { resumenReportes } from '../controllers/reportes.controller.js'
|
||||
import { estadoSistema } from '../controllers/sistema.controller.js'
|
||||
import {
|
||||
requireAdmin,
|
||||
obtenerConfigsEndpoint,
|
||||
actualizarConfigsEndpoint,
|
||||
obtenerRolesConfigEndpoint,
|
||||
actualizarRolConfigEndpoint,
|
||||
obtenerUsuariosEndpoint,
|
||||
actualizarUsuarioEndpoint,
|
||||
resetRolesEndpoint,
|
||||
obtenerAccionesEndpoint,
|
||||
} from '../controllers/config.controller.js'
|
||||
|
||||
export const apiRouter = Router()
|
||||
|
||||
@@ -190,3 +201,13 @@ apiRouter.get('/ai/conversations/:id', obtenerConversacion)
|
||||
apiRouter.post('/ai/documents', subirDocumento)
|
||||
apiRouter.get('/ai/documents', listarDocumentosIA)
|
||||
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
|
||||
|
||||
// ── Configuración (admin) ──────────────────────────────────────────────
|
||||
apiRouter.get('/config/sistema', requireAdmin, obtenerConfigsEndpoint)
|
||||
apiRouter.patch('/config/sistema', requireAdmin, actualizarConfigsEndpoint)
|
||||
apiRouter.get('/config/roles', requireAdmin, obtenerRolesConfigEndpoint)
|
||||
apiRouter.patch('/config/roles/:rol', requireAdmin, actualizarRolConfigEndpoint)
|
||||
apiRouter.get('/config/usuarios', requireAdmin, obtenerUsuariosEndpoint)
|
||||
apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint)
|
||||
apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint)
|
||||
apiRouter.get('/config/acciones', obtenerAccionesEndpoint)
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('login', () => {
|
||||
asignacion: 'MONTEBELLO',
|
||||
mondayUserId: null,
|
||||
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
|
||||
acciones: [],
|
||||
})
|
||||
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { prisma } from './prisma.js'
|
||||
import { config } from '../config/index.js'
|
||||
import { modulosDeRol, type Modulo } from '../config/roles.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
import * as configService from './config.service.js'
|
||||
|
||||
export interface UsuarioPublico {
|
||||
nombre: string
|
||||
@@ -14,6 +15,8 @@ export interface UsuarioPublico {
|
||||
mondayUserId: number | null
|
||||
// Módulos del ecosistema visibles para el rol (matriz de config/roles.ts)
|
||||
modulos: Modulo[]
|
||||
// Acciones granulares del rol (catálogo de config/acciones.ts)
|
||||
acciones: string[]
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
@@ -34,13 +37,23 @@ export function normalizarUsername(nombre: string): string {
|
||||
.replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
export function aPublico(usuario: Usuario): UsuarioPublico {
|
||||
export async function aPublico(usuario: Usuario): Promise<UsuarioPublico> {
|
||||
let acciones: string[] = []
|
||||
try {
|
||||
const acc = await configService.obtenerAcciones(usuario.rol)
|
||||
acciones = Object.entries(acc)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
} catch {
|
||||
// Si falla la lectura de config, devuelve acciones vacías (no rompe)
|
||||
}
|
||||
return {
|
||||
nombre: usuario.nombre,
|
||||
rol: usuario.rol,
|
||||
asignacion: usuario.asignacion,
|
||||
mondayUserId: usuario.mondayUserId,
|
||||
modulos: modulosDeRol(usuario.rol),
|
||||
modulos: await modulosDeRol(usuario.rol),
|
||||
acciones,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +77,7 @@ export async function login(username: string, password: string): Promise<LoginRe
|
||||
config.auth.jwtSecret,
|
||||
{ expiresIn: config.auth.jwtExpiresIn } as jwt.SignOptions,
|
||||
)
|
||||
return { token, usuario: aPublico(usuario) }
|
||||
return { token, usuario: await aPublico(usuario) }
|
||||
}
|
||||
|
||||
/** Payload del JWT emitido por login() */
|
||||
|
||||
235
api-gateway/src/services/config.service.test.ts
Normal file
235
api-gateway/src/services/config.service.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ---------- Prisma mockeado ----------
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
configuracionSistema: {
|
||||
findMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
rolConfiguracion: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
usuario: {
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
||||
}))
|
||||
|
||||
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import {
|
||||
obtenerConfigs,
|
||||
actualizarConfigs,
|
||||
obtenerConfig,
|
||||
obtenerConfigSync,
|
||||
refrescarCache,
|
||||
resetCacheForTesting,
|
||||
obtenerRolesConfig,
|
||||
obtenerRolConfig,
|
||||
actualizarRolConfig,
|
||||
resetRoles,
|
||||
obtenerAcciones,
|
||||
obtenerUsuarios,
|
||||
actualizarUsuario,
|
||||
} from './config.service.js'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetCacheForTesting()
|
||||
})
|
||||
|
||||
// ── ConfiguracionSistema ───────────────────────────────────────────────
|
||||
|
||||
describe('obtenerConfigs', () => {
|
||||
it('devuelve todas las configs como Record<clave, valor>', async () => {
|
||||
prismaMock.configuracionSistema.findMany.mockResolvedValue([
|
||||
{ clave: 'a', valor: '1', updatedAt: new Date() },
|
||||
{ clave: 'b', valor: '2', updatedAt: new Date() },
|
||||
])
|
||||
const result = await obtenerConfigs()
|
||||
expect(result).toEqual({ a: '1', b: '2' })
|
||||
})
|
||||
|
||||
it('devuelve objeto vacío si no hay configs', async () => {
|
||||
prismaMock.configuracionSistema.findMany.mockResolvedValue([])
|
||||
expect(await obtenerConfigs()).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('actualizarConfigs', () => {
|
||||
it('upsert cada clave y refresca el cache', async () => {
|
||||
prismaMock.configuracionSistema.findMany.mockResolvedValue([])
|
||||
prismaMock.configuracionSistema.upsert.mockResolvedValue({})
|
||||
await actualizarConfigs({ x: '1', y: '2' })
|
||||
expect(prismaMock.configuracionSistema.upsert).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('obtenerConfig + obtenerConfigSync (cache)', () => {
|
||||
it('obtenerConfig consulta DB en primera llamada y cachea', async () => {
|
||||
prismaMock.configuracionSistema.findMany.mockResolvedValue([
|
||||
{ clave: 'cache_k', valor: 'cache_v', updatedAt: new Date() },
|
||||
])
|
||||
const callsBefore = prismaMock.configuracionSistema.findMany.mock.calls.length
|
||||
const v1 = await obtenerConfig('cache_k')
|
||||
expect(v1).toBe('cache_v')
|
||||
expect(prismaMock.configuracionSistema.findMany.mock.calls.length).toBe(callsBefore + 1)
|
||||
|
||||
// Segunda llamada usa cache (no vuelve a consultar DB)
|
||||
const v2 = await obtenerConfig('cache_k')
|
||||
expect(v2).toBe('cache_v')
|
||||
expect(prismaMock.configuracionSistema.findMany.mock.calls.length).toBe(callsBefore + 1)
|
||||
})
|
||||
|
||||
it('obtenerConfigSync lee del cache si ya fue inicializado', async () => {
|
||||
prismaMock.configuracionSistema.findMany.mockResolvedValue([
|
||||
{ clave: 'sync_k', valor: 'sync_v', updatedAt: new Date() },
|
||||
])
|
||||
await refrescarCache()
|
||||
expect(obtenerConfigSync('sync_k')).toBe('sync_v')
|
||||
})
|
||||
|
||||
it('refrescarCache recarga desde DB', async () => {
|
||||
prismaMock.configuracionSistema.findMany.mockResolvedValue([
|
||||
{ clave: 'nuevo', valor: 'si', updatedAt: new Date() },
|
||||
])
|
||||
await refrescarCache()
|
||||
expect(obtenerConfigSync('nuevo')).toBe('si')
|
||||
})
|
||||
})
|
||||
|
||||
// ── RolConfiguracion ───────────────────────────────────────────────────
|
||||
|
||||
describe('obtenerRolesConfig', () => {
|
||||
it('devuelve todos los roles ordenados', async () => {
|
||||
prismaMock.rolConfiguracion.findMany.mockResolvedValue([
|
||||
{ rol: 'admin', modulos: '[]', acciones: '{}', updatedAt: new Date() },
|
||||
])
|
||||
const result = await obtenerRolesConfig()
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.rol).toBe('admin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('obtenerRolConfig', () => {
|
||||
it('parsea JSON de modulos y acciones', async () => {
|
||||
prismaMock.rolConfiguracion.findUnique.mockResolvedValue({
|
||||
rol: 'vendedor',
|
||||
modulos: JSON.stringify(['mi-dia', 'cotizador']),
|
||||
acciones: JSON.stringify({ descartar_lead: true }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
const result = await obtenerRolConfig('vendedor')
|
||||
expect(result).toEqual({
|
||||
modulos: ['mi-dia', 'cotizador'],
|
||||
acciones: { descartar_lead: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('devuelve null si el rol no existe', async () => {
|
||||
prismaMock.rolConfiguracion.findUnique.mockResolvedValue(null)
|
||||
expect(await obtenerRolConfig('inexistente')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('actualizarRolConfig', () => {
|
||||
it('upsert con JSON serializado', async () => {
|
||||
prismaMock.rolConfiguracion.upsert.mockResolvedValue({})
|
||||
await actualizarRolConfig('admin', {
|
||||
modulos: ['all'],
|
||||
acciones: { todo: true },
|
||||
})
|
||||
expect(prismaMock.rolConfiguracion.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { rol: 'admin' },
|
||||
create: expect.objectContaining({
|
||||
modulos: JSON.stringify(['all']),
|
||||
acciones: JSON.stringify({ todo: true }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetRoles', () => {
|
||||
it('borra todas las configs y re-siembra desde MATRIZ + defaults', async () => {
|
||||
prismaMock.rolConfiguracion.deleteMany.mockResolvedValue({ count: 0 })
|
||||
prismaMock.rolConfiguracion.create.mockResolvedValue({})
|
||||
await resetRoles()
|
||||
expect(prismaMock.rolConfiguracion.deleteMany).toHaveBeenCalled()
|
||||
// MATRIZ tiene 6 roles
|
||||
expect(prismaMock.rolConfiguracion.create).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Acciones por rol ───────────────────────────────────────────────────
|
||||
|
||||
describe('obtenerAcciones', () => {
|
||||
it('lee de DB si existe el rol', async () => {
|
||||
prismaMock.rolConfiguracion.findUnique.mockResolvedValue({
|
||||
rol: 'vendedor',
|
||||
modulos: '[]',
|
||||
acciones: JSON.stringify({ descartar_lead: true, ver_reportes: false }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
const acc = await obtenerAcciones('vendedor')
|
||||
expect(acc.descartar_lead).toBe(true)
|
||||
expect(acc.ver_reportes).toBe(false)
|
||||
})
|
||||
|
||||
it('fallback a defaults si el rol no existe en DB', async () => {
|
||||
prismaMock.rolConfiguracion.findUnique.mockResolvedValue(null)
|
||||
const acc = await obtenerAcciones('vendedor')
|
||||
expect(acc.descartar_lead).toBe(true)
|
||||
expect(acc.ver_reportes).toBe(false)
|
||||
})
|
||||
|
||||
it('fallback a defaults si JSON está corrupto', async () => {
|
||||
prismaMock.rolConfiguracion.findUnique.mockResolvedValue({
|
||||
rol: 'vendedor',
|
||||
modulos: '[]',
|
||||
acciones: 'no-json',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
const acc = await obtenerAcciones('vendedor')
|
||||
expect(acc.descartar_lead).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Usuarios ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('obtenerUsuarios', () => {
|
||||
it('devuelve usuarios sin passwordHash', async () => {
|
||||
prismaMock.usuario.findMany.mockResolvedValue([
|
||||
{ id: 'u1', username: 'a', nombre: 'A', rol: 'vendedor', asignacion: null, activo: true, mondayUserId: null },
|
||||
])
|
||||
const result = await obtenerUsuarios()
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).not.toHaveProperty('passwordHash')
|
||||
})
|
||||
})
|
||||
|
||||
describe('actualizarUsuario', () => {
|
||||
it('actualiza solo los campos enviados', async () => {
|
||||
prismaMock.usuario.update.mockResolvedValue({})
|
||||
await actualizarUsuario('u1', { rol: 'admin' })
|
||||
expect(prismaMock.usuario.update).toHaveBeenCalledWith({
|
||||
where: { id: 'u1' },
|
||||
data: { rol: 'admin' },
|
||||
})
|
||||
})
|
||||
|
||||
it('actualiza asignacion y activo', async () => {
|
||||
prismaMock.usuario.update.mockResolvedValue({})
|
||||
await actualizarUsuario('u1', { asignacion: null, activo: false })
|
||||
expect(prismaMock.usuario.update).toHaveBeenCalledWith({
|
||||
where: { id: 'u1' },
|
||||
data: { asignacion: null, activo: false },
|
||||
})
|
||||
})
|
||||
})
|
||||
194
api-gateway/src/services/config.service.ts
Normal file
194
api-gateway/src/services/config.service.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Servicio de configuración del sistema.
|
||||
*
|
||||
* Lee/escribe las tablas ConfiguracionSistema y RolConfiguracion, y opera
|
||||
* sobre usuarios (rol, asignación, activo). Singleton con cache de 30 s en
|
||||
* `obtenerConfig` para no golpear la DB en cada request.
|
||||
*/
|
||||
|
||||
import { prisma } from './prisma.js'
|
||||
import { accionesPorRol, type AccionClave } from '../config/acciones.js'
|
||||
import { MATRIZ } from '../config/roles.js'
|
||||
import type { Modulo } from '../config/roles.js'
|
||||
|
||||
// ── Cache de 30 s para obtenerConfig ────────────────────────────────────
|
||||
const CACHE_TTL_MS = 30_000
|
||||
let cacheMap: Record<string, string> | null = null
|
||||
let cacheExpiresAt = 0
|
||||
|
||||
function invalidarCache(): void {
|
||||
cacheMap = null
|
||||
cacheExpiresAt = 0
|
||||
}
|
||||
|
||||
// ── ConfiguracionSistema ───────────────────────────────────────────────
|
||||
|
||||
/** Todas las configs como Record<clave, valor> */
|
||||
export async function obtenerConfigs(): Promise<Record<string, string>> {
|
||||
const rows = await prisma.configuracionSistema.findMany()
|
||||
const result: Record<string, string> = {}
|
||||
for (const r of rows) result[r.clave] = r.valor
|
||||
return result
|
||||
}
|
||||
|
||||
/** Upsert parcial de configs (solo las claves enviadas) */
|
||||
export async function actualizarConfigs(parcial: Record<string, string>): Promise<void> {
|
||||
await prisma.$transaction(
|
||||
Object.entries(parcial).map(([clave, valor]) =>
|
||||
prisma.configuracionSistema.upsert({
|
||||
where: { clave },
|
||||
create: { clave, valor },
|
||||
update: { valor },
|
||||
}),
|
||||
),
|
||||
)
|
||||
await refrescarCache()
|
||||
}
|
||||
|
||||
/** Una config individual (cache 30 s). Devuelve null si la clave no existe. */
|
||||
export async function obtenerConfig(clave: string): Promise<string | null> {
|
||||
const now = Date.now()
|
||||
if (cacheMap && now < cacheExpiresAt) return cacheMap[clave] ?? null
|
||||
|
||||
cacheMap = await obtenerConfigs()
|
||||
cacheExpiresAt = now + CACHE_TTL_MS
|
||||
return cacheMap[clave] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Versión síncrona de obtenerConfig: lee del cache en memoria.
|
||||
* Devuelve undefined si el cache no ha sido inicializado aún (el caller
|
||||
* debe usar el fallback apropiado, p. ej. process.env).
|
||||
* Se alimenta con `refrescarCache()` (llamar en startup y al cambiar configs).
|
||||
*/
|
||||
export function obtenerConfigSync(clave: string): string | undefined {
|
||||
if (!cacheMap) return undefined
|
||||
return cacheMap[clave]
|
||||
}
|
||||
|
||||
/** Fuerza la recarga del cache desde la DB (llamar en startup y al guardar configs) */
|
||||
export async function refrescarCache(): Promise<void> {
|
||||
cacheMap = await obtenerConfigs()
|
||||
cacheExpiresAt = Date.now() + CACHE_TTL_MS
|
||||
}
|
||||
|
||||
/** Resetea el cache (para tests) */
|
||||
export function resetCacheForTesting(): void {
|
||||
cacheMap = null
|
||||
cacheExpiresAt = 0
|
||||
}
|
||||
|
||||
// ── RolConfiguracion ───────────────────────────────────────────────────
|
||||
|
||||
export interface RolConfigData {
|
||||
modulos: string[]
|
||||
acciones: Record<string, boolean>
|
||||
}
|
||||
|
||||
/** Todas las configs de rol (sin parsear JSON) */
|
||||
export async function obtenerRolesConfig() {
|
||||
return prisma.rolConfiguracion.findMany({ orderBy: { rol: 'asc' } })
|
||||
}
|
||||
|
||||
/** Config de un rol (JSON parseado) */
|
||||
export async function obtenerRolConfig(rol: string): Promise<RolConfigData | null> {
|
||||
const row = await prisma.rolConfiguracion.findUnique({ where: { rol } })
|
||||
if (!row) return null
|
||||
return {
|
||||
modulos: JSON.parse(row.modulos) as string[],
|
||||
acciones: JSON.parse(row.acciones) as Record<string, boolean>,
|
||||
}
|
||||
}
|
||||
|
||||
/** Upsert de módulos y acciones de un rol */
|
||||
export async function actualizarRolConfig(
|
||||
rol: string,
|
||||
data: { modulos: string[]; acciones: Record<string, boolean> },
|
||||
): Promise<void> {
|
||||
await prisma.rolConfiguracion.upsert({
|
||||
where: { rol },
|
||||
create: {
|
||||
rol,
|
||||
modulos: JSON.stringify(data.modulos),
|
||||
acciones: JSON.stringify(data.acciones),
|
||||
},
|
||||
update: {
|
||||
modulos: JSON.stringify(data.modulos),
|
||||
acciones: JSON.stringify(data.acciones),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Borra todas las RolConfiguracion y re-siembra desde MATRIZ + ACCIONES_POR_ROL_DEFAULT */
|
||||
export async function resetRoles(): Promise<void> {
|
||||
await prisma.rolConfiguracion.deleteMany()
|
||||
|
||||
const entries = Object.entries(MATRIZ) as [string, readonly Modulo[]][]
|
||||
await prisma.$transaction(
|
||||
entries.map(([rol, modulos]) => {
|
||||
const acciones = accionesPorRol(rol)
|
||||
return prisma.rolConfiguracion.create({
|
||||
data: {
|
||||
rol,
|
||||
modulos: JSON.stringify([...modulos]),
|
||||
acciones: JSON.stringify(acciones),
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Acciones por rol ───────────────────────────────────────────────────
|
||||
|
||||
/** Las acciones booleanas de un rol (lee de DB; fallback a defaults) */
|
||||
export async function obtenerAcciones(rol: string): Promise<Record<AccionClave, boolean>> {
|
||||
const row = await prisma.rolConfiguracion.findUnique({ where: { rol } })
|
||||
if (!row) return accionesPorRol(rol)
|
||||
try {
|
||||
return JSON.parse(row.acciones) as Record<AccionClave, boolean>
|
||||
} catch {
|
||||
return accionesPorRol(rol)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Usuarios ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface UsuarioListado {
|
||||
id: string
|
||||
username: string
|
||||
nombre: string
|
||||
rol: string
|
||||
asignacion: string | null
|
||||
activo: boolean
|
||||
mondayUserId: number | null
|
||||
}
|
||||
|
||||
/** Lista de usuarios sin passwordHash */
|
||||
export async function obtenerUsuarios(): Promise<UsuarioListado[]> {
|
||||
const rows = await prisma.usuario.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nombre: true,
|
||||
rol: true,
|
||||
asignacion: true,
|
||||
activo: true,
|
||||
mondayUserId: true,
|
||||
},
|
||||
orderBy: { nombre: 'asc' },
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Actualiza rol, asignación y/o activo de un usuario */
|
||||
export async function actualizarUsuario(
|
||||
id: string,
|
||||
data: { rol?: string; asignacion?: string | null; activo?: boolean },
|
||||
): Promise<void> {
|
||||
// Filtra undefined para que Prisma solo actualice los campos enviados
|
||||
const clean: Record<string, unknown> = {}
|
||||
if (data.rol !== undefined) clean.rol = data.rol
|
||||
if (data.asignacion !== undefined) clean.asignacion = data.asignacion
|
||||
if (data.activo !== undefined) clean.activo = data.activo
|
||||
await prisma.usuario.update({ where: { id }, data: clean as never })
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
* local) en lugar de pagar los reintentos por request; un cron sondea cada 60 s
|
||||
* y al recuperarse dispara el sync worker.
|
||||
*
|
||||
* MODO_LOCAL_FORZADO=true fuerza modo local aunque Monday responda (pruebas).
|
||||
* MODO_LOCAL_FORZADO=true (env var o DB) fuerza modo local aunque Monday
|
||||
* responda (pruebas / toggle admin). La DB se cachea 30 s en configService.
|
||||
*
|
||||
* El ping se inyecta con `registrarPing()` (wiring en index.ts) para no
|
||||
* importar monday.service aquí y evitar ciclos de imports.
|
||||
@@ -45,9 +46,24 @@ export class SaludMondayService {
|
||||
return this.mondayOk
|
||||
}
|
||||
|
||||
/** true si los callers pueden hablar con Monday ahora mismo */
|
||||
/**
|
||||
* true si los callers pueden hablar con Monday ahora mismo.
|
||||
* Lee modo_local_forzado de la DB (via cache 30 s en configService) y de
|
||||
* process.env como fallback. Síncrono: la DB se precarga al iniciar y se
|
||||
* invalida al cambiar configs.
|
||||
*/
|
||||
estaDisponible(): boolean {
|
||||
return this.mondayOk && process.env.MODO_LOCAL_FORZADO !== 'true'
|
||||
// Lazy import para no crear ciclos en tests (configService importa prisma)
|
||||
let forzadoDb = false
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const cs = require('./config.service.js') as typeof import('./config.service.js')
|
||||
forzadoDb = cs.obtenerConfigSync('modo_local_forzado') === 'true'
|
||||
} catch {
|
||||
// configService no disponible (tests aislados, DB no inicializada)
|
||||
}
|
||||
const forzadoEnv = process.env.MODO_LOCAL_FORZADO === 'true'
|
||||
return this.mondayOk && !forzadoDb && !forzadoEnv
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface UsuarioSesion {
|
||||
nombre: string
|
||||
rol: string
|
||||
asignacion: string | null
|
||||
acciones?: string[]
|
||||
}
|
||||
|
||||
export interface Sesion {
|
||||
|
||||
@@ -5,27 +5,33 @@ import { TarjetaModulo } from './components/TarjetaModulo'
|
||||
import { ConfiguracionCuenta } from './components/ConfiguracionCuenta'
|
||||
import BannerModoLocal from './components/BannerModoLocal'
|
||||
import { VistaContactos } from './components/VistaContactos'
|
||||
import { getToken } from './services/auth'
|
||||
import { ConfigPage } from './components/config/ConfigPage'
|
||||
import { getToken, type UsuarioSesion } from './services/auth'
|
||||
import { getMe } from './services/api'
|
||||
|
||||
type Vista = 'home' | 'contactos'
|
||||
type Vista = 'home' | 'contactos' | 'configuraciones'
|
||||
|
||||
export default function App() {
|
||||
const [vista, setVista] = useState<Vista>('home')
|
||||
// Módulos permitidos por el rol (GET /auth/me); null = sin sesión → se muestran todos
|
||||
const [permitidos, setPermitidos] = useState<string[] | null>(null)
|
||||
const [usuario, setUsuario] = useState<UsuarioSesion | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelado = false
|
||||
const cargar = () => {
|
||||
if (!getToken()) {
|
||||
setPermitidos(null)
|
||||
setUsuario(null)
|
||||
return
|
||||
}
|
||||
// Si /me falla se conserva lo anterior (o todos): el login wall de cada app sigue
|
||||
getMe()
|
||||
.then((usuario) => {
|
||||
if (!cancelado) setPermitidos(usuario.modulos ?? [])
|
||||
.then((u) => {
|
||||
if (!cancelado) {
|
||||
setPermitidos(u.modulos ?? [])
|
||||
setUsuario(u)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
@@ -40,16 +46,26 @@ export default function App() {
|
||||
if (vista === 'contactos') {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
|
||||
<BannerModoLocal />
|
||||
<VistaContactos onVolver={() => setVista('home')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (vista === 'configuraciones' && usuario?.rol === 'admin') {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
|
||||
<BannerModoLocal />
|
||||
<ConfigPage onVolver={() => setVista('home')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
|
||||
<BannerModoLocal />
|
||||
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-6">
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getSesion } from '../services/auth'
|
||||
import { getSesion, type UsuarioSesion } from '../services/auth'
|
||||
|
||||
interface Props {
|
||||
usuario?: UsuarioSesion | null
|
||||
onConfiguraciones?: () => void
|
||||
}
|
||||
|
||||
/** Barra superior: marca a la izquierda, estado de sesión y acceso a configuración a la derecha */
|
||||
export function Navbar() {
|
||||
export function Navbar({ usuario, onConfiguraciones }: Props) {
|
||||
const [conSesion, setConSesion] = useState(() => getSesion() !== null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -15,6 +20,8 @@ export function Navbar() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const esAdmin = usuario?.rol === 'admin'
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex h-14 max-w-[1280px] items-center justify-between px-6">
|
||||
@@ -33,6 +40,17 @@ export function Navbar() {
|
||||
/>
|
||||
{conSesion ? 'Sesión activa' : 'Sin sesión'}
|
||||
</span>
|
||||
{esAdmin && onConfiguraciones && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfiguraciones}
|
||||
title="Configuraciones"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-[#64748B] transition-colors hover:bg-[#F1F5F9] hover:text-accent"
|
||||
aria-label="Abrir configuraciones"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href="#configuracion"
|
||||
className="text-sm font-medium text-[#64748B] transition-colors hover:text-accent"
|
||||
|
||||
72
apps/landing/src/components/config/ConfigPage.test.tsx
Normal file
72
apps/landing/src/components/config/ConfigPage.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ConfigPage } from './ConfigPage'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
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 respuestaOk = (data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('localStorage', crearStorageStub())
|
||||
// Mockear las llamadas iniciales que los tabs pueden hacer
|
||||
fetchMock.mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: true, pendientesSync: 0 }))
|
||||
if (url.endsWith('/config/sistema')) return Promise.resolve(respuestaOk({ modo_local_forzado: 'false' }))
|
||||
if (url.endsWith('/config/roles')) return Promise.resolve(respuestaOk([]))
|
||||
if (url.endsWith('/config/usuarios')) return Promise.resolve(respuestaOk([]))
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ConfigPage', () => {
|
||||
it('renderiza los 4 tabs y muestra "Modo de trabajo" por defecto', () => {
|
||||
render(<ConfigPage onVolver={() => {}} />)
|
||||
expect(screen.getByText('Configuraciones')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Modo de trabajo' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Roles y permisos' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Usuarios' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Sistema' })).toBeTruthy()
|
||||
// El tab de "Modo de trabajo" está activo por defecto
|
||||
expect(screen.getByText('Modo de trabajo')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('al hacer clic en un tab, muestra su contenido', async () => {
|
||||
render(<ConfigPage onVolver={() => {}} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Roles y permisos' }))
|
||||
expect(await screen.findByText('Guardar cambios')).toBeTruthy()
|
||||
expect(screen.getByText('Restaurar valores por defecto')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('al hacer clic en "Volver", ejecuta el callback', () => {
|
||||
const volver = vi.fn()
|
||||
render(<ConfigPage onVolver={volver} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /volver/i }))
|
||||
expect(volver).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
68
apps/landing/src/components/config/ConfigPage.tsx
Normal file
68
apps/landing/src/components/config/ConfigPage.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react'
|
||||
import { ModoTrabajo } from './ModoTrabajo'
|
||||
import { RolesPermisos } from './RolesPermisos'
|
||||
import { Usuarios } from './Usuarios'
|
||||
import { Sistema } from './Sistema'
|
||||
|
||||
interface Props {
|
||||
onVolver: () => void
|
||||
}
|
||||
|
||||
type Tab = 'modo' | 'roles' | 'usuarios' | 'sistema'
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: 'modo', label: 'Modo de trabajo' },
|
||||
{ key: 'roles', label: 'Roles y permisos' },
|
||||
{ key: 'usuarios', label: 'Usuarios' },
|
||||
{ key: 'sistema', label: 'Sistema' },
|
||||
]
|
||||
|
||||
export function ConfigPage({ onVolver }: Props) {
|
||||
const [tab, setTab] = useState<Tab>('modo')
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-6 py-12 sm:py-16">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVolver}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
← Volver
|
||||
</button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl">
|
||||
Configuraciones
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<nav aria-label="Tabs de configuraciones" className="mt-8">
|
||||
<ul className="flex gap-1 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-1">
|
||||
{TABS.map((t) => (
|
||||
<li key={t.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
tab === t.key
|
||||
? 'bg-white text-[#111827] shadow-sm'
|
||||
: 'text-[#64748B] hover:text-[#374151]'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Contenido del tab activo */}
|
||||
<section className="mt-6">
|
||||
{tab === 'modo' && <ModoTrabajo />}
|
||||
{tab === 'roles' && <RolesPermisos />}
|
||||
{tab === 'usuarios' && <Usuarios />}
|
||||
{tab === 'sistema' && <Sistema />}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
85
apps/landing/src/components/config/ModoTrabajo.test.tsx
Normal file
85
apps/landing/src/components/config/ModoTrabajo.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { ModoTrabajo } from './ModoTrabajo'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
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 respuestaOk = (data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('localStorage', crearStorageStub())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ModoTrabajo', () => {
|
||||
it('muestra el estado de Monday y el toggle', async () => {
|
||||
fetchMock.mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: true, pendientesSync: 0 }))
|
||||
if (url.includes('/config/sistema')) return Promise.resolve(respuestaOk({ modo_local_forzado: 'false' }))
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
})
|
||||
|
||||
render(<ModoTrabajo />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Conectado|No disponible/)).toBeTruthy()
|
||||
}, { timeout: 5000 })
|
||||
expect(screen.getByRole('switch', { name: /cambiar modo de trabajo/i })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('al hacer clic en el toggle, hace PATCH y actualiza el texto', async () => {
|
||||
fetchMock.mockImplementation((input, init) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: true, pendientesSync: 0 }))
|
||||
if (url.includes('/config/sistema') && (!init?.method || init.method === 'GET')) {
|
||||
return Promise.resolve(respuestaOk({ modo_local_forzado: 'false' }))
|
||||
}
|
||||
if (url.includes('/config/sistema') && init?.method === 'PATCH') {
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
}
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
})
|
||||
|
||||
render(<ModoTrabajo />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('switch', { name: /cambiar modo de trabajo/i })).toBeTruthy()
|
||||
}, { timeout: 5000 })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: /cambiar modo de trabajo/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
const llamadaPatch = fetchMock.mock.calls.find(
|
||||
([url, init]) => String(url).includes('/config/sistema') && init?.method === 'PATCH',
|
||||
)
|
||||
expect(llamadaPatch).toBeTruthy()
|
||||
}, { timeout: 5000 })
|
||||
|
||||
expect(await screen.findByText('Modo local activado')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
155
apps/landing/src/components/config/ModoTrabajo.tsx
Normal file
155
apps/landing/src/components/config/ModoTrabajo.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchEstadoSistema, type EstadoSistema } from '../../services/sistema'
|
||||
import { getConfigSistema, patchConfigSistema } from '../../services/config'
|
||||
import { API_URL, conAuth } from '../../services/auth'
|
||||
|
||||
export function ModoTrabajo() {
|
||||
const [estado, setEstado] = useState<EstadoSistema | null>(null)
|
||||
const [modoLocal, setModoLocal] = useState(false)
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [guardando, setGuardando] = useState(false)
|
||||
const [aviso, setAviso] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true
|
||||
const cargar = async () => {
|
||||
setCargando(true)
|
||||
try {
|
||||
const [estadoRes, configRes] = await Promise.all([
|
||||
fetchEstadoSistema(),
|
||||
getConfigSistema(),
|
||||
])
|
||||
if (!activo) return
|
||||
setEstado(estadoRes)
|
||||
setModoLocal(configRes.modo_local_forzado === 'true')
|
||||
} catch {
|
||||
if (activo) setError('No se pudo cargar el estado del sistema.')
|
||||
} finally {
|
||||
if (activo) setCargando(false)
|
||||
}
|
||||
}
|
||||
void cargar()
|
||||
return () => { activo = false }
|
||||
}, [])
|
||||
|
||||
const toggleModo = async () => {
|
||||
const nuevoModo = !modoLocal
|
||||
setGuardando(true)
|
||||
setAviso('')
|
||||
setError('')
|
||||
try {
|
||||
await patchConfigSistema({ modo_local_forzado: String(nuevoModo) })
|
||||
setModoLocal(nuevoModo)
|
||||
setAviso(nuevoModo ? 'Modo local activado' : 'Modo vinculado a Monday restaurado')
|
||||
} catch {
|
||||
setError('No se pudo actualizar el modo de trabajo.')
|
||||
} finally {
|
||||
setGuardando(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reconectar = async () => {
|
||||
setGuardando(true)
|
||||
setAviso('')
|
||||
setError('')
|
||||
try {
|
||||
// Intenta reconectar — si el endpoint no existe, simplemente informa
|
||||
const res = await fetch(`${API_URL}/sistema/reconexion`, conAuth({ method: 'POST' }))
|
||||
if (res.ok) {
|
||||
setAviso('Reconexión forzada. El estado se actualizará en unos segundos.')
|
||||
// Recargar estado después de un breve delay
|
||||
setTimeout(async () => {
|
||||
const nuevoEstado = await fetchEstadoSistema()
|
||||
setEstado(nuevoEstado)
|
||||
}, 3000)
|
||||
} else {
|
||||
setAviso('El endpoint de reconexión no está disponible aún.')
|
||||
}
|
||||
} catch {
|
||||
setAviso('El endpoint de reconexión no está disponible aún.')
|
||||
} finally {
|
||||
setGuardando(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (cargando) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<p className="text-sm text-[#64748B]">Cargando estado del sistema…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-[#111827]">Modo de trabajo</h2>
|
||||
|
||||
{/* Indicador del estado de Monday */}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2.5 w-2.5 rounded-full ${estado?.mondayOk ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
/>
|
||||
<span className="text-sm text-[#64748B]">
|
||||
Monday: {estado?.mondayOk ? 'Conectado' : 'No disponible'}
|
||||
{estado && ` · ${estado.pendientesSync} pendiente(s) de sincronizar`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Toggle de modo */}
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleModo()}
|
||||
disabled={guardando}
|
||||
className={`relative inline-flex h-8 w-14 flex-shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-accent/20 disabled:opacity-50 ${
|
||||
modoLocal ? 'bg-amber-500' : 'bg-accent'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={modoLocal}
|
||||
aria-label="Cambiar modo de trabajo"
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-6 w-6 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
||||
modoLocal ? 'translate-x-6' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#111827]">
|
||||
{modoLocal ? 'Modo local (sin Monday)' : 'Vinculado a Monday'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-[#64748B]">
|
||||
{modoLocal
|
||||
? 'Las capturas se guardan localmente y se sincronizarán cuando Monday esté disponible.'
|
||||
: 'Los leads, actividades y contactos se sincronizan con Monday en tiempo real.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botón reconectar */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void reconectar()}
|
||||
disabled={guardando}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-50"
|
||||
>
|
||||
{guardando ? 'Procesando…' : 'Forzar reconexión'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{aviso && (
|
||||
<p role="status" className="mt-4 text-sm font-medium text-green-600">
|
||||
{aviso}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="mt-4 text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
apps/landing/src/components/config/RolesPermisos.test.tsx
Normal file
85
apps/landing/src/components/config/RolesPermisos.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { RolesPermisos } from './RolesPermisos'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
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 ROLES_FAKE = [
|
||||
{
|
||||
rol: 'vendedor',
|
||||
modulos: JSON.stringify(['mi-dia', 'cotizador']),
|
||||
acciones: JSON.stringify({ descartar_lead: true, ver_precios: false }),
|
||||
},
|
||||
]
|
||||
|
||||
const respuestaOk = (data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('localStorage', crearStorageStub())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('RolesPermisos', () => {
|
||||
it('muestra la tabla de roles con los checkboxes correctos', async () => {
|
||||
fetchMock.mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/config/roles')) return Promise.resolve(respuestaOk(ROLES_FAKE))
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
})
|
||||
|
||||
render(<RolesPermisos />)
|
||||
expect(await screen.findByText('vendedor')).toBeTruthy()
|
||||
// Botones de acción
|
||||
expect(screen.getByRole('button', { name: 'Guardar cambios' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Restaurar valores por defecto' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('al guardar, hace PATCH por cada rol modificado', async () => {
|
||||
fetchMock.mockImplementation((input, init) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/config/roles') && (!init?.method || init.method === 'GET')) {
|
||||
return Promise.resolve(respuestaOk(ROLES_FAKE))
|
||||
}
|
||||
if (url.endsWith('/config/roles/vendedor') && init?.method === 'PATCH') {
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
}
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
})
|
||||
|
||||
render(<RolesPermisos />)
|
||||
await screen.findByText('vendedor')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Guardar cambios' }))
|
||||
|
||||
await waitFor(() => {
|
||||
const llamadaPatch = fetchMock.mock.calls.find(
|
||||
([url, init]) => String(url).endsWith('/config/roles/vendedor') && init?.method === 'PATCH',
|
||||
)
|
||||
expect(llamadaPatch).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
240
apps/landing/src/components/config/RolesPermisos.tsx
Normal file
240
apps/landing/src/components/config/RolesPermisos.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
getRolesConfig,
|
||||
patchRolConfig,
|
||||
resetRoles,
|
||||
type RolConfig,
|
||||
} from '../../services/config'
|
||||
|
||||
const MODULOS_DISPONIBLES = [
|
||||
'mi-dia',
|
||||
'cotizador',
|
||||
'alta-cliente',
|
||||
'alta-afluencia',
|
||||
'cobranza',
|
||||
'chat',
|
||||
'mi-tablero',
|
||||
'tablero-comercial',
|
||||
'contactos',
|
||||
]
|
||||
|
||||
const ACCIONES_DISPONIBLES = [
|
||||
'descartar_lead',
|
||||
'editar_cotizacion',
|
||||
'asignar_vendedor',
|
||||
'ver_reportes',
|
||||
'exportar_pdf',
|
||||
'ver_precios',
|
||||
]
|
||||
|
||||
/** Descripción amigable de cada acción */
|
||||
const ACCION_LABELS: Record<string, string> = {
|
||||
descartar_lead: 'Descartar leads',
|
||||
editar_cotizacion: 'Editar cotizaciones',
|
||||
asignar_vendedor: 'Asignar vendedor',
|
||||
ver_reportes: 'Ver reportes',
|
||||
exportar_pdf: 'Exportar PDF',
|
||||
ver_precios: 'Ver precios',
|
||||
}
|
||||
|
||||
/** Descripción amigable de cada módulo */
|
||||
const MODULO_LABELS: Record<string, string> = {
|
||||
'mi-dia': 'Mi día',
|
||||
cotizador: 'Cotizador',
|
||||
'alta-cliente': 'Alta cliente',
|
||||
'alta-afluencia': 'Alta afluencia',
|
||||
cobranza: 'Cobranza',
|
||||
chat: 'Chat IA',
|
||||
'mi-tablero': 'Mi tablero',
|
||||
'tablero-comercial': 'Tablero comercial',
|
||||
contactos: 'Contactos',
|
||||
}
|
||||
|
||||
interface RolEstado {
|
||||
modulos: string[]
|
||||
acciones: Record<string, boolean>
|
||||
}
|
||||
|
||||
export function RolesPermisos() {
|
||||
const [roles, setRoles] = useState<RolConfig[]>([])
|
||||
const [edicion, setEdicion] = useState<Record<string, RolEstado>>({})
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [guardando, setGuardando] = useState(false)
|
||||
const [aviso, setAviso] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setCargando(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await getRolesConfig()
|
||||
setRoles(data)
|
||||
// Construir estado de edición
|
||||
const estado: Record<string, RolEstado> = {}
|
||||
for (const r of data) {
|
||||
estado[r.rol] = {
|
||||
modulos: JSON.parse(r.modulos) as string[],
|
||||
acciones: JSON.parse(r.acciones) as Record<string, boolean>,
|
||||
}
|
||||
}
|
||||
setEdicion(estado)
|
||||
} catch {
|
||||
setError('No se pudieron cargar los roles.')
|
||||
} finally {
|
||||
setCargando(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { void cargar() }, [cargar])
|
||||
|
||||
const toggleModulo = (rol: string, modulo: string) => {
|
||||
setEdicion((prev) => {
|
||||
const actual = prev[rol]
|
||||
if (!actual) return prev
|
||||
const modulos = actual.modulos.includes(modulo)
|
||||
? actual.modulos.filter((m) => m !== modulo)
|
||||
: [...actual.modulos, modulo]
|
||||
return { ...prev, [rol]: { ...actual, modulos } }
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAccion = (rol: string, accion: string) => {
|
||||
setEdicion((prev) => {
|
||||
const actual = prev[rol]
|
||||
if (!actual) return prev
|
||||
const acciones = { ...actual.acciones, [accion]: !actual.acciones[accion] }
|
||||
return { ...prev, [rol]: { ...actual, acciones } }
|
||||
})
|
||||
}
|
||||
|
||||
const guardarTodo = async () => {
|
||||
setGuardando(true)
|
||||
setAviso('')
|
||||
setError('')
|
||||
try {
|
||||
await Promise.all(
|
||||
Object.entries(edicion).map(([rol, estado]) =>
|
||||
patchRolConfig(rol, {
|
||||
modulos: estado.modulos,
|
||||
acciones: estado.acciones,
|
||||
}),
|
||||
),
|
||||
)
|
||||
setAviso('Todos los roles se actualizaron correctamente.')
|
||||
} catch {
|
||||
setError('No se pudieron guardar los cambios. Intenta de nuevo.')
|
||||
} finally {
|
||||
setGuardando(false)
|
||||
}
|
||||
}
|
||||
|
||||
const restaurar = async () => {
|
||||
if (!window.confirm('¿Restaurar todos los roles a sus valores por defecto?')) return
|
||||
setGuardando(true)
|
||||
setAviso('')
|
||||
setError('')
|
||||
try {
|
||||
await resetRoles()
|
||||
await cargar()
|
||||
setAviso('Roles restaurados a valores por defecto.')
|
||||
} catch {
|
||||
setError('No se pudieron restaurar los roles.')
|
||||
} finally {
|
||||
setGuardando(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (cargando) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<p className="text-sm text-[#64748B]">Cargando configuración de roles…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Botones de acción */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void guardarTodo()}
|
||||
disabled={guardando}
|
||||
className="rounded-lg bg-[#2563EB] px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-[#1D4ED8] disabled:opacity-50"
|
||||
>
|
||||
{guardando ? 'Guardando…' : 'Guardar cambios'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void restaurar()}
|
||||
disabled={guardando}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-50"
|
||||
>
|
||||
Restaurar valores por defecto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{aviso && (
|
||||
<p role="status" className="text-sm font-medium text-green-600">{aviso}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Tabla de roles */}
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F9FAFB]">
|
||||
<th className="px-4 py-3 font-medium text-[#111827]">Rol</th>
|
||||
{MODULOS_DISPONIBLES.map((m) => (
|
||||
<th key={m} className="px-3 py-3 text-center text-xs font-medium text-[#64748B]">
|
||||
{MODULO_LABELS[m] ?? m}
|
||||
</th>
|
||||
))}
|
||||
{ACCIONES_DISPONIBLES.map((a) => (
|
||||
<th key={a} className="px-3 py-3 text-center text-xs font-medium text-[#64748B]">
|
||||
{ACCION_LABELS[a] ?? a}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roles.map((r) => {
|
||||
const estado = edicion[r.rol]
|
||||
if (!estado) return null
|
||||
return (
|
||||
<tr key={r.rol} className="border-b border-[#E5E7EB] last:border-0">
|
||||
<td className="whitespace-nowrap px-4 py-3 font-medium text-[#111827]">
|
||||
{r.rol}
|
||||
</td>
|
||||
{MODULOS_DISPONIBLES.map((m) => (
|
||||
<td key={m} className="px-3 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={estado.modulos.includes(m)}
|
||||
onChange={() => toggleModulo(r.rol, m)}
|
||||
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-accent/20"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
{ACCIONES_DISPONIBLES.map((a) => (
|
||||
<td key={a} className="px-3 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!estado.acciones[a]}
|
||||
onChange={() => toggleAccion(r.rol, a)}
|
||||
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-accent/20"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
apps/landing/src/components/config/Sistema.test.tsx
Normal file
101
apps/landing/src/components/config/Sistema.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { Sistema } from './Sistema'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
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 CONFIG_FAKE = {
|
||||
modo_local_forzado: 'false',
|
||||
recordatorio_inactividad_dias: '15',
|
||||
recordatorio_cobranza_hora: '10:00',
|
||||
sesion_minutos: '720',
|
||||
max_upload_mb: '20',
|
||||
mostrar_precios_lista: 'true',
|
||||
auto_logout: 'false',
|
||||
}
|
||||
|
||||
const respuestaOk = (data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('localStorage', crearStorageStub())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Sistema', () => {
|
||||
it('muestra los campos del formulario con los valores cargados', async () => {
|
||||
fetchMock.mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/config/sistema')) return Promise.resolve(respuestaOk(CONFIG_FAKE))
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
})
|
||||
|
||||
render(<Sistema />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('15')).toBeTruthy()
|
||||
expect(screen.getByDisplayValue('10:00')).toBeTruthy()
|
||||
expect(screen.getByDisplayValue('20')).toBeTruthy()
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Select de TTL — verificar que tiene la opción correcta seleccionada
|
||||
const select = screen.getByLabelText('Sesión TTL (minutos)') as HTMLSelectElement
|
||||
expect(select.value).toBe('720')
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('al guardar, hace PATCH con los campos del formulario', async () => {
|
||||
fetchMock.mockImplementation((input, init) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/config/sistema') && (!init?.method || init.method === 'GET')) {
|
||||
return Promise.resolve(respuestaOk(CONFIG_FAKE))
|
||||
}
|
||||
if (url.includes('/config/sistema') && init?.method === 'PATCH') {
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
}
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
})
|
||||
|
||||
render(<Sistema />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('15')).toBeTruthy()
|
||||
}, { timeout: 5000 })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Guardar' }))
|
||||
|
||||
await waitFor(() => {
|
||||
const llamadaPatch = fetchMock.mock.calls.find(
|
||||
([url, init]) => String(url).includes('/config/sistema') && init?.method === 'PATCH',
|
||||
)
|
||||
expect(llamadaPatch).toBeTruthy()
|
||||
const body = JSON.parse(String(llamadaPatch?.[1]?.body))
|
||||
expect(body.recordatorio_inactividad_dias).toBe('15')
|
||||
expect(body.sesion_minutos).toBe('720')
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
224
apps/landing/src/components/config/Sistema.tsx
Normal file
224
apps/landing/src/components/config/Sistema.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getConfigSistema, patchConfigSistema } from '../../services/config'
|
||||
|
||||
interface Formulario {
|
||||
recordatorio_inactividad_dias: string
|
||||
recordatorio_cobranza_hora: string
|
||||
sesion_minutos: string
|
||||
max_upload_mb: string
|
||||
mostrar_precios_lista: string
|
||||
auto_logout: string
|
||||
}
|
||||
|
||||
const TTL_OPTIONS = [
|
||||
{ value: '240', label: '4 horas' },
|
||||
{ value: '480', label: '8 horas' },
|
||||
{ value: '720', label: '12 horas' },
|
||||
{ value: '1440', label: '24 horas' },
|
||||
]
|
||||
|
||||
const labelEstilo = 'block text-xs font-medium text-[#4B5563]'
|
||||
const inputEstilo =
|
||||
'mt-2 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
|
||||
|
||||
export function Sistema() {
|
||||
const [form, setForm] = useState<Formulario>({
|
||||
recordatorio_inactividad_dias: '10',
|
||||
recordatorio_cobranza_hora: '09:30',
|
||||
sesion_minutos: '480',
|
||||
max_upload_mb: '10',
|
||||
mostrar_precios_lista: 'true',
|
||||
auto_logout: 'true',
|
||||
})
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [guardando, setGuardando] = useState(false)
|
||||
const [aviso, setAviso] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true
|
||||
getConfigSistema()
|
||||
.then((config) => {
|
||||
if (!activo) return
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
recordatorio_inactividad_dias: config.recordatorio_inactividad_dias ?? prev.recordatorio_inactividad_dias,
|
||||
recordatorio_cobranza_hora: config.recordatorio_cobranza_hora ?? prev.recordatorio_cobranza_hora,
|
||||
sesion_minutos: config.sesion_minutos ?? prev.sesion_minutos,
|
||||
max_upload_mb: config.max_upload_mb ?? prev.max_upload_mb,
|
||||
mostrar_precios_lista: config.mostrar_precios_lista ?? prev.mostrar_precios_lista,
|
||||
auto_logout: config.auto_logout ?? prev.auto_logout,
|
||||
}))
|
||||
})
|
||||
.catch(() => {
|
||||
if (activo) setError('No se pudieron cargar las configuraciones.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (activo) setCargando(false)
|
||||
})
|
||||
return () => { activo = false }
|
||||
}, [])
|
||||
|
||||
const guardar = async () => {
|
||||
setGuardando(true)
|
||||
setAviso('')
|
||||
setError('')
|
||||
try {
|
||||
await patchConfigSistema(form as unknown as Record<string, string>)
|
||||
setAviso('Configuración guardada correctamente.')
|
||||
} catch {
|
||||
setError('No se pudieron guardar las configuraciones.')
|
||||
} finally {
|
||||
setGuardando(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (cargando) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<p className="text-sm text-[#64748B]">Cargando configuraciones…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-[#111827]">Configuración del sistema</h2>
|
||||
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
{/* Recordatorio inactividad */}
|
||||
<div>
|
||||
<label htmlFor="cfg-inactividad" className={labelEstilo}>
|
||||
Recordatorios inactividad (días)
|
||||
</label>
|
||||
<input
|
||||
id="cfg-inactividad"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.recordatorio_inactividad_dias}
|
||||
onChange={(e) => setForm((f) => ({ ...f, recordatorio_inactividad_dias: e.target.value }))}
|
||||
className={inputEstilo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hora recordatorio cobranza */}
|
||||
<div>
|
||||
<label htmlFor="cfg-cobranza" className={labelEstilo}>
|
||||
Hora recordatorio cobranza
|
||||
</label>
|
||||
<input
|
||||
id="cfg-cobranza"
|
||||
type="time"
|
||||
value={form.recordatorio_cobranza_hora}
|
||||
onChange={(e) => setForm((f) => ({ ...f, recordatorio_cobranza_hora: e.target.value }))}
|
||||
className={inputEstilo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sesión TTL */}
|
||||
<div>
|
||||
<label htmlFor="cfg-sesion" className={labelEstilo}>
|
||||
Sesión TTL (minutos)
|
||||
</label>
|
||||
<select
|
||||
id="cfg-sesion"
|
||||
value={form.sesion_minutos}
|
||||
onChange={(e) => setForm((f) => ({ ...f, sesion_minutos: e.target.value }))}
|
||||
className={inputEstilo}
|
||||
>
|
||||
{TTL_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label} ({o.value} min)</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Límite subida */}
|
||||
<div>
|
||||
<label htmlFor="cfg-upload" className={labelEstilo}>
|
||||
Límite subida (MB)
|
||||
</label>
|
||||
<input
|
||||
id="cfg-upload"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.max_upload_mb}
|
||||
onChange={(e) => setForm((f) => ({ ...f, max_upload_mb: e.target.value }))}
|
||||
className={inputEstilo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mostrar precios */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({
|
||||
...f,
|
||||
mostrar_precios_lista: f.mostrar_precios_lista === 'true' ? 'false' : 'true',
|
||||
}))}
|
||||
className={`relative inline-flex h-8 w-14 flex-shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-accent/20 ${
|
||||
form.mostrar_precios_lista === 'true' ? 'bg-accent' : 'bg-[#D1D5DB]'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={form.mostrar_precios_lista === 'true'}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-6 w-6 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
||||
form.mostrar_precios_lista === 'true' ? 'translate-x-6' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#111827]">Mostrar precios en PDF</p>
|
||||
<p className="text-xs text-[#64748B]">Controla si se muestra el descuento en el PDF del cotizador.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logout automático */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({
|
||||
...f,
|
||||
auto_logout: f.auto_logout === 'true' ? 'false' : 'true',
|
||||
}))}
|
||||
className={`relative inline-flex h-8 w-14 flex-shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-accent/20 ${
|
||||
form.auto_logout === 'true' ? 'bg-accent' : 'bg-[#D1D5DB]'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={form.auto_logout === 'true'}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-6 w-6 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
||||
form.auto_logout === 'true' ? 'translate-x-6' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#111827]">Logout automático</p>
|
||||
<p className="text-xs text-[#64748B]">Cerrar sesión automáticamente al recibir 401.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botón guardar */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void guardar()}
|
||||
disabled={guardando}
|
||||
className="rounded-lg bg-[#2563EB] px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-[#1D4ED8] disabled:opacity-50"
|
||||
>
|
||||
{guardando ? 'Guardando…' : 'Guardar'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{aviso && (
|
||||
<p role="status" className="mt-4 text-sm font-medium text-green-600">{aviso}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="mt-4 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
apps/landing/src/components/config/Usuarios.test.tsx
Normal file
100
apps/landing/src/components/config/Usuarios.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { Usuarios } from './Usuarios'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
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 USUARIOS_FAKE = [
|
||||
{ id: '1', username: 'admin01', nombre: 'Admin User', rol: 'admin', asignacion: null, activo: true, mondayUserId: null },
|
||||
{ id: '2', username: 'vend01', nombre: 'Vendedor Uno', rol: 'vendedor', asignacion: 'MONTEBELLO', activo: true, mondayUserId: 123 },
|
||||
]
|
||||
|
||||
const respuestaOk = (data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('localStorage', crearStorageStub())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Usuarios', () => {
|
||||
it('muestra la tabla de usuarios cargados', async () => {
|
||||
fetchMock.mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/config/usuarios')) return Promise.resolve(respuestaOk(USUARIOS_FAKE))
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
})
|
||||
|
||||
render(<Usuarios />)
|
||||
expect(await screen.findByText('Admin User')).toBeTruthy()
|
||||
expect(screen.getByText('Vendedor Uno')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Buscar usuario')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('la búsqueda filtra usuarios por nombre', async () => {
|
||||
fetchMock.mockImplementation((input) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/config/usuarios')) return Promise.resolve(respuestaOk(USUARIOS_FAKE))
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
})
|
||||
|
||||
render(<Usuarios />)
|
||||
await screen.findByText('Admin User')
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Buscar usuario'), { target: { value: 'Admin' } })
|
||||
|
||||
expect(screen.getByText('Admin User')).toBeTruthy()
|
||||
expect(screen.queryByText('Vendedor Uno')).toBeNull()
|
||||
})
|
||||
|
||||
it('al cambiar el select de rol, hace PATCH inmediato', async () => {
|
||||
fetchMock.mockImplementation((input, init) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/config/usuarios') && (!init?.method || init.method === 'GET')) {
|
||||
return Promise.resolve(respuestaOk(USUARIOS_FAKE))
|
||||
}
|
||||
if (url.endsWith('/config/usuarios/2') && init?.method === 'PATCH') {
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
}
|
||||
return Promise.resolve(respuestaOk({}))
|
||||
})
|
||||
|
||||
render(<Usuarios />)
|
||||
await screen.findByText('Vendedor Uno')
|
||||
|
||||
// Encontrar el select de rol para el segundo usuario (Vendedor Uno)
|
||||
const selects = screen.getAllByRole('combobox')
|
||||
// El primer select es el de Admin (rol), el tercero es el de Vendedor (rol)
|
||||
fireEvent.change(selects[2], { target: { value: 'director_ventas' } })
|
||||
|
||||
await waitFor(() => {
|
||||
const llamadaPatch = fetchMock.mock.calls.find(
|
||||
([url, init]) => String(url).endsWith('/config/usuarios/2') && init?.method === 'PATCH',
|
||||
)
|
||||
expect(llamadaPatch).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
233
apps/landing/src/components/config/Usuarios.tsx
Normal file
233
apps/landing/src/components/config/Usuarios.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
getUsuariosConfig,
|
||||
patchUsuarioConfig,
|
||||
type UsuarioConfig,
|
||||
} from '../../services/config'
|
||||
|
||||
const ROLES = [
|
||||
'vendedor',
|
||||
'administracion_venta',
|
||||
'director_ventas',
|
||||
'escrituracion',
|
||||
'direccion_general',
|
||||
'admin',
|
||||
] as const
|
||||
|
||||
const ASIGNACIONES = [
|
||||
'ATELIER',
|
||||
'RESIDENCIAL',
|
||||
'MONTEBELLO',
|
||||
'BROKER',
|
||||
'ACORPORATIVO',
|
||||
'',
|
||||
] as const
|
||||
|
||||
const ROL_LABELS: Record<string, string> = {
|
||||
vendedor: 'Vendedor',
|
||||
administracion_venta: 'Administración venta',
|
||||
director_ventas: 'Director ventas',
|
||||
escrituracion: 'Escrituración',
|
||||
direccion_general: 'Dirección general',
|
||||
admin: 'Admin',
|
||||
}
|
||||
|
||||
const ASIGNACION_LABELS: Record<string, string> = {
|
||||
ATELIER: 'Atelier',
|
||||
RESIDENCIAL: 'Residencial',
|
||||
MONTEBELLO: 'Montebello',
|
||||
BROKER: 'Broker',
|
||||
ACORPORATIVO: 'A Corporativo',
|
||||
'': 'Sin asignación',
|
||||
}
|
||||
|
||||
export function Usuarios() {
|
||||
const [usuarios, setUsuarios] = useState<UsuarioConfig[]>([])
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busqueda, setBusqueda] = useState('')
|
||||
// feedback por usuario: { [usuarioId]: 'ok' | 'error' | null }
|
||||
const [feedback, setFeedback] = useState<Record<string, string>>({})
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setCargando(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await getUsuariosConfig()
|
||||
setUsuarios(data)
|
||||
} catch {
|
||||
setError('No se pudieron cargar los usuarios.')
|
||||
} finally {
|
||||
setCargando(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { void cargar() }, [cargar])
|
||||
|
||||
const mostrarFeedback = (id: string, tipo: 'ok' | 'error') => {
|
||||
setFeedback((prev) => ({ ...prev, [id]: tipo }))
|
||||
setTimeout(() => setFeedback((prev) => {
|
||||
const copy = { ...prev }
|
||||
delete copy[id]
|
||||
return copy
|
||||
}), 2000)
|
||||
}
|
||||
|
||||
const cambiarRol = async (id: string, nuevoRol: string) => {
|
||||
try {
|
||||
await patchUsuarioConfig(id, { rol: nuevoRol })
|
||||
setUsuarios((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, rol: nuevoRol } : u)),
|
||||
)
|
||||
mostrarFeedback(id, 'ok')
|
||||
} catch {
|
||||
mostrarFeedback(id, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const cambiarAsignacion = async (id: string, nuevaAsignacion: string) => {
|
||||
const valor = nuevaAsignacion || null
|
||||
try {
|
||||
await patchUsuarioConfig(id, { asignacion: valor })
|
||||
setUsuarios((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, asignacion: valor } : u)),
|
||||
)
|
||||
mostrarFeedback(id, 'ok')
|
||||
} catch {
|
||||
mostrarFeedback(id, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleActivo = async (id: string, activo: boolean) => {
|
||||
try {
|
||||
await patchUsuarioConfig(id, { activo })
|
||||
setUsuarios((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, activo } : u)),
|
||||
)
|
||||
mostrarFeedback(id, 'ok')
|
||||
} catch {
|
||||
mostrarFeedback(id, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const filtrados = usuarios.filter((u) => {
|
||||
const q = busqueda.toLowerCase()
|
||||
return (
|
||||
u.nombre.toLowerCase().includes(q) ||
|
||||
u.username.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
if (cargando) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<p className="text-sm text-[#64748B]">Cargando usuarios…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Búsqueda */}
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
||||
<label htmlFor="buscar-usuario" className="block text-xs font-medium text-[#4B5563]">
|
||||
Buscar usuario
|
||||
</label>
|
||||
<input
|
||||
id="buscar-usuario"
|
||||
type="text"
|
||||
value={busqueda}
|
||||
onChange={(e) => setBusqueda(e.target.value)}
|
||||
placeholder="Nombre o usuario…"
|
||||
className="mt-2 w-full max-w-sm rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabla */}
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F9FAFB]">
|
||||
<th className="px-4 py-3 font-medium text-[#111827]">Nombre</th>
|
||||
<th className="px-4 py-3 font-medium text-[#111827]">Usuario</th>
|
||||
<th className="px-4 py-3 font-medium text-[#111827]">Rol</th>
|
||||
<th className="px-4 py-3 font-medium text-[#111827]">Asignación</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-[#111827]">Activo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtrados.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-6 text-center text-sm text-[#64748B]">
|
||||
No se encontraron usuarios.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtrados.map((u) => (
|
||||
<tr key={u.id} className="border-b border-[#E5E7EB] last:border-0">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-[#111827]">{u.nombre}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-[#64748B]">{u.username}</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={u.rol}
|
||||
onChange={(e) => void cambiarRol(u.id, e.target.value)}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs text-[#111827] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>{ROL_LABELS[r]}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={u.asignacion ?? ''}
|
||||
onChange={(e) => void cambiarAsignacion(u.id, e.target.value)}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs text-[#111827] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
>
|
||||
{ASIGNACIONES.map((a) => (
|
||||
<option key={a} value={a}>{ASIGNACION_LABELS[a]}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleActivo(u.id, !u.activo)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-accent/20 ${
|
||||
u.activo ? 'bg-green-500' : 'bg-[#D1D5DB]'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={u.activo}
|
||||
aria-label={u.activo ? 'Desactivar usuario' : 'Activar usuario'}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
||||
u.activo ? 'translate-x-5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Feedback inline */}
|
||||
{Object.entries(feedback).map(([id, tipo]) => (
|
||||
<p
|
||||
key={`fb-${id}`}
|
||||
role="status"
|
||||
className={`text-xs ${tipo === 'ok' ? 'text-green-600' : 'text-red-600'}`}
|
||||
>
|
||||
{tipo === 'ok' ? '✓ Guardado' : '✗ Error al guardar'}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export interface UsuarioSesion {
|
||||
// Módulos del ecosistema visibles para el rol (matriz del API; presente en
|
||||
// /auth/me y /auth/login desde sep-2026)
|
||||
modulos?: string[]
|
||||
// Acciones permitidas por el rol (solo admin ve las de todos los roles)
|
||||
acciones?: string[]
|
||||
}
|
||||
|
||||
export interface Sesion {
|
||||
|
||||
119
apps/landing/src/services/config.ts
Normal file
119
apps/landing/src/services/config.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { API_URL, conAuth, manejar401 } from './auth'
|
||||
|
||||
// ─── Config Sistema ──────────────────────────────────────────────
|
||||
|
||||
export interface ConfigSistema {
|
||||
[clave: string]: string
|
||||
}
|
||||
|
||||
/** GET /config/sistema — todas las configuraciones del sistema */
|
||||
export async function getConfigSistema(): Promise<ConfigSistema> {
|
||||
const resp = await fetch(`${API_URL}/config/sistema`, conAuth())
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as ConfigSistema
|
||||
}
|
||||
|
||||
/** PATCH /config/sistema — actualiza configs (parcial) */
|
||||
export async function patchConfigSistema(data: Record<string, string>): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_URL}/config/sistema`,
|
||||
conAuth({
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
)
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
}
|
||||
|
||||
// ─── Roles ───────────────────────────────────────────────────────
|
||||
|
||||
export interface RolConfig {
|
||||
rol: string
|
||||
modulos: string // JSON array string
|
||||
acciones: string // JSON object string
|
||||
}
|
||||
|
||||
/** GET /config/roles — todas las configuraciones de rol */
|
||||
export async function getRolesConfig(): Promise<RolConfig[]> {
|
||||
const resp = await fetch(`${API_URL}/config/roles`, conAuth())
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as RolConfig[]
|
||||
}
|
||||
|
||||
/** PATCH /config/roles/:rol — actualiza módulos y acciones de un rol */
|
||||
export async function patchRolConfig(
|
||||
rol: string,
|
||||
data: { modulos: string[]; acciones: Record<string, boolean> },
|
||||
): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_URL}/config/roles/${encodeURIComponent(rol)}`,
|
||||
conAuth({
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
)
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
}
|
||||
|
||||
/** POST /config/reset-roles — restaura valores por defecto */
|
||||
export async function resetRoles(): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_URL}/config/reset-roles`,
|
||||
conAuth({ method: 'POST' }),
|
||||
)
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
}
|
||||
|
||||
// ─── Usuarios ────────────────────────────────────────────────────
|
||||
|
||||
export interface UsuarioConfig {
|
||||
id: string
|
||||
username: string
|
||||
nombre: string
|
||||
rol: string
|
||||
asignacion: string | null
|
||||
activo: boolean
|
||||
mondayUserId: number | null
|
||||
}
|
||||
|
||||
/** GET /config/usuarios — lista de usuarios con rol/asignación/activo */
|
||||
export async function getUsuariosConfig(): Promise<UsuarioConfig[]> {
|
||||
const resp = await fetch(`${API_URL}/config/usuarios`, conAuth())
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as UsuarioConfig[]
|
||||
}
|
||||
|
||||
/** PATCH /config/usuarios/:id — cambia rol/asignación/activo */
|
||||
export async function patchUsuarioConfig(
|
||||
id: string,
|
||||
data: { rol?: string; asignacion?: string | null; activo?: boolean },
|
||||
): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_URL}/config/usuarios/${encodeURIComponent(id)}`,
|
||||
conAuth({
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
)
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
}
|
||||
|
||||
// ─── Acciones ────────────────────────────────────────────────────
|
||||
|
||||
/** GET /config/acciones — acciones disponibles para el rol del usuario actual */
|
||||
export async function getAcciones(): Promise<Record<string, boolean>> {
|
||||
const resp = await fetch(`${API_URL}/config/acciones`, conAuth())
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as Record<string, boolean>
|
||||
}
|
||||
@@ -173,6 +173,7 @@ function FilaOportunidad({
|
||||
onAfluencia,
|
||||
onDescartar,
|
||||
enviando,
|
||||
acciones,
|
||||
}: {
|
||||
op: OportunidadMiDia
|
||||
onEtapa: (etapa: string, fecha?: string) => void
|
||||
@@ -180,6 +181,7 @@ function FilaOportunidad({
|
||||
onAfluencia: () => void
|
||||
onDescartar: () => void
|
||||
enviando: boolean
|
||||
acciones?: string[]
|
||||
}) {
|
||||
const [conCita, setConCita] = useState(false)
|
||||
const [fechaCita, setFechaCita] = useState('')
|
||||
@@ -264,7 +266,7 @@ function FilaOportunidad({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
disabled={enviando || (acciones !== undefined && !acciones.includes('descartar_lead'))}
|
||||
onClick={onDescartar}
|
||||
className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
@@ -503,12 +505,14 @@ function FilaLead({
|
||||
onConvertir,
|
||||
onDescartar,
|
||||
enviando,
|
||||
acciones,
|
||||
}: {
|
||||
lead: LeadMiDia
|
||||
onAccion: (estado: string, fecha?: string) => void
|
||||
onConvertir: () => void
|
||||
onDescartar: () => void
|
||||
enviando: boolean
|
||||
acciones?: string[]
|
||||
}) {
|
||||
return (
|
||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
||||
@@ -564,7 +568,7 @@ function FilaLead({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={enviando}
|
||||
disabled={enviando || (acciones !== undefined && !acciones.includes("descartar_lead"))}
|
||||
onClick={onDescartar}
|
||||
className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
@@ -578,6 +582,7 @@ function FilaLead({
|
||||
export default function MiDiaPage({ accionesSesion }: { accionesSesion: AccionesSesion }) {
|
||||
const nombreVendedor = getSesion()?.usuario.nombre ?? ''
|
||||
const rol = getSesion()?.usuario.rol ?? 'vendedor'
|
||||
const acciones = getSesion()?.usuario.acciones ?? []
|
||||
const [resumen, setResumen] = useState<ResumenMiDia | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cargando, setCargando] = useState(true)
|
||||
@@ -836,6 +841,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
|
||||
onConvertir={() => void convertir(lead)}
|
||||
onDescartar={() => setDescartando(lead)}
|
||||
acciones={acciones}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -859,6 +865,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
window.location.href = urlAfluenciaParaOportunidad(op)
|
||||
}}
|
||||
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
|
||||
acciones={acciones}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface UsuarioSesion {
|
||||
nombre: string
|
||||
rol: string
|
||||
asignacion: string | null
|
||||
acciones?: string[]
|
||||
}
|
||||
|
||||
export interface Sesion {
|
||||
|
||||
Reference in New Issue
Block a user