417 lines
19 KiB
JavaScript
417 lines
19 KiB
JavaScript
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>
|
|
);
|
|
}
|