ifixkart-admin/app/(admin)/roles/page.tsx

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">&quot;{deleteTarget.role_name}&quot;</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>
);
}