feat(config): alta de usuarios desde Configuraciones (POST /config/usuarios, solo admin)

This commit is contained in:
2026-09-14 20:21:15 +00:00
parent 31379806be
commit b99707689f
5 changed files with 240 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import {
crearUsuarioConfig,
getUsuariosConfig,
patchUsuarioConfig,
type UsuarioConfig,
@@ -49,6 +50,16 @@ export function Usuarios() {
// feedback por usuario: { [usuarioId]: 'ok' | 'error' | null }
const [feedback, setFeedback] = useState<Record<string, string>>({})
// ─── Alta de usuario ─────────────────────────────────────────────
const [formAbierto, setFormAbierto] = useState(false)
const [nombreNuevo, setNombreNuevo] = useState('')
const [rolNuevo, setRolNuevo] = useState<string>('vendedor')
const [asignacionNueva, setAsignacionNueva] = useState<string>('')
const [passwordNuevo, setPasswordNuevo] = useState('')
const [guardandoAlta, setGuardandoAlta] = useState(false)
const [errorAlta, setErrorAlta] = useState('')
const [exitoAlta, setExitoAlta] = useState('')
const cargar = useCallback(async () => {
setCargando(true)
setError('')
@@ -118,6 +129,39 @@ export function Usuarios() {
)
})
const enviarAlta = async (e: FormEvent) => {
e.preventDefault()
setErrorAlta('')
setExitoAlta('')
if (nombreNuevo.trim().length < 3) {
setErrorAlta('Escribe el nombre completo del usuario.')
return
}
if (passwordNuevo.length < 8) {
setErrorAlta('La contraseña necesita al menos 8 caracteres.')
return
}
setGuardandoAlta(true)
try {
const creado = await crearUsuarioConfig({
nombre: nombreNuevo.trim(),
rol: rolNuevo,
asignacion: asignacionNueva || null,
password: passwordNuevo,
})
setExitoAlta(`Usuario "${creado.username}" creado con rol ${ROL_LABELS[creado.rol] ?? creado.rol}.`)
setNombreNuevo('')
setRolNuevo('vendedor')
setAsignacionNueva('')
setPasswordNuevo('')
await cargar()
} catch (err) {
setErrorAlta(err instanceof Error && err.message ? err.message : 'No se pudo crear el usuario.')
} finally {
setGuardandoAlta(false)
}
}
if (cargando) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
@@ -132,6 +176,109 @@ export function Usuarios() {
<p role="alert" className="text-sm text-red-600">{error}</p>
)}
{/* Alta de usuario */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Dar de alta usuario
</h2>
<button
type="button"
onClick={() => {
setFormAbierto((v) => !v)
setErrorAlta('')
setExitoAlta('')
}}
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
>
{formAbierto ? 'Cerrar' : '+ Nuevo usuario'}
</button>
</div>
{formAbierto && (
<form onSubmit={enviarAlta} className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="alta-nombre" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Nombre completo *
</label>
<input
id="alta-nombre"
type="text"
value={nombreNuevo}
onChange={(e) => setNombreNuevo(e.target.value)}
placeholder="P. ej. María López García"
className="mt-1.5 w-full 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 dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
/>
<p className="mt-1 text-[11px] text-[#64748B] dark:text-[#94A3B8]">
El usuario de acceso se genera del nombre (minúsculas, sin acentos).
</p>
</div>
<div>
<label htmlFor="alta-password" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Contraseña *
</label>
<input
id="alta-password"
type="password"
value={passwordNuevo}
onChange={(e) => setPasswordNuevo(e.target.value)}
placeholder="Mínimo 8 caracteres"
className="mt-1.5 w-full 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 dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
/>
</div>
<div>
<label htmlFor="alta-rol" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Rol *
</label>
<select
id="alta-rol"
value={rolNuevo}
onChange={(e) => setRolNuevo(e.target.value)}
className="mt-1.5 w-full 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 dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
>
{ROLES.map((r) => (
<option key={r} value={r}>{ROL_LABELS[r]}</option>
))}
</select>
</div>
<div>
<label htmlFor="alta-asignacion" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Asignación
</label>
<select
id="alta-asignacion"
value={asignacionNueva}
onChange={(e) => setAsignacionNueva(e.target.value)}
className="mt-1.5 w-full 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 dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
>
{ASIGNACIONES.map((a) => (
<option key={a} value={a}>{ASIGNACION_LABELS[a]}</option>
))}
</select>
</div>
<div className="sm:col-span-2 flex items-center gap-3">
<button
type="submit"
disabled={guardandoAlta}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{guardandoAlta ? 'Creando…' : 'Crear usuario'}
</button>
{exitoAlta && (
<p role="status" className="text-sm text-green-600">{exitoAlta}</p>
)}
{errorAlta && (
<p role="alert" className="text-sm text-red-600">{errorAlta}</p>
)}
</div>
</form>
)}
</div>
{/* Búsqueda */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<label htmlFor="buscar-usuario" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">

View File

@@ -108,6 +108,29 @@ export async function patchUsuarioConfig(
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
}
/** POST /config/usuarios — dar de alta un usuario (solo admin) */
export async function crearUsuarioConfig(data: {
nombre: string
rol: string
asignacion: string | null
password: string
}): Promise<UsuarioConfig> {
const resp = await fetch(
`${API_URL}/config/usuarios`,
conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}),
)
manejar401(resp)
if (!resp.ok) {
const cuerpo = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(cuerpo?.error ?? `HTTP ${resp.status}`)
}
return (await resp.json()) as UsuarioConfig
}
// ─── Auditoría ─────────────────────────────────────────────────
export interface AuditoriaItem {