- Complete UI refactor with charcoal/bone color palette - Add Space Grotesk font for headlines, Inter for body - Update all 24+ pages with new design system - Add LinearPrimitives reusable components - Improve dark mode support throughout - Add subtle micro-animations and transitions
161 lines
6.9 KiB
TypeScript
161 lines
6.9 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Shield, Plus, Edit2, Trash2, Users } from 'lucide-react';
|
|
import { rolesApi, Role } from '../lib/rolesApi';
|
|
import RoleModal from '../components/roles/RoleModal';
|
|
import { PageHeader, EmptyState, ActionButton, CardSkeleton } from '../components/ui/LinearPrimitives';
|
|
|
|
export default function RolesPage() {
|
|
const [roles, setRoles] = useState<Role[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [selectedRole, setSelectedRole] = useState<Role | undefined>(undefined);
|
|
|
|
useEffect(() => {
|
|
loadRoles();
|
|
}, []);
|
|
|
|
const loadRoles = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await rolesApi.getAll();
|
|
setRoles(data);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleEdit = (role: Role) => {
|
|
setSelectedRole(role);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleCreate = () => {
|
|
setSelectedRole(undefined);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleDelete = async (role: Role) => {
|
|
if (role.isSystem) {
|
|
alert('Cannot delete system roles.');
|
|
return;
|
|
}
|
|
if (!confirm(`Delete role "${role.name}"?`)) return;
|
|
|
|
try {
|
|
await rolesApi.delete(role.id);
|
|
loadRoles();
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert('Failed to delete role');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto space-y-6 pb-20 animate-in">
|
|
<PageHeader
|
|
title="Roles & Permissions"
|
|
subtitle="Manage staff access levels"
|
|
actions={
|
|
<button onClick={handleCreate} className="btn btn-primary">
|
|
<Plus size={16} />
|
|
<span className="hidden sm:inline">New Role</span>
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{Array.from({ length: 4 }).map((_, i) => <CardSkeleton key={i} />)}
|
|
</div>
|
|
) : roles.length === 0 ? (
|
|
<EmptyState
|
|
icon={Shield}
|
|
title="No roles configured"
|
|
description="Create your first role to manage permissions."
|
|
action={
|
|
<button onClick={handleCreate} className="btn btn-primary">
|
|
<Plus size={16} />
|
|
Create Role
|
|
</button>
|
|
}
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{roles.map(role => (
|
|
<div key={role.id} className="card card-interactive p-4 flex flex-col h-full group">
|
|
<div className="flex justify-between items-start mb-3">
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-medium text-primary text-sm flex items-center gap-2">
|
|
{role.name}
|
|
{role.isSystem && (
|
|
<span className="badge text-[10px]">System</span>
|
|
)}
|
|
</h3>
|
|
<p className="text-xs text-tertiary mt-1 truncate">
|
|
{role.description || 'No description'}
|
|
</p>
|
|
</div>
|
|
<div className="w-8 h-8 rounded-md bg-accent-muted flex items-center justify-center flex-shrink-0">
|
|
<Shield size={14} className="text-accent" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<h4 className="text-[10px] font-medium text-tertiary uppercase tracking-wider mb-2">
|
|
Access
|
|
</h4>
|
|
<div className="flex flex-wrap gap-1">
|
|
{Object.keys(role.permissions).slice(0, 4).map(res => (
|
|
<span key={res} className="badge-success text-[10px]">
|
|
{res}
|
|
</span>
|
|
))}
|
|
{Object.keys(role.permissions).length > 4 && (
|
|
<span className="badge text-[10px]">
|
|
+{Object.keys(role.permissions).length - 4}
|
|
</span>
|
|
)}
|
|
{Object.keys(role.permissions).length === 0 && (
|
|
<span className="text-xs text-tertiary">No permissions</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-subtle mt-4 pt-3 flex justify-between items-center">
|
|
<div className="flex items-center gap-1 text-xs text-tertiary">
|
|
<Users size={12} />
|
|
<span>{role._count?.users || 0}</span>
|
|
</div>
|
|
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-fast">
|
|
<ActionButton
|
|
icon={Edit2}
|
|
label="Edit"
|
|
onClick={() => handleEdit(role)}
|
|
variant="accent"
|
|
/>
|
|
{!role.isSystem && (
|
|
<ActionButton
|
|
icon={Trash2}
|
|
label="Delete"
|
|
onClick={() => handleDelete(role)}
|
|
variant="destructive"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<RoleModal
|
|
isOpen={isModalOpen}
|
|
onClose={() => setIsModalOpen(false)}
|
|
onSuccess={loadRoles}
|
|
role={selectedRole}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|