457 lines
18 KiB
TypeScript
457 lines
18 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { RolesApi, type PermissionItem, type RoleItem } from '../RolesApi';
|
|
import {
|
|
CustomInput,
|
|
CustomButton,
|
|
CustomCheckBox,
|
|
CustomTextArea,
|
|
CustomAlertBanner,
|
|
CustomSuccessModal,
|
|
Skeleton,
|
|
} from '../../../components/custom';
|
|
import {
|
|
ShieldCheckIcon,
|
|
ArrowLeftIcon,
|
|
LockKeyIcon,
|
|
} from '@phosphor-icons/react';
|
|
|
|
interface AddRolesProps {
|
|
roleToEdit?: RoleItem | null;
|
|
onBack: () => void;
|
|
onSaved: () => void;
|
|
}
|
|
|
|
export default function AddRoles({ roleToEdit, onBack, onSaved }: AddRolesProps) {
|
|
const [name, setName] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [selectedPermissionIds, setSelectedPermissionIds] = useState<Set<string>>(new Set());
|
|
const [groupedPermissions, setGroupedPermissions] = useState<Record<string, PermissionItem[]>>({});
|
|
const [loading, setLoading] = useState(false);
|
|
const [fetching, setFetching] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
|
const [successTitle, setSuccessTitle] = useState('');
|
|
|
|
const isSystemRole = roleToEdit?.isSystem ?? false;
|
|
|
|
useEffect(() => {
|
|
loadPermissions();
|
|
}, [roleToEdit]);
|
|
|
|
const loadPermissions = async () => {
|
|
setFetching(true);
|
|
setError(null);
|
|
try {
|
|
const response = await RolesApi.getPermissions();
|
|
setGroupedPermissions(response.grouped || {});
|
|
|
|
if (roleToEdit) {
|
|
setName(roleToEdit.name);
|
|
setDescription(roleToEdit.description || '');
|
|
|
|
// Fetch full role details to get its permissions
|
|
const detailedRole = await RolesApi.getRoleById(roleToEdit.id);
|
|
const existingIds = (detailedRole.permissions || []).map((p) => p.id);
|
|
setSelectedPermissionIds(new Set(existingIds));
|
|
} else {
|
|
setName('');
|
|
setDescription('');
|
|
setSelectedPermissionIds(new Set());
|
|
}
|
|
} catch (err: any) {
|
|
setError(err?.response?.data?.message || 'Failed to load permissions list.');
|
|
} finally {
|
|
setFetching(false);
|
|
}
|
|
};
|
|
|
|
const togglePermission = (id: string) => {
|
|
if (isSystemRole) return;
|
|
setSelectedPermissionIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const toggleGroup = (_groupName: string, perms: PermissionItem[]) => {
|
|
if (isSystemRole) return;
|
|
const groupIds = perms.map((p) => p.id);
|
|
const allSelected = groupIds.every((id) => selectedPermissionIds.has(id));
|
|
|
|
setSelectedPermissionIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (allSelected) {
|
|
groupIds.forEach((id) => next.delete(id));
|
|
} else {
|
|
groupIds.forEach((id) => next.add(id));
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const selectAll = () => {
|
|
if (isSystemRole) return;
|
|
const allIds = Object.values(groupedPermissions).flatMap((perms) => perms.map((p) => p.id));
|
|
setSelectedPermissionIds(new Set(allIds));
|
|
};
|
|
|
|
const deselectAll = () => {
|
|
if (isSystemRole) return;
|
|
setSelectedPermissionIds(new Set());
|
|
};
|
|
|
|
const handleSubmit = async (e?: React.FormEvent) => {
|
|
if (e) e.preventDefault();
|
|
if (!name.trim()) {
|
|
setError('Role name is required.');
|
|
return;
|
|
}
|
|
|
|
if (selectedPermissionIds.size === 0) {
|
|
setError('Please select at least one permission for this role.');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const permissionIds = Array.from(selectedPermissionIds);
|
|
if (roleToEdit) {
|
|
await RolesApi.updateRole(roleToEdit.id, {
|
|
name: name.trim(),
|
|
description: description.trim(),
|
|
permissionIds,
|
|
});
|
|
setSuccessTitle('Role Updated Successfully.');
|
|
} else {
|
|
await RolesApi.createRole({
|
|
name: name.trim(),
|
|
description: description.trim(),
|
|
permissionIds,
|
|
});
|
|
setSuccessTitle('Role Created Successfully.');
|
|
}
|
|
setShowSuccessModal(true);
|
|
} catch (err: any) {
|
|
setError(err?.response?.data?.message || 'Failed to save role.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const CardHeader = ({ icon: Icon, title }: { icon: any; title: string }) => (
|
|
<div className="flex items-center gap-2 mb-5">
|
|
<div className="w-8 h-8 rounded-lg bg-[#E8F3EF] flex items-center justify-center text-[#1E7D5C]">
|
|
<Icon size={18} />
|
|
</div>
|
|
<h2 className="text-base font-bold text-[#0F172B]">{title}</h2>
|
|
</div>
|
|
);
|
|
|
|
if (fetching) {
|
|
return (
|
|
<div className="flex flex-col h-full bg-white min-h-screen">
|
|
{/* ─── Header Skeleton ────────────────────────────────────────────── */}
|
|
<div className="flex items-center justify-between py-4 bg-white border-b border-gray-200">
|
|
<div className="flex items-start gap-4">
|
|
<div className="mt-1 p-1">
|
|
<Skeleton variant="circular" className="w-5 h-5" />
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Skeleton className="w-48 h-7" />
|
|
<Skeleton className="w-32 h-4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ─── Body Content Skeleton ──────────────────────────────────────── */}
|
|
<div className="flex-1 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-6 pb-32">
|
|
<div className="w-full flex flex-col gap-6">
|
|
{/* Role Information Card Skeleton */}
|
|
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
|
<div className="flex items-center gap-2 mb-5">
|
|
<Skeleton className="w-8 h-8 rounded-lg" />
|
|
<Skeleton className="w-40 h-5" />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
<div className="flex flex-col gap-2">
|
|
<Skeleton className="w-24 h-4" />
|
|
<Skeleton className="w-full h-11" />
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<Skeleton className="w-24 h-4" />
|
|
<Skeleton className="w-full h-11" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Skeleton className="w-24 h-4" />
|
|
<Skeleton className="w-full h-24" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Permissions Matrix Card Skeleton */}
|
|
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton className="w-8 h-8 rounded-lg" />
|
|
<Skeleton className="w-48 h-5" />
|
|
</div>
|
|
<Skeleton className="w-32 h-9 rounded-lg" />
|
|
</div>
|
|
<div className="border border-gray-100 rounded-[12px] p-6 bg-white space-y-4">
|
|
<Skeleton className="w-48 h-6" />
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<Skeleton className="w-full h-20 rounded-lg" />
|
|
<Skeleton className="w-full h-20 rounded-lg" />
|
|
<Skeleton className="w-full h-20 rounded-lg" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-white min-h-screen">
|
|
{/* ─── Header ────────────────────────────────────────────────────── */}
|
|
<div className="flex items-center justify-between py-4 bg-white border-b border-gray-200">
|
|
<div className="flex items-start gap-4">
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
className="mt-1 p-1 hover:bg-gray-100 rounded-full transition-colors text-gray-500"
|
|
>
|
|
<ArrowLeftIcon size={20} />
|
|
</button>
|
|
<div className="flex flex-col">
|
|
<h1 className="text-xl font-bold text-[#0F172B]">
|
|
{roleToEdit ? (isSystemRole ? 'View System Role' : 'Edit Role') : 'Create Custom Role'}
|
|
</h1>
|
|
<span className="text-[13px] text-gray-500">Global Framework Registry</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ─── Body Content ──────────────────────────────────────────────── */}
|
|
<div className="flex-1 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-6 pb-32">
|
|
<div className="w-full flex flex-col gap-6">
|
|
{/* Error Alert */}
|
|
{error && (
|
|
<CustomAlertBanner
|
|
message={error}
|
|
type="error"
|
|
onClose={() => setError(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Role Information Card */}
|
|
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
|
<CardHeader icon={LockKeyIcon} title="Role Information" />
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
<div className="flex flex-col gap-2">
|
|
<CustomInput
|
|
label="Role Name"
|
|
required
|
|
disabled={isSystemRole}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="e.g. Flight Disruption Lead"
|
|
className="!h-11"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<label className="text-[13px] font-semibold text-gray-700">Role Type</label>
|
|
<div className="flex items-center gap-6 h-11">
|
|
<span
|
|
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-semibold ${
|
|
isSystemRole
|
|
? 'bg-amber-50 text-amber-700 border border-amber-200'
|
|
: 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
|
}`}
|
|
>
|
|
{isSystemRole ? 'System Role (Immutable)' : 'Custom Role'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<CustomTextArea
|
|
label="Description"
|
|
disabled={isSystemRole}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="e.g. Manages passenger goodwill offers and compensation evaluations"
|
|
className="!h-24 resize-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Granular Permissions Matrix Card */}
|
|
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<CardHeader icon={ShieldCheckIcon} title="Granular Permissions Matrix" />
|
|
<span className="text-xs font-semibold text-[#1E7D5C] bg-[#E8F3EF] px-2.5 py-1 rounded-full border border-emerald-100 mb-5">
|
|
{selectedPermissionIds.size} Selected
|
|
</span>
|
|
</div>
|
|
|
|
{!isSystemRole && (
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<CustomButton
|
|
variant="outlined"
|
|
size="sm"
|
|
onClick={selectAll}
|
|
className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-emerald-50/50 font-semibold !h-9 text-xs"
|
|
>
|
|
Select All
|
|
</CustomButton>
|
|
<CustomButton
|
|
variant="outlined"
|
|
size="sm"
|
|
onClick={deselectAll}
|
|
className="!border-gray-300 !text-gray-600 hover:!bg-gray-50 font-semibold !h-9 text-xs"
|
|
>
|
|
Deselect All
|
|
</CustomButton>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-6">
|
|
{Object.entries(groupedPermissions).map(([groupName, perms]) => {
|
|
const groupIds = perms.map((p) => p.id);
|
|
const allGroupSelected = groupIds.length > 0 && groupIds.every((id) => selectedPermissionIds.has(id));
|
|
const selectedInGroupCount = perms.filter((p) => selectedPermissionIds.has(p.id)).length;
|
|
|
|
return (
|
|
<div
|
|
key={groupName}
|
|
className="border border-gray-100 rounded-[12px] p-6 bg-white relative transition-all shadow-sm"
|
|
>
|
|
{/* Module Header */}
|
|
<div className="flex items-center justify-between pb-4 mb-4 border-b border-gray-100">
|
|
<div className="flex items-center gap-3">
|
|
<CustomCheckBox
|
|
disabled={isSystemRole}
|
|
checked={allGroupSelected}
|
|
onChange={() => toggleGroup(groupName, perms)}
|
|
/>
|
|
<h3 className="text-base font-bold text-[#0F172B]">{groupName}</h3>
|
|
</div>
|
|
<span className="text-[12px] font-semibold text-[#1E7D5C] bg-[#E8F3EF] px-3 py-1 rounded-full border border-emerald-100">
|
|
{selectedInGroupCount} / {perms.length} selected
|
|
</span>
|
|
</div>
|
|
|
|
{/* Permission Cards Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{perms.map((perm) => {
|
|
const isSelected = selectedPermissionIds.has(perm.id);
|
|
return (
|
|
<div
|
|
key={perm.id}
|
|
onClick={() => togglePermission(perm.id)}
|
|
className={`p-3.5 rounded-[10px] border transition-all cursor-pointer select-none flex items-start gap-3 ${
|
|
isSelected
|
|
? 'bg-emerald-50/80 border-emerald-300 shadow-sm'
|
|
: 'bg-white border-gray-200 hover:border-gray-300 hover:bg-gray-50/50'
|
|
} ${isSystemRole ? 'opacity-80 cursor-default' : ''}`}
|
|
>
|
|
<div className="mt-0.5">
|
|
<CustomCheckBox
|
|
checked={isSelected}
|
|
disabled={isSystemRole}
|
|
onChange={() => togglePermission(perm.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1 overflow-hidden flex-1">
|
|
<span className="text-xs font-bold text-[#0F172B] block truncate">
|
|
{perm.name}
|
|
</span>
|
|
<span className="text-[11px] font-mono text-[#6C766D] bg-gray-100 px-1.5 py-0.5 rounded inline-block truncate max-w-full">
|
|
{perm.code}
|
|
</span>
|
|
{perm.description && (
|
|
<p className="text-[11px] text-[#6C766D] line-clamp-2 mt-1 leading-tight">
|
|
{perm.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ─── Sticky Footer ─────────────────────────────────────────────── */}
|
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-8 py-4 flex items-center justify-between z-10 shadow-[0_-4px_10px_rgba(0,0,0,0.02)]">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-[14px] font-semibold text-gray-600">Permissions Selected:</span>
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-[#E8F3EF] text-[#1E7D5C]">
|
|
{selectedPermissionIds.size} Active
|
|
</span>
|
|
{isSystemRole && (
|
|
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-0.5 rounded border border-amber-200 font-medium">
|
|
System Role (Read-only)
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<CustomButton
|
|
variant="outlined"
|
|
className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-gray-50 font-semibold px-6"
|
|
onClick={onBack}
|
|
disabled={loading}
|
|
>
|
|
Cancel
|
|
</CustomButton>
|
|
{!isSystemRole && (
|
|
<CustomButton
|
|
variant="primary"
|
|
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm disabled:opacity-50"
|
|
onClick={() => handleSubmit()}
|
|
disabled={loading || !name.trim() || selectedPermissionIds.size === 0}
|
|
loading={loading}
|
|
>
|
|
{loading ? 'Saving...' : roleToEdit ? 'Save Changes' : 'Create Role'}
|
|
</CustomButton>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Success Modal */}
|
|
<CustomSuccessModal
|
|
isOpen={showSuccessModal}
|
|
onClose={() => {
|
|
setShowSuccessModal(false);
|
|
onSaved();
|
|
}}
|
|
title={successTitle}
|
|
label="ROLE NAME"
|
|
cohortName={name}
|
|
cohortDescription={description}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|