520 lines
22 KiB
TypeScript
520 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
|
import {
|
|
Shield,
|
|
Plus,
|
|
Search,
|
|
Trash2,
|
|
X,
|
|
RefreshCw,
|
|
Check,
|
|
Lock,
|
|
ChevronRight,
|
|
Info,
|
|
AlertTriangle,
|
|
Loader2,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { rolePermissionService, RoleResponse, PermissionResponse } from '@/services/api/rolePermissionService';
|
|
import { SlideOver } from '@/components/ui/SlideOver';
|
|
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
|
|
|
|
export default function RolesPage() {
|
|
const [roles, setRoles] = useState<RoleResponse[]>([]);
|
|
const [permissions, setPermissions] = useState<PermissionResponse[]>([]);
|
|
const [selectedRole, setSelectedRole] = useState<RoleResponse | null>(null);
|
|
const [selectedRolePerms, setSelectedRolePerms] = useState<string[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [savingPerms, setSavingPerms] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [newRoleName, setNewRoleName] = useState('');
|
|
const [newRolePrefix, setNewRolePrefix] = useState('');
|
|
const [newRoleDesc, setNewRoleDesc] = useState('');
|
|
const [deleteTarget, setDeleteTarget] = useState<RoleResponse | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [fetchedRoles, fetchedPermissions] = await Promise.all([
|
|
rolePermissionService.getRoles(),
|
|
rolePermissionService.getPermissions(),
|
|
]);
|
|
setRoles(fetchedRoles);
|
|
setPermissions(fetchedPermissions);
|
|
|
|
const defaultRole =
|
|
fetchedRoles.find((r) => r.role_name.toLowerCase() !== 'super admin') || fetchedRoles[0];
|
|
if (defaultRole) {
|
|
await selectRole(defaultRole, fetchedPermissions);
|
|
}
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to load roles and permissions data';
|
|
toast.error(message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
const selectRole = async (role: RoleResponse, allPerms: PermissionResponse[] = permissions) => {
|
|
setSelectedRole(role);
|
|
if (role.role_name.toLowerCase() === 'super admin') {
|
|
setSelectedRolePerms(allPerms.map((p) => p.permission_id));
|
|
return;
|
|
}
|
|
try {
|
|
const assignedIds = await rolePermissionService.getRolePermissions(role.role_id);
|
|
setSelectedRolePerms(assignedIds);
|
|
} catch {
|
|
toast.error(`Failed to fetch permissions for ${role.role_name}`);
|
|
}
|
|
};
|
|
|
|
const handleAddRole = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newRoleName.trim()) {
|
|
toast.error('Role name is required');
|
|
return;
|
|
}
|
|
if (!newRolePrefix.trim() || newRolePrefix.length > 5) {
|
|
toast.error('Role prefix is required (max 5 letters)');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSubmitting(true);
|
|
const created = await rolePermissionService.createRole({
|
|
role_name: newRoleName.trim(),
|
|
role_prefix: newRolePrefix.trim().toUpperCase(),
|
|
description: newRoleDesc.trim() || undefined,
|
|
});
|
|
setRoles([...roles, created]);
|
|
toast.success(`Role "${created.role_name}" created successfully`);
|
|
setNewRoleName('');
|
|
setNewRolePrefix('');
|
|
setNewRoleDesc('');
|
|
setShowAddModal(false);
|
|
await selectRole(created);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to create role';
|
|
toast.error(message);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteRole = async () => {
|
|
if (!deleteTarget) return;
|
|
try {
|
|
setIsDeleting(true);
|
|
await rolePermissionService.deleteRole(deleteTarget.role_id);
|
|
toast.success(`Role "${deleteTarget.role_name}" deleted`);
|
|
setRoles(roles.filter((r) => r.role_id !== deleteTarget.role_id));
|
|
if (selectedRole?.role_id === deleteTarget.role_id) {
|
|
setSelectedRole(null);
|
|
setSelectedRolePerms([]);
|
|
}
|
|
setDeleteTarget(null);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to delete role';
|
|
toast.error(message);
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
const handleTogglePermission = (permissionId: string) => {
|
|
if (!selectedRole || selectedRole.role_name.toLowerCase() === 'super admin') return;
|
|
if (selectedRolePerms.includes(permissionId)) {
|
|
setSelectedRolePerms(selectedRolePerms.filter((id) => id !== permissionId));
|
|
} else {
|
|
setSelectedRolePerms([...selectedRolePerms, permissionId]);
|
|
}
|
|
};
|
|
|
|
const handleSavePermissions = async () => {
|
|
if (!selectedRole) return;
|
|
setSavingPerms(true);
|
|
try {
|
|
await rolePermissionService.updateRolePermissions(selectedRole.role_id, selectedRolePerms);
|
|
toast.success(`Permissions for "${selectedRole.role_name}" updated successfully`);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to update role permissions';
|
|
toast.error(message);
|
|
} finally {
|
|
setSavingPerms(false);
|
|
}
|
|
};
|
|
|
|
const permissionsByModule = useMemo(() => {
|
|
const groups: Record<string, PermissionResponse[]> = {};
|
|
permissions.forEach((p) => {
|
|
const moduleName = p.module.charAt(0).toUpperCase() + p.module.slice(1);
|
|
if (!groups[moduleName]) groups[moduleName] = [];
|
|
groups[moduleName].push(p);
|
|
});
|
|
return groups;
|
|
}, [permissions]);
|
|
|
|
const filteredRoles = useMemo(() => {
|
|
return roles.filter((r) => r.role_name.toLowerCase().includes(searchQuery.toLowerCase()));
|
|
}, [roles, searchQuery]);
|
|
|
|
const pager = useClientPagination(filteredRoles);
|
|
|
|
const isSuperAdmin = selectedRole?.role_name.toLowerCase() === 'super admin';
|
|
const dataCardShell =
|
|
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden min-w-0';
|
|
const inputClass =
|
|
'w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary';
|
|
const labelClass = 'block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5';
|
|
const primaryButton =
|
|
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50';
|
|
const secondaryButton =
|
|
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control border border-border bg-card text-foreground text-[13px] font-medium hover:bg-muted cursor-pointer disabled:opacity-50';
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5 min-w-0">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-lg font-semibold text-foreground">Roles & Permissions</h1>
|
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none">
|
|
{roles.length}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={fetchData}
|
|
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
|
|
title="Reload"
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAddModal(true)}
|
|
className={primaryButton}
|
|
>
|
|
<span className="w-[18px] h-[18px] rounded-full bg-white text-primary flex items-center justify-center">
|
|
<Plus className="w-3 h-3" strokeWidth={3} />
|
|
</span>
|
|
Add role
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start min-w-0">
|
|
<div className={`lg:col-span-5 ${dataCardShell}`}>
|
|
<div className="p-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search roles"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full h-9 pl-8 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto border-t border-border">
|
|
<table className="crm-data-table">
|
|
<thead>
|
|
<tr className="border-b border-border bg-gray-50">
|
|
<th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Role</th>
|
|
<th className="px-5 py-3 text-[13px] font-semibold text-gray-700 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={2} className="px-5 py-12 text-center">
|
|
<Loader2 className="w-6 h-6 animate-spin text-primary mx-auto" />
|
|
<p className="text-[13px] text-muted-foreground mt-2">Loading roles...</p>
|
|
</td>
|
|
</tr>
|
|
) : filteredRoles.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={2} className="px-5 py-12 text-center text-[13px] text-muted-foreground">
|
|
{searchQuery ? `No roles match “${searchQuery}”` : 'No roles yet'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
pager.items.map((role) => {
|
|
const active = selectedRole?.role_id === role.role_id;
|
|
return (
|
|
<tr
|
|
key={role.role_id}
|
|
onClick={() => selectRole(role)}
|
|
className={`border-b border-border hover:bg-muted/10 cursor-pointer transition-colors ${
|
|
active ? 'bg-primary/5' : ''
|
|
}`}
|
|
>
|
|
<td className="px-5 py-3.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[13px] font-semibold text-foreground">{role.role_name}</span>
|
|
{role.is_system && (
|
|
<span className="inline-flex items-center px-2 py-0.5 crm-radius-badge bg-primary/10 text-primary text-[11px] font-semibold">
|
|
System
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-[12px] text-muted-foreground mt-0.5">
|
|
Prefix: {role.role_prefix}
|
|
{role.description ? ` · ${role.description}` : ''}
|
|
</p>
|
|
</td>
|
|
<td className="px-5 py-3.5 text-right" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex items-center justify-end gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => selectRole(role)}
|
|
className="w-8 h-8 crm-radius-control flex items-center justify-center hover:bg-muted cursor-pointer"
|
|
title="Edit permissions"
|
|
>
|
|
<Shield className="w-4 h-4 text-muted-foreground" />
|
|
</button>
|
|
{!role.is_system && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setDeleteTarget(role)}
|
|
className="w-8 h-8 crm-radius-control flex items-center justify-center hover:bg-destructive/10 cursor-pointer"
|
|
title="Delete role"
|
|
>
|
|
<Trash2 className="w-4 h-4 text-destructive" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<TablePagination {...pager} />
|
|
</div>
|
|
|
|
<div className="lg:col-span-7 min-w-0">
|
|
{selectedRole ? (
|
|
<div className={`${dataCardShell} p-5 space-y-5`}>
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between border-b border-border pb-4 gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="w-4 h-4 text-primary shrink-0" />
|
|
<h2 className="text-[16px] font-semibold text-foreground truncate">{selectedRole.role_name}</h2>
|
|
</div>
|
|
<p className="text-[13px] text-muted-foreground mt-1">
|
|
{selectedRole.description || 'Manage access levels for this role.'}
|
|
</p>
|
|
</div>
|
|
{!isSuperAdmin && (
|
|
<button
|
|
type="button"
|
|
onClick={handleSavePermissions}
|
|
disabled={savingPerms}
|
|
className={primaryButton}
|
|
>
|
|
{savingPerms ? 'Saving...' : 'Save permissions'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isSuperAdmin && (
|
|
<div className="border border-border bg-muted/30 crm-radius-section p-3.5 flex items-start gap-2.5">
|
|
<Lock className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-[13px] font-semibold text-foreground">Full access</p>
|
|
<p className="text-[12px] text-muted-foreground mt-0.5">
|
|
Super Admin has access to every module. Permissions cannot be edited.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-5">
|
|
{Object.keys(permissionsByModule).map((groupName) => (
|
|
<div key={groupName} className="space-y-3">
|
|
<h3 className="text-[13px] font-semibold text-foreground flex items-center gap-2 border-b border-border pb-2">
|
|
<ChevronRight className="w-3.5 h-3.5 text-primary" />
|
|
{groupName}
|
|
</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
|
{permissionsByModule[groupName].map((perm) => {
|
|
const isChecked = selectedRolePerms.includes(perm.permission_id);
|
|
return (
|
|
<button
|
|
key={perm.permission_id}
|
|
type="button"
|
|
onClick={() => !isSuperAdmin && handleTogglePermission(perm.permission_id)}
|
|
disabled={isSuperAdmin}
|
|
className={`p-3 crm-radius-section border flex items-start gap-3 text-left transition-colors ${
|
|
isChecked ? 'bg-primary/5 border-primary/20' : 'bg-card border-border hover:bg-muted/20'
|
|
} ${isSuperAdmin ? 'cursor-not-allowed opacity-80' : 'cursor-pointer'}`}
|
|
>
|
|
<div
|
|
className={`w-4 h-4 mt-0.5 crm-radius-toggle border flex items-center justify-center shrink-0 ${
|
|
isChecked ? 'bg-primary border-primary text-white' : 'border-border text-transparent'
|
|
}`}
|
|
>
|
|
<Check className="w-3 h-3" strokeWidth={3} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<span className="text-[13px] font-semibold text-foreground block">{perm.permission_code}</span>
|
|
<span className="text-[12px] text-muted-foreground mt-0.5 block">
|
|
{perm.description || 'No description'}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className={`${dataCardShell} p-12 text-center flex flex-col items-center justify-center gap-2 min-h-[280px]`}>
|
|
<Info className="w-8 h-8 text-muted-foreground/30" />
|
|
<p className="text-[13px] text-muted-foreground">Select a role to manage its permissions.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<SlideOver
|
|
open={showAddModal}
|
|
onClose={() => !isSubmitting && setShowAddModal(false)}
|
|
title="Add role"
|
|
icon={<Shield className="w-4 h-4 text-primary shrink-0" />}
|
|
>
|
|
<form onSubmit={handleAddRole} className="flex min-h-0 flex-1 flex-col">
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-4">
|
|
<div>
|
|
<label className={labelClass}>
|
|
Role name <span className="text-primary">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
placeholder="e.g. Technician Supervisor"
|
|
value={newRoleName}
|
|
onChange={(e) => setNewRoleName(e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Role prefix <span className="text-primary">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
maxLength={5}
|
|
placeholder="e.g. TEC"
|
|
value={newRolePrefix}
|
|
onChange={(e) => setNewRolePrefix(e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
<p className="text-[12px] text-muted-foreground mt-1">Used for employee codes. Max 5 letters.</p>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Description</label>
|
|
<textarea
|
|
placeholder="Brief role responsibilities..."
|
|
value={newRoleDesc}
|
|
onChange={(e) => setNewRoleDesc(e.target.value)}
|
|
rows={3}
|
|
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="shrink-0 border-t border-border px-5 py-3 flex items-center justify-end gap-2 bg-card">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAddModal(false)}
|
|
disabled={isSubmitting}
|
|
className={secondaryButton}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={isSubmitting} className={primaryButton}>
|
|
{isSubmitting ? 'Creating...' : 'Create role'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</SlideOver>
|
|
|
|
{deleteTarget && (
|
|
<div
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
|
|
onClick={() => !isDeleting && setDeleteTarget(null)}
|
|
>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="delete-role-title"
|
|
className="bg-card border border-border shadow-2xl w-full max-w-sm overflow-hidden crm-radius-none"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2">
|
|
<div className="flex items-start gap-3 min-w-0">
|
|
<div className="w-10 h-10 rounded-full bg-destructive/10 text-destructive flex items-center justify-center shrink-0">
|
|
<AlertTriangle className="w-5 h-5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 id="delete-role-title" className="text-[15px] font-semibold text-foreground">
|
|
Delete role
|
|
</h3>
|
|
<p className="text-[13px] text-muted-foreground mt-1 leading-relaxed">
|
|
Delete <span className="font-semibold text-foreground">"{deleteTarget.role_name}"</span>? This cannot be undone.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="Close delete dialog"
|
|
onClick={() => setDeleteTarget(null)}
|
|
disabled={isDeleting}
|
|
className="w-8 h-8 rounded-full border border-border text-muted-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center shrink-0 disabled:opacity-50"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 px-5 pb-5 pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setDeleteTarget(null)}
|
|
disabled={isDeleting}
|
|
className={secondaryButton}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleDeleteRole}
|
|
disabled={isDeleting}
|
|
className="inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-destructive hover:bg-destructive/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50"
|
|
>
|
|
{isDeleting ? 'Deleting...' : 'Delete'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|