Files
aeroresolve_frontend/src/app/roles/components/AddRoles.tsx
T

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>
);
}