feature/rbac y perfiles implementados
This commit is contained in:
416
src/pages/ProfileForm.jsx
Normal file
416
src/pages/ProfileForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user