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:
2026-09-06 09:46:19 +00:00
parent 50e6eca7da
commit b2634f97ab
35 changed files with 2572 additions and 33 deletions

View File

@@ -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">

View File

@@ -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"

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

View 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]"
>
&larr; 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>
)
}

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

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

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

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

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

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

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

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

View File

@@ -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 {

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