feature/rbac y perfiles implementados

This commit is contained in:
2026-05-21 08:00:43 -06:00
parent 546a411df8
commit dc5f9fd6ce
29 changed files with 2007 additions and 977 deletions

416
src/pages/ProfileForm.jsx Normal file
View File

@@ -0,0 +1,416 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
getRole,
createRole,
updateRole,
fetchPermissionsByModule,
} from '../api/rbac';
import { useNotification } from '../context/NotificationContext';
// Nombres legibles de módulos para la UI
const MODULE_LABELS = {
usuarios: 'Usuarios',
pedimentos: 'Pedimentos',
partidas: 'Partidas',
remesas: 'Remesas',
coves: 'COVEs',
edocuments: 'E-Documents',
acuses: 'Acuses',
documentos: 'Documentos',
vucem: 'Ventanilla Única (VUCEM)',
reportes: 'Reportes',
datastage: 'DataStage',
organizacion: 'Organización',
notificaciones: 'Notificaciones',
cards: 'Dashboard / Cards',
};
export default function ProfileForm() {
const { id } = useParams();
const isEditing = Boolean(id);
const navigate = useNavigate();
const { showMessage } = useNotification();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isAdminRole, setIsAdminRole] = useState(false);
// Permisos de la API: { modulo: [{ id, codename, descripcion, modulo }] }
const [permsByModule, setPermsByModule] = useState({});
const [loadingPerms, setLoadingPerms] = useState(true);
// IDs numéricos de permisos seleccionados
const [selectedPermIds, setSelectedPermIds] = useState(new Set());
const [loadingRole, setLoadingRole] = useState(isEditing);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (typeof window !== 'undefined' && !document.getElementById('profiles-animations')) {
const style = document.createElement('style');
style.id = 'profiles-animations';
style.innerHTML = `
@keyframes fadeInUpProfiles {
0% { opacity: 0; transform: translateY(24px); }
100% { opacity: 1; transform: translateY(0); }
}
.fade-in-up-profiles { animation: fadeInUpProfiles 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; }
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-bounce-slow { animation: bounce-slow 2.2s infinite; }
`;
document.head.appendChild(style);
}
}, []);
// Cargar catálogo de permisos desde API
useEffect(() => {
fetchPermissionsByModule()
.then(data => {
setPermsByModule(data && typeof data === 'object' ? data : {});
})
.catch(() => showMessage('Error al cargar catálogo de permisos', 'error'))
.finally(() => setLoadingPerms(false));
}, []);
// Cargar datos del rol si es edición
useEffect(() => {
if (!isEditing) return;
getRole(id)
.then(role => {
setName(role.nombre || '');
setDescription(role.descripcion || '');
setIsAdminRole(role.is_admin_role || false);
// Permisos vienen como [{ id, codename, descripcion, modulo }]
const permList = Array.isArray(role.permissions) ? role.permissions : [];
setSelectedPermIds(new Set(permList.map(p => p.id)));
setLoadingRole(false);
})
.catch(err => {
showMessage(err.message || 'Error al cargar perfil', 'error');
setLoadingRole(false);
});
}, [id, isEditing]);
// Módulos en el orden en que llegan de la API
const modules = Object.keys(permsByModule);
const togglePerm = (permId) => {
setSelectedPermIds(prev => {
const next = new Set(prev);
if (next.has(permId)) next.delete(permId);
else next.add(permId);
return next;
});
};
const toggleModule = (module) => {
const moduleIds = (permsByModule[module] || []).map(p => p.id);
const allSelected = moduleIds.every(id => selectedPermIds.has(id));
setSelectedPermIds(prev => {
const next = new Set(prev);
if (allSelected) {
moduleIds.forEach(id => next.delete(id));
} else {
moduleIds.forEach(id => next.add(id));
}
return next;
});
};
const totalPerms = modules.reduce((acc, m) => acc + (permsByModule[m]?.length ?? 0), 0);
const selectAll = () => {
const all = modules.flatMap(m => (permsByModule[m] || []).map(p => p.id));
setSelectedPermIds(new Set(all));
};
const clearAll = () => setSelectedPermIds(new Set());
const handleSubmit = async (e) => {
e.preventDefault();
if (!name.trim()) {
showMessage('El nombre del perfil es obligatorio', 'error');
return;
}
setSubmitting(true);
try {
const payload = {
nombre: name.trim(),
descripcion: description.trim(),
permission_ids: [...selectedPermIds],
};
if (isEditing) {
await updateRole(id, payload);
showMessage('Perfil actualizado correctamente', 'success');
} else {
await createRole(payload);
showMessage('Perfil creado correctamente', 'success');
}
navigate('/profiles');
} catch (err) {
showMessage(err.message || 'Error al guardar perfil', 'error');
} finally {
setSubmitting(false);
}
};
if (loadingRole) {
return (
<div className="min-h-screen p-4 sm:p-6 bg-gradient-to-br from-gray-50 to-blue-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600" />
</div>
);
}
return (
<div className="min-h-screen p-4 sm:p-6 bg-gradient-to-br from-gray-50 to-blue-50">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8 relative overflow-hidden rounded-2xl shadow bg-gradient-to-r from-indigo-600 via-indigo-700 to-indigo-800 border border-indigo-200 p-6 sm:p-8 flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6 fade-in-up-profiles">
<div className="flex-shrink-0 bg-white/20 backdrop-blur-sm rounded-full p-3 sm:p-4 shadow-lg animate-bounce-slow">
<svg className="h-8 w-8 sm:h-10 sm:w-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div className="flex-1">
<h1 className="text-2xl sm:text-3xl font-extrabold text-white tracking-tight mb-1">
{isEditing ? 'Editar Perfil' : 'Nuevo Perfil'}
</h1>
<p className="text-sm sm:text-base text-white/80 font-medium">
{isEditing ? `Editando: ${name}` : 'Define nombre y permisos del nuevo perfil'}
</p>
</div>
<button
type="button"
onClick={() => navigate('/profiles')}
className="flex-shrink-0 inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white text-sm font-medium rounded-lg border border-white/30 transition-all duration-200"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Regresar
</button>
<div className="absolute -top-10 -right-10 opacity-20 pointer-events-none select-none">
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="white" fillOpacity="0.15" />
</svg>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Datos del perfil */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 fade-in-up-profiles" style={{ animationDelay: '0.05s' }}>
<div className="flex items-center mb-4 pb-3 border-b border-gray-200">
<div className="bg-indigo-600 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Datos del perfil</h4>
<p className="text-xs text-slate-500">Identifica el perfil dentro de la organización</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl">
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">
Nombre <span className="text-red-600">*</span>
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
disabled={isAdminRole}
placeholder="Ej. Operador de VUCEM"
className="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all bg-white text-slate-900 placeholder-slate-400 text-sm disabled:bg-slate-100 disabled:text-slate-500"
/>
{isAdminRole && (
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l2.09 6.26L20 10l-5 4.87L16.18 22 12 18.77 7.82 22 9 14.87 4 10l5.91-1.74z" />
</svg>
Perfil de administrador el nombre es fijo
</p>
)}
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">Descripción</label>
<input
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Descripción opcional del perfil"
className="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all bg-white text-slate-900 placeholder-slate-400 text-sm"
/>
</div>
</div>
</div>
{/* Permisos */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 fade-in-up-profiles" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-200">
<div className="flex items-center">
<div className="bg-indigo-600 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Permisos</h4>
<p className="text-xs text-slate-500">
{loadingPerms
? 'Cargando catálogo…'
: `${selectedPermIds.size} de ${totalPerms} permisos seleccionados`}
</p>
</div>
</div>
{!loadingPerms && (
<div className="flex gap-2">
<button
type="button"
onClick={selectAll}
className="px-2.5 py-1 text-xs font-medium text-indigo-700 bg-indigo-50 hover:bg-indigo-100 rounded-md border border-indigo-200 transition-colors"
>
Todos
</button>
<button
type="button"
onClick={clearAll}
className="px-2.5 py-1 text-xs font-medium text-slate-600 bg-slate-50 hover:bg-slate-100 rounded-md border border-slate-200 transition-colors"
>
Ninguno
</button>
</div>
)}
</div>
{loadingPerms ? (
<div className="flex items-center justify-center py-10">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600" />
</div>
) : modules.length === 0 ? (
<div className="text-center py-10 text-slate-400 text-sm">
No se pudo cargar el catálogo de permisos
</div>
) : (
<div className="space-y-5">
{modules.map(module => {
const modulePerms = permsByModule[module] || [];
const selectedCount = modulePerms.filter(p => selectedPermIds.has(p.id)).length;
const allSelected = selectedCount === modulePerms.length && modulePerms.length > 0;
const someSelected = selectedCount > 0 && !allSelected;
return (
<div key={module} className="border border-slate-100 rounded-xl overflow-hidden">
{/* Cabecera módulo */}
<div
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer select-none transition-colors ${
allSelected ? 'bg-indigo-50' : someSelected ? 'bg-slate-50' : 'bg-slate-50/50'
}`}
onClick={() => toggleModule(module)}
>
<div className="flex items-center gap-2">
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
allSelected
? 'bg-indigo-600 border-indigo-600'
: someSelected
? 'bg-indigo-200 border-indigo-400'
: 'bg-white border-slate-300'
}`}>
{allSelected && (
<svg className="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7" />
</svg>
)}
{someSelected && (
<div className="w-1.5 h-0.5 bg-indigo-600 rounded" />
)}
</div>
<span className="text-sm font-semibold text-slate-700">
{MODULE_LABELS[module] ?? module}
</span>
</div>
<span className="text-xs text-slate-400 font-mono">
{selectedCount}/{modulePerms.length}
</span>
</div>
{/* Permisos del módulo */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-px bg-slate-100">
{modulePerms.map(perm => {
const active = selectedPermIds.has(perm.id);
return (
<button
key={perm.id}
type="button"
onClick={() => togglePerm(perm.id)}
className={`flex items-center gap-2 px-3 py-2 text-left transition-colors duration-100 ${
active ? 'bg-indigo-50' : 'bg-white hover:bg-slate-50'
}`}
>
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0 transition-colors ${
active ? 'bg-indigo-600 border-indigo-600' : 'bg-white border-slate-300'
}`}>
{active && (
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span
className={`text-xs truncate ${active ? 'text-indigo-800 font-medium' : 'text-slate-600'}`}
title={perm.descripcion}
>
{perm.descripcion}
</span>
</button>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Botones */}
<div className="flex flex-col sm:flex-row justify-end gap-3 pb-8 fade-in-up-profiles" style={{ animationDelay: '0.15s' }}>
<button
type="button"
onClick={() => navigate('/profiles')}
disabled={submitting}
className="w-full sm:w-auto px-6 py-2.5 border border-slate-300 rounded-lg shadow-sm text-sm font-semibold text-slate-700 bg-white hover:bg-slate-50 transition-all duration-200 disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting || loadingPerms}
className="w-full sm:w-auto px-6 py-2.5 rounded-lg shadow-lg text-sm font-semibold text-white bg-gradient-to-r from-indigo-600 to-indigo-800 hover:from-indigo-700 hover:to-indigo-900 transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={isEditing ? 'M5 13l4 4L19 7' : 'M12 6v6m0 0v6m0-6h6m-6 0H6'} />
</svg>
<span>
{submitting
? (isEditing ? 'Guardando...' : 'Creando...')
: (isEditing ? 'Guardar cambios' : 'Crear perfil')}
</span>
</button>
</div>
</form>
</div>
</div>
);
}