Files

237 lines
14 KiB
TypeScript

import React, { useState } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, router } from '@inertiajs/react';
import { PageProps } from '@/types';
import { Can } from '@/Components/Can';
import { Portal } from '@/Components/Portal';
import { swal } from '@/lib/swal';
interface RoleData {
id: number;
name: string;
guard_name: string;
permissions: string[];
users_count: number;
}
interface PermissionData {
id: number;
name: string;
group: string;
}
interface RolesPageProps extends PageProps {
roles: RoleData[];
permissions: PermissionData[];
}
export default function RolesIndex({ roles, permissions }: RolesPageProps) {
const groups = [...new Set(permissions.map(p => p.group))];
const [localMatrix, setLocalMatrix] = useState<Record<number, string[]>>(() => {
const m: Record<number, string[]> = {};
roles.forEach(r => { m[r.id] = [...r.permissions]; });
return m;
});
const [dirty, setDirty] = useState<Record<number, boolean>>({});
const [saving, setSaving] = useState<Record<number, boolean>>({});
const [showCreateModal, setShowCreateModal] = useState(false);
const [newRoleName, setNewRoleName] = useState('');
const togglePermission = (roleId: number, permName: string) => {
setLocalMatrix(prev => {
const current = prev[roleId] || [];
const updated = current.includes(permName)
? current.filter(p => p !== permName)
: [...current, permName];
return { ...prev, [roleId]: updated };
});
setDirty(prev => ({ ...prev, [roleId]: true }));
};
const handleSaveRole = async (role: RoleData) => {
setSaving(prev => ({ ...prev, [role.id]: true }));
router.patch(`/roles/${role.id}/permissions`, {
permissions: localMatrix[role.id] || [],
}, {
preserveScroll: true,
onSuccess: () => {
setDirty(prev => ({ ...prev, [role.id]: false }));
setSaving(prev => ({ ...prev, [role.id]: false }));
swal.success('Saved', `Permissions updated for "${role.name}"`);
},
onError: () => {
setSaving(prev => ({ ...prev, [role.id]: false }));
swal.error('Error', 'Failed to update permissions.');
},
});
};
const handleCreateRole = (e: React.SyntheticEvent) => {
e.preventDefault();
if (!newRoleName.trim()) return;
router.post('/roles', { name: newRoleName.trim() }, {
onSuccess: () => {
setShowCreateModal(false);
setNewRoleName('');
swal.success('Created', 'New role created successfully.');
},
onError: () => swal.error('Error', 'Failed to create role.'),
});
};
const handleDeleteRole = async (role: RoleData) => {
const result = await swal.confirmDelete(role.name);
if (!result.isConfirmed) return;
router.delete(`/roles/${role.id}`, {
onSuccess: () => swal.success('Deleted', `Role "${role.name}" has been deleted.`),
onError: () => swal.error('Error', 'Failed to delete role.'),
});
};
return (
<AuthenticatedLayout>
<Head title="Roles & Permissions" />
<div className="flex items-center justify-between mb-8 anim-down">
<div>
<h1 className="text-xl font-bold text-[#3D4E4B] tracking-tight leading-none">Access Control</h1>
<p className="text-sm font-semibold text-gray-400 tracking-tight mt-2">Configure hierarchical roles and granular permissions</p>
</div>
<Can ability="role.manage">
<button onClick={() => setShowCreateModal(true)}
className="h-10 px-6 rounded-xl bg-[#D4A017] text-white text-sm font-bold tracking-tight hover:bg-[#B88B14] transition-all shadow-lg shadow-[#D4A017]/20">
New Role
</button>
</Can>
</div>
{/* Role Summary Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{roles.map((role, idx) => {
const count = (localMatrix[role.id] || []).length;
const isSuperAdmin = role.name === 'super-admin';
return (
<div key={role.id} className="bg-white rounded-2xl border border-gray-100 p-6 shadow-sm anim-up" style={{ animationDelay: `${idx * 0.05}s` }}>
<div className="flex items-center justify-between mb-6">
<span className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg border ${isSuperAdmin ? 'bg-[#3D4E4B] text-white border-[#3D4E4B]' : 'bg-white text-gray-500 border-gray-100'}`}>
{role.name}
</span>
{!isSuperAdmin && (
<Can ability="role.manage">
<button onClick={() => handleDeleteRole(role)} className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-300 hover:text-red-500 hover:bg-red-50 transition-all">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</Can>
)}
</div>
<div className="text-3xl font-black text-[#3D4E4B] tracking-tighter">
{count}
<span className="text-sm text-gray-200 font-bold ml-1 tracking-normal">/ {permissions.length} perms</span>
</div>
<div className="text-[10px] font-black text-gray-300 uppercase tracking-widest mt-2">{role.users_count} Total Active Users</div>
<div className="mt-6 h-1.5 bg-gray-50 rounded-full overflow-hidden">
<div className="h-full bg-[#D4A017] transition-all duration-700" style={{ width: `${(count / permissions.length) * 100}%` }} />
</div>
{dirty[role.id] && (
<button onClick={() => handleSaveRole(role)} disabled={saving[role.id]}
className="mt-6 w-full h-10 bg-[#3D4E4B] text-white text-xs font-black uppercase tracking-widest rounded-xl anim-fade shadow-lg shadow-[#3D4E4B]/20 disabled:opacity-60">
{saving[role.id] ? 'Saving…' : 'Apply Changes'}
</button>
)}
</div>
);
})}
</div>
{/* Permissions Matrix */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden mb-20 anim-up" style={{ animationDelay: '0.2s' }}>
<div className="px-8 py-6 border-b border-gray-50 bg-gray-50/20">
<h3 className="text-sm font-black text-[#3D4E4B] uppercase tracking-widest">Permissions Matrix</h3>
</div>
<div className="overflow-x-auto custom-scrollbar">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-white">
<th className="px-8 py-5 text-[10px] font-black text-gray-400 uppercase tracking-widest w-72 sticky left-0 bg-white z-10 border-b border-gray-50">Functional Permission</th>
{roles.map(role => (
<th key={role.id} className="px-8 py-5 text-center text-[10px] font-black text-[#3D4E4B] uppercase tracking-widest border-b border-gray-50 bg-gray-50/30">{role.name}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{groups.map(group => (
<React.Fragment key={group}>
<tr className="bg-gray-50/10">
<td colSpan={roles.length + 1} className="px-8 py-3">
<span className="text-[10px] font-black text-[#D4A017] uppercase tracking-[0.2em]">{group} Module</span>
</td>
</tr>
{permissions.filter(p => p.group === group).map((perm) => (
<tr key={perm.id} className="hover:bg-gray-50/30 transition-colors">
<td className="px-8 py-4 sticky left-0 bg-white group border-r border-gray-50/50">
<div className="text-sm font-bold text-[#3D4E4B] tracking-tight capitalize">{perm.name.replace('.', ' ')}</div>
<div className="text-[9px] text-gray-300 font-bold uppercase tracking-widest mt-1">{perm.name}</div>
</td>
{roles.map(role => {
const isChecked = (localMatrix[role.id] || []).includes(perm.name);
const isSuperAdmin = role.name === 'super-admin';
return (
<td key={`${role.id}-${perm.id}`} className="px-8 py-4 text-center">
<button
onClick={() => !isSuperAdmin && togglePermission(role.id, perm.name)}
disabled={isSuperAdmin}
className={`w-8 h-8 rounded-xl transition-all flex items-center justify-center mx-auto border-2 ${
isSuperAdmin
? 'bg-gray-50 text-[#3D4E4B] border-gray-100 opacity-40'
: isChecked
? 'bg-[#3D4E4B] text-white shadow-lg shadow-[#3D4E4B]/20 border-[#3D4E4B]'
: 'bg-white text-gray-100 hover:text-[#D4A017] border-gray-100 hover:border-[#D4A017]'
}`}
>
{isChecked ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={4}><path d="M5 13l4 4L19 7" /></svg>
) : (
<div className="w-1.5 h-1.5 rounded-full bg-current" />
)}
</button>
</td>
);
})}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
{/* Create Role Modal */}
{showCreateModal && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[#3D4E4B]/60 backdrop-blur-md anim-fade">
<div className="relative bg-white rounded-3xl shadow-2xl w-full max-w-sm overflow-hidden anim-zoom border border-gray-100">
<div className="px-8 py-6 border-b border-gray-50">
<h3 className="text-base font-black text-[#3D4E4B] tracking-tight">Provision New Role</h3>
</div>
<form onSubmit={handleCreateRole} className="p-8 space-y-8">
<div className="space-y-2">
<label className="text-xs font-black text-gray-400 uppercase tracking-widest ml-1">Internal Role Name</label>
<input type="text" value={newRoleName} onChange={e => setNewRoleName(e.target.value)} placeholder="e.g. auditor"
className="input-field" required autoFocus />
</div>
<div className="flex gap-4">
<button type="button" onClick={() => setShowCreateModal(false)} className="flex-1 h-11 bg-white text-gray-400 text-xs font-black uppercase tracking-widest border border-gray-100 rounded-2xl hover:bg-gray-50 transition-all">Cancel</button>
<button type="submit" className="flex-1 h-11 bg-[#3D4E4B] text-white text-xs font-black uppercase tracking-widest rounded-2xl hover:bg-[#2D3A38] transition-all shadow-lg shadow-[#3D4E4B]/20">Provision</button>
</div>
</form>
</div>
</div>
</Portal>
)}
</AuthenticatedLayout>
);
}