Refactored projects page, fixed security user modal #147
@@ -78,7 +78,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
|
||||
// Component state
|
||||
const [internalCreateModalOpen, setInternalCreateModalOpen] = useState(false);
|
||||
const isCreateModalOpen = isCreateModalOpenExternal ?? internalCreateModalOpen;
|
||||
const isCreateModalOpen = Boolean(isCreateModalOpenExternal) || internalCreateModalOpen;
|
||||
const setCreateModalOpen = (open: boolean) => {
|
||||
if (onCloseCreateModalExternal && !open) {
|
||||
onCloseCreateModalExternal();
|
||||
@@ -652,7 +652,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
||||
<p className="text-slate-500 max-w-sm">
|
||||
{searchTerm || statusFilter !== 'all'
|
||||
? 'No contracts match the current search filters. Try resetting the filters.'
|
||||
: 'No contracts have been created yet. Click "Create Contract & Upload PDF" to link an approved award.'}
|
||||
: 'No contracts have been created yet. Click "Create Contract" to link an approved award.'}
|
||||
</p>
|
||||
{!(searchTerm || statusFilter !== 'all') && (
|
||||
<button
|
||||
|
||||
@@ -94,7 +94,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
<div className="p-5 sm:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200">
|
||||
{/* Project Meta Bar */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 rounded-2xl bg-[#0d121f] border border-slate-800">
|
||||
<div className="space-y-1 max-w-3xl">
|
||||
<div className="space-y-1 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30">
|
||||
Project #{project.id} Summary
|
||||
@@ -145,7 +145,7 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
/>
|
||||
|
||||
{/* 4 Vital Resource Summary Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{/* Card 1: Milestones */}
|
||||
<div className="bg-[#0d121f] rounded-2xl border border-slate-800 p-4 flex flex-col justify-between space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
|
||||
@@ -124,12 +124,12 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
<div className="bg-[#0b101c] rounded-2xl border border-slate-800/80 p-5 space-y-5 overflow-hidden shadow-inner">
|
||||
{/* Timeline Header & Key Stats */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-4 border-b border-slate-800/80">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-8 w-8 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div className="h-8 w-8 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400 shrink-0">
|
||||
<Calendar className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2">
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2 flex-wrap">
|
||||
Implementation Schedule & Timeline
|
||||
<span className="text-[10px] normal-case font-normal text-slate-400">
|
||||
({timelineData.totalDurationDays} Days Span)
|
||||
@@ -142,17 +142,17 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Quick Legend & Actions */}
|
||||
<div className="flex items-center gap-3 text-[11px] flex-wrap">
|
||||
<div className="flex items-center gap-3 px-3 py-1 bg-slate-900/80 rounded-xl border border-slate-800 text-slate-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-2 text-[11px] flex-wrap shrink-0">
|
||||
<div className="flex items-center gap-3 px-3 py-1 bg-slate-900/80 rounded-xl border border-slate-800 text-slate-400 flex-wrap">
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block shadow-sm shadow-emerald-500/50" />
|
||||
Verified Deliverable
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block shadow-sm shadow-amber-500/50" />
|
||||
Pending / Review
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span className="w-2.5 h-2.5 rounded-sm bg-blue-500 inline-block shadow-sm shadow-blue-500/50" />
|
||||
Work Activity
|
||||
</span>
|
||||
@@ -206,7 +206,7 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
|
||||
{/* Layer 1: Milestones Track */}
|
||||
<div className="relative z-10 mb-6 space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1">
|
||||
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1 gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-1.5 text-blue-400">
|
||||
<Flag className="w-3.5 h-3.5" />
|
||||
Contractual Deliverables & Milestones ({milestones.length})
|
||||
@@ -214,7 +214,7 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageMilestones}
|
||||
className="text-[10px] text-blue-400 hover:text-blue-300 hover:underline cursor-pointer flex items-center gap-1 font-medium"
|
||||
className="text-[10px] text-blue-400 hover:text-blue-300 hover:underline cursor-pointer flex items-center gap-1 font-medium whitespace-nowrap shrink-0"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> Add / Manage Milestones
|
||||
</button>
|
||||
@@ -295,7 +295,7 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
|
||||
{/* Layer 2: Activities Track */}
|
||||
<div className="relative z-10 space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1">
|
||||
<div className="flex items-center justify-between text-[11px] font-semibold text-slate-300 px-1 gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-1.5 text-cyan-400">
|
||||
<Briefcase className="w-3.5 h-3.5" />
|
||||
Work Breakdown Activities ({activities.length})
|
||||
@@ -303,7 +303,7 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageActivities}
|
||||
className="text-[10px] text-cyan-400 hover:text-cyan-300 hover:underline cursor-pointer flex items-center gap-1 font-medium"
|
||||
className="text-[10px] text-cyan-400 hover:text-cyan-300 hover:underline cursor-pointer flex items-center gap-1 font-medium whitespace-nowrap shrink-0"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> Add / Manage Activities
|
||||
</button>
|
||||
@@ -366,7 +366,7 @@ export const ProjectTimelineGantt: React.FC<ProjectTimelineGanttProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Bottom Summary KPI Bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 pt-2">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-3 pt-2">
|
||||
<div className="p-3 bg-[#090d16] rounded-xl border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 block">Project Duration</span>
|
||||
<span className="text-sm font-bold text-white mt-0.5 block">
|
||||
|
||||
@@ -24,20 +24,17 @@ import {
|
||||
assignUserRole,
|
||||
removeUserRole,
|
||||
getUserClaims,
|
||||
addUserClaim,
|
||||
deleteUserClaim,
|
||||
forceLogoutUser,
|
||||
setUserLockout,
|
||||
updateUserStatus,
|
||||
getSystemClaims,
|
||||
} from '../../services/api';
|
||||
import { clearCachedUserRbac } from '../../services/roleClaimsCache';
|
||||
import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/roleClaimsCache';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
||||
import { decodeIdNumberHex } from '../../utils/hashids';
|
||||
import { User, Role, Claim, ActivityStatus } from '../../types';
|
||||
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
|
||||
import { useRbac, ROLE_CLAIM_DEFINITIONS, CLAIM_ACTION_OPTIONS } from '../../auth/rbac';
|
||||
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
|
||||
|
||||
interface UserSecurityModalProps {
|
||||
user: User | null;
|
||||
@@ -74,9 +71,6 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
|
||||
|
||||
// Claim creation form state
|
||||
const [newClaimType, setNewClaimType] = useState('');
|
||||
const [newClaimValue, setNewClaimValue] = useState('');
|
||||
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
|
||||
|
||||
const userId = user?.id || '';
|
||||
@@ -102,23 +96,106 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// 4. Fetch user claims
|
||||
const { data: userClaims = [], isLoading: loadingUserClaims, refetch: refetchUserClaims } = useQuery<Claim[]>({
|
||||
queryKey: ['user-claims', userId],
|
||||
// Effective role derivation for hooks (Single-role policy)
|
||||
const rawRoleValue = freshUser?.role || user?.role;
|
||||
const userRoleStr = rawRoleValue != null ? String(rawRoleValue).trim() : '';
|
||||
|
||||
const assignedRole = React.useMemo(() => {
|
||||
if (Array.isArray(userRoles) && userRoles.length > 0) {
|
||||
return userRoles[0];
|
||||
}
|
||||
if (userRoleStr && Array.isArray(systemRoles)) {
|
||||
const match = systemRoles.find(
|
||||
(r) =>
|
||||
r &&
|
||||
((r.name && r.name.toLowerCase() === userRoleStr.toLowerCase()) ||
|
||||
String(r.id) === userRoleStr)
|
||||
);
|
||||
if (match) return match;
|
||||
return { id: userRoleStr, name: userRoleStr } as Role;
|
||||
}
|
||||
return null;
|
||||
}, [userRoles, userRoleStr, systemRoles]);
|
||||
|
||||
const assignedRoleId = assignedRole?.id || (assignedRole as any)?.Id || '';
|
||||
const assignedRoleName = assignedRole?.name || (assignedRole as any)?.Name || '';
|
||||
|
||||
// 4. Fetch claims derived from the user's role by hitting the endpoint
|
||||
const {
|
||||
data: roleClaims = [],
|
||||
isLoading: loadingRoleClaims,
|
||||
refetch: refetchRoleClaims,
|
||||
} = useQuery<Claim[]>({
|
||||
queryKey: ['role-claims-detail', assignedRoleId, assignedRoleName],
|
||||
queryFn: async () => {
|
||||
if (!assignedRoleId && !assignedRoleName) return [];
|
||||
return await fetchAndCacheRoleClaims(String(assignedRoleId), assignedRoleName, true);
|
||||
},
|
||||
enabled: isOpen && (!!assignedRoleId || !!assignedRoleName),
|
||||
});
|
||||
|
||||
// 5. Fetch direct user claims if any
|
||||
const {
|
||||
data: directClaims = [],
|
||||
isLoading: loadingDirectClaims,
|
||||
refetch: refetchDirectClaims,
|
||||
} = useQuery<Claim[]>({
|
||||
queryKey: ['user-direct-claims', userId],
|
||||
queryFn: () => (userId ? getUserClaims(userId) : []),
|
||||
enabled: isOpen && !!userId,
|
||||
});
|
||||
|
||||
// 5. Fetch all system claims across the identity management system (Requirement 4 & 7)
|
||||
const { data: systemClaims = [], isLoading: loadingSystemClaims } = useQuery<Claim[]>({
|
||||
queryKey: ['system-claims'],
|
||||
queryFn: getSystemClaims,
|
||||
enabled: isOpen,
|
||||
});
|
||||
// Combined claims with metadata
|
||||
const userEffectiveClaims = React.useMemo(() => {
|
||||
const map = new Map<string, Claim & { description?: string; label?: string; source: string }>();
|
||||
|
||||
// Role Mutations
|
||||
// 1. Role claims from endpoint
|
||||
if (Array.isArray(roleClaims)) {
|
||||
roleClaims.forEach((c) => {
|
||||
if (!c) return;
|
||||
const type = (c.claimType || c.type || '').trim();
|
||||
if (!type) return;
|
||||
const def = ROLE_CLAIM_DEFINITIONS.find((d) => d.claim.toLowerCase() === type.toLowerCase());
|
||||
map.set(type.toLowerCase(), {
|
||||
...c,
|
||||
claimType: type,
|
||||
claimValue: c.claimValue || c.value || 'all',
|
||||
label: def?.label || type,
|
||||
description: def?.description || 'Security claim granted via role membership',
|
||||
source: assignedRoleName ? `Role: ${assignedRoleName}` : 'Role Policy',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Direct user claims if any
|
||||
if (Array.isArray(directClaims)) {
|
||||
directClaims.forEach((c) => {
|
||||
if (!c) return;
|
||||
const type = (c.claimType || c.type || '').trim();
|
||||
if (!type) return;
|
||||
if (!map.has(type.toLowerCase())) {
|
||||
const def = ROLE_CLAIM_DEFINITIONS.find((d) => d.claim.toLowerCase() === type.toLowerCase());
|
||||
map.set(type.toLowerCase(), {
|
||||
...c,
|
||||
claimType: type,
|
||||
claimValue: c.claimValue || c.value || 'all',
|
||||
label: def?.label || type,
|
||||
description: def?.description || 'Direct user account claim',
|
||||
source: 'Direct User Claim',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
}, [roleClaims, directClaims, assignedRoleName]);
|
||||
|
||||
// Role Mutations (Strictly single-role per user)
|
||||
const assignRoleMutation = useMutation({
|
||||
mutationFn: (roleId: string) => {
|
||||
if ((Array.isArray(userRoles) ? userRoles : []).length >= 1) {
|
||||
throw new Error('Single-role policy: This user already has an assigned role. Please remove the existing role before assigning a new one.');
|
||||
}
|
||||
const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find(
|
||||
(r) => r && (String(r.id) === String(roleId) || String((r as any).Id) === String(roleId))
|
||||
);
|
||||
@@ -130,8 +207,11 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
},
|
||||
onSuccess: () => {
|
||||
clearCachedUserRbac(userId);
|
||||
success('Role Assigned', 'Role membership updated successfully.');
|
||||
success('Role Assigned', 'Role assigned successfully.');
|
||||
refetchUserRoles();
|
||||
refetchUser();
|
||||
refetchRoleClaims();
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
setSelectedRoleToAdd('');
|
||||
},
|
||||
onError: (err: any) => toastError('Role Assignment Failed', err),
|
||||
@@ -143,44 +223,13 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
clearCachedUserRbac(userId);
|
||||
success('Role Removed', 'Role revoked from user.');
|
||||
refetchUserRoles();
|
||||
refetchUser();
|
||||
refetchRoleClaims();
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
onError: (err: any) => toastError('Role Removal Failed', err),
|
||||
});
|
||||
|
||||
// Claim Mutations
|
||||
const addClaimMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const normalizedType = newClaimType.toLowerCase().trim();
|
||||
if ((normalizedType === 'unrestricted' || normalizedType === 'admin') && hasTenantSelfHelpAccess) {
|
||||
throw new Error('Security policy: Users with tenant self-help access cannot assign Admin or Unrestricted claims.');
|
||||
}
|
||||
return addUserClaim({
|
||||
userId,
|
||||
claimId: Math.floor(Math.random() * 10000) + 1,
|
||||
claimType: newClaimType.trim(),
|
||||
claimValue: newClaimValue.trim(),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
clearCachedUserRbac(userId);
|
||||
success('Claim Granted', 'User claim added successfully.');
|
||||
refetchUserClaims();
|
||||
setNewClaimType('');
|
||||
setNewClaimValue('');
|
||||
},
|
||||
onError: (err: any) => toastError('Claim Creation Failed', err),
|
||||
});
|
||||
|
||||
const deleteClaimMutation = useMutation({
|
||||
mutationFn: (claimId: string | number) => deleteUserClaim(userId, claimId),
|
||||
onSuccess: () => {
|
||||
clearCachedUserRbac(userId);
|
||||
success('Claim Revoked', 'Claim removed from user.');
|
||||
refetchUserClaims();
|
||||
},
|
||||
onError: (err: any) => toastError('Claim Revocation Failed', err),
|
||||
});
|
||||
|
||||
// Session & Lockout Mutations
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: () => forceLogoutUser(userId),
|
||||
@@ -210,56 +259,22 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
onError: (err: any) => toastError('Status Update Failed', err),
|
||||
});
|
||||
|
||||
const assignableClaims = React.useMemo(() => {
|
||||
const claimMap = new Map<string, { claim: string; label: string }>();
|
||||
if (Array.isArray(systemClaims)) {
|
||||
systemClaims.forEach((c) => {
|
||||
if (!c) return;
|
||||
const type = c.claimType || c.type;
|
||||
if (type && typeof type === 'string') {
|
||||
if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) {
|
||||
return;
|
||||
}
|
||||
claimMap.set(type, { claim: type, label: type });
|
||||
}
|
||||
});
|
||||
}
|
||||
if (Array.isArray(ROLE_CLAIM_DEFINITIONS)) {
|
||||
ROLE_CLAIM_DEFINITIONS.forEach((def) => {
|
||||
if (!def || !def.claim) return;
|
||||
const claimStr = String(def.claim);
|
||||
if (hasTenantSelfHelpAccess && (claimStr.toLowerCase() === 'unrestricted' || claimStr.toLowerCase() === 'admin')) {
|
||||
return;
|
||||
}
|
||||
claimMap.set(claimStr, { claim: claimStr, label: `${claimStr} (${def.label || claimStr})` });
|
||||
});
|
||||
}
|
||||
return Array.from(claimMap.values());
|
||||
}, [systemClaims, hasTenantSelfHelpAccess]);
|
||||
|
||||
const assignedRoleIds = React.useMemo(() => {
|
||||
return new Set(
|
||||
(Array.isArray(userRoles) ? userRoles : []).map((r) => r?.id || (r as any)?.Id).filter(Boolean)
|
||||
);
|
||||
}, [userRoles]);
|
||||
|
||||
const availableRoles = React.useMemo(() => {
|
||||
return (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => {
|
||||
if (!r) return false;
|
||||
const rId = r.id || (r as any).Id;
|
||||
const rName = r.name || (r as any).Name || '';
|
||||
if (rId && assignedRoleIds.has(rId)) return false;
|
||||
if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [systemRoles, assignedRoleIds, hasTenantSelfHelpAccess]);
|
||||
}, [systemRoles, hasTenantSelfHelpAccess]);
|
||||
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
const effectiveUser = freshUser || user;
|
||||
const decodedId = effectiveUser?.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null;
|
||||
const effectiveUser: User = freshUser || user;
|
||||
const decodedId = effectiveUser.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -304,7 +319,7 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
}`}
|
||||
>
|
||||
<Key className="w-3.5 h-3.5" />
|
||||
<span>Claims ({(Array.isArray(userClaims) ? userClaims : []).length})</span>
|
||||
<span>Claims ({userEffectiveClaims.length})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -396,69 +411,102 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: Roles */}
|
||||
{/* TAB 2: Roles (Single-Role Policy) */}
|
||||
{activeTab === 'roles' && (
|
||||
<div className="space-y-3">
|
||||
{/* Add Role selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedRoleToAdd}
|
||||
onChange={(e) => setSelectedRoleToAdd(e.target.value)}
|
||||
disabled={loadingSystemRoles || availableRoles.length === 0 || !canManageRolesAndClaims}
|
||||
className="flex-1 bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{availableRoles.length === 0 ? 'All system roles assigned' : 'Select role to assign...'}
|
||||
</option>
|
||||
{availableRoles.map((r: any) => {
|
||||
const roleOptId = r?.id || r?.Id || String(r);
|
||||
const roleOptName = r?.name || r?.Name || String(r);
|
||||
return (
|
||||
<option key={roleOptId} value={roleOptId}>
|
||||
{roleOptName}
|
||||
{/* If user already has a role, enforce single-role policy */}
|
||||
{(Array.isArray(userRoles) ? userRoles : []).length > 0 ? (
|
||||
<div className="p-3 bg-slate-900/80 rounded-xl border border-slate-800 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Shield className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-semibold text-white block">Active Assigned Role (1 of 1 Max)</span>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
The system strictly enforces one role per user. To assign a different role, remove the current role first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-500/10 border border-emerald-500/25 px-2 py-0.5 rounded-full shrink-0 font-semibold">
|
||||
Role Assigned
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
/* Add Role selector when user has no role */
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
Assign System Role (1 Role Max)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedRoleToAdd}
|
||||
onChange={(e) => setSelectedRoleToAdd(e.target.value)}
|
||||
disabled={loadingSystemRoles || availableRoles.length === 0 || !canManageRolesAndClaims || assignRoleMutation.isPending}
|
||||
className="flex-1 bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50 text-xs"
|
||||
>
|
||||
<option value="">
|
||||
{availableRoles.length === 0 ? 'No available roles' : 'Select role to assign...'}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectedRoleToAdd && assignRoleMutation.mutate(selectedRoleToAdd)}
|
||||
disabled={!selectedRoleToAdd || assignRoleMutation.isPending || !canManageRolesAndClaims}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-semibold transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Assign Role</span>
|
||||
</button>
|
||||
</div>
|
||||
{availableRoles.map((r: any) => {
|
||||
const roleOptId = r?.id || r?.Id || String(r);
|
||||
const roleOptName = r?.name || r?.Name || String(r);
|
||||
return (
|
||||
<option key={roleOptId} value={roleOptId}>
|
||||
{roleOptName}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectedRoleToAdd && assignRoleMutation.mutate(selectedRoleToAdd)}
|
||||
disabled={!selectedRoleToAdd || assignRoleMutation.isPending || !canManageRolesAndClaims}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-semibold transition disabled:opacity-50 cursor-pointer shrink-0 text-xs shadow-md shadow-blue-600/20"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>{assignRoleMutation.isPending ? 'Assigning...' : 'Assign Role'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List of currently assigned roles */}
|
||||
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
|
||||
{loadingUserRoles ? (
|
||||
<div className="p-4 text-center text-slate-500">Loading roles...</div>
|
||||
<div className="p-6 text-center text-slate-400 flex flex-col items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 animate-spin text-blue-400" />
|
||||
<span>Loading assigned role...</span>
|
||||
</div>
|
||||
) : (Array.isArray(userRoles) ? userRoles : []).length === 0 ? (
|
||||
<div className="p-4 text-center text-slate-500">No roles assigned to this user.</div>
|
||||
<div className="p-6 text-center text-slate-500 flex flex-col items-center gap-2">
|
||||
<Shield className="w-7 h-7 text-slate-600 mb-0.5" />
|
||||
<p className="font-semibold text-slate-300">No role assigned to this user.</p>
|
||||
<p className="text-[11px] text-slate-500">Select a role above to assign it. Users must have exactly one role.</p>
|
||||
</div>
|
||||
) : (
|
||||
(Array.isArray(userRoles) ? userRoles : []).map((role) => {
|
||||
if (!role) return null;
|
||||
const roleId = role.id || (role as any).Id || String(role);
|
||||
const roleName = role.name || (role as any).Name || String(role);
|
||||
return (
|
||||
<div key={roleId} className="p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-white font-semibold flex items-center gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>{roleName}</span>
|
||||
<div key={roleId} className="p-3.5 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-white font-semibold flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<span className="text-sm">{roleName}</span>
|
||||
<span className="text-[10px] text-blue-400 bg-blue-500/10 border border-blue-500/20 px-2 py-0.5 rounded font-mono font-medium">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-mono">Role ID: {roleId}</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono mt-1">Role ID: {roleId}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRoleMutation.mutate(roleId)}
|
||||
disabled={removeRoleMutation.isPending || !canManageRolesAndClaims}
|
||||
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition disabled:opacity-50 cursor-pointer"
|
||||
title="Revoke Role"
|
||||
className="p-2 text-rose-400 hover:text-white hover:bg-rose-500/20 border border-transparent hover:border-rose-500/30 rounded-xl transition disabled:opacity-50 cursor-pointer shrink-0"
|
||||
title="Remove Role"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -468,127 +516,74 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: Claims */}
|
||||
{/* TAB 3: Claims (Read-Only Inherited from Role) */}
|
||||
{activeTab === 'claims' && (
|
||||
<div className="space-y-3">
|
||||
{/* Add claim inline form */}
|
||||
<div className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">Grant Role Claim / Custom Claim</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono">RBAC Standard Policy</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-[10px] text-slate-400 mb-1 font-medium">Claim Domain</label>
|
||||
<select
|
||||
value={assignableClaims.some((d) => d.claim === newClaimType) ? newClaimType : (newClaimType ? 'custom' : '')}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === 'custom') {
|
||||
setNewClaimType('');
|
||||
} else {
|
||||
setNewClaimType(e.target.value);
|
||||
}
|
||||
}}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">-- Select System Claim --</option>
|
||||
{assignableClaims.map((def) => (
|
||||
<option key={def.claim} value={def.claim}>
|
||||
{def.label}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">-- Custom Claim Type --</option>
|
||||
</select>
|
||||
{(!assignableClaims.some((d) => d.claim === newClaimType) || newClaimType === '') && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Custom Claim Type..."
|
||||
value={newClaimType}
|
||||
onChange={(e) => setNewClaimType(e.target.value)}
|
||||
className="mt-1.5 w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-slate-400 mb-1 font-medium">Claim Value / Action Assignment</label>
|
||||
<select
|
||||
value={CLAIM_ACTION_OPTIONS.some((a) => a.action === newClaimValue) ? newClaimValue : (newClaimValue ? 'custom' : '')}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === 'custom') {
|
||||
setNewClaimValue('');
|
||||
} else {
|
||||
setNewClaimValue(e.target.value);
|
||||
}
|
||||
}}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">-- Select Action Value --</option>
|
||||
{CLAIM_ACTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.action} value={opt.action}>
|
||||
{opt.action}: {opt.description}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">-- Custom Value --</option>
|
||||
</select>
|
||||
{(!CLAIM_ACTION_OPTIONS.some((a) => a.action === newClaimValue) || newClaimValue === '') && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Custom Value (e.g. true)..."
|
||||
value={newClaimValue}
|
||||
onChange={(e) => setNewClaimValue(e.target.value)}
|
||||
className="mt-1.5 w-full bg-slate-900 border border-slate-800 rounded-lg px-2.5 py-1.5 text-white text-xs placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
)}
|
||||
{/* Informational Policy Banner */}
|
||||
<div className="p-3 bg-blue-500/10 rounded-xl border border-blue-500/20 text-xs flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<CheckCircle2 className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-semibold text-white block">Role-Derived Security Claims</span>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
{assignedRoleName ? (
|
||||
<>Claims are inherited directly from the assigned role (<strong className="text-blue-300">{assignedRoleName}</strong>). They cannot be manually added or removed at the individual user level.</>
|
||||
) : (
|
||||
<>Assign a role on the Roles tab to grant security claims to this user.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addClaimMutation.mutate()}
|
||||
disabled={!newClaimType.trim() || !newClaimValue.trim() || addClaimMutation.isPending || !canManageRolesAndClaims}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-semibold transition disabled:opacity-50 cursor-pointer text-xs"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Grant Claim</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-blue-400 bg-blue-500/20 px-2 py-0.5 rounded border border-blue-500/30 shrink-0 font-semibold">
|
||||
Read-Only
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Existing claims list */}
|
||||
{/* Claims list */}
|
||||
<div className="bg-slate-900/60 rounded-xl border border-slate-800 divide-y divide-slate-800/80">
|
||||
{loadingUserClaims ? (
|
||||
<div className="p-4 text-center text-slate-500">Loading user claims...</div>
|
||||
) : (Array.isArray(userClaims) ? userClaims : []).length === 0 ? (
|
||||
<div className="p-4 text-center text-slate-500">No active claims found for user.</div>
|
||||
{loadingRoleClaims || loadingDirectClaims ? (
|
||||
<div className="p-8 text-center text-slate-400 flex flex-col items-center gap-2">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-blue-400" />
|
||||
<span>Loading security claims from role endpoint...</span>
|
||||
</div>
|
||||
) : userEffectiveClaims.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500 flex flex-col items-center gap-2">
|
||||
<Key className="w-8 h-8 text-slate-600 mb-1" />
|
||||
<p className="font-semibold text-slate-300">No active claims found for user.</p>
|
||||
<p className="text-[11px] text-slate-500 max-w-sm">
|
||||
{assignedRoleName
|
||||
? `The assigned role (${assignedRoleName}) does not contain any configured permission claims.`
|
||||
: 'Assign a role to this user on the Roles tab to establish functional permissions.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
(Array.isArray(userClaims) ? userClaims : []).map((claim, idx) => {
|
||||
if (!claim) return null;
|
||||
userEffectiveClaims.map((claim, idx) => {
|
||||
const claimType = claim.claimType || claim.type || 'Custom Claim';
|
||||
const claimVal = claim.claimValue ?? claim.value ?? '';
|
||||
const claimId = claim.claimId || (claim as any).id || claimType || String(idx);
|
||||
const claimVal = claim.claimValue ?? claim.value ?? 'all';
|
||||
return (
|
||||
<div key={idx} className="p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-white font-medium flex items-center gap-1.5">
|
||||
<Key className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>{claimType}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-300 font-mono mt-0.5">
|
||||
Value: <span className="text-emerald-400">{claimVal}</span>
|
||||
{claim.issuer && <span className="text-slate-500 ml-2">({claim.issuer})</span>}
|
||||
<div key={idx} className="p-3.5 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-white font-medium flex items-center gap-2 flex-wrap">
|
||||
<Key className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||||
<span className="font-semibold text-xs text-white">{claim.label || claimType}</span>
|
||||
<span className="text-[10px] font-mono text-slate-400 bg-slate-800 px-1.5 py-0.2 rounded border border-slate-700/60">
|
||||
{claimType}
|
||||
</span>
|
||||
</div>
|
||||
{claim.description && (
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 leading-relaxed">
|
||||
{claim.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-mono">
|
||||
Value: {claimVal}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 hidden sm:inline">
|
||||
{claim.source}
|
||||
</span>
|
||||
</div>
|
||||
{claimType && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteClaimMutation.mutate(claimId)}
|
||||
disabled={deleteClaimMutation.isPending || !canManageRolesAndClaims}
|
||||
className="p-1.5 text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition disabled:opacity-50 cursor-pointer"
|
||||
title="Delete Claim"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -108,7 +108,6 @@ export const MonitoringPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>(
|
||||
tabParam === 'evidence' || tabParam === 'indicators' || tabParam === 'risks' ? tabParam : 'contracts'
|
||||
);
|
||||
const [isCreateContractModalOpen, setIsCreateContractModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const t = searchParams.get('tab');
|
||||
@@ -437,15 +436,6 @@ export const MonitoringPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
||||
{activeTab === 'contracts' && canAdd('grant_financials') && (
|
||||
<button
|
||||
onClick={() => setIsCreateContractModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 rounded-xl shadow-lg shadow-indigo-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Create Contract & Upload PDF</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'indicators' && canAdd('evidence_risks') && (
|
||||
<button
|
||||
onClick={() => setIsIndicatorModalOpen(true)}
|
||||
@@ -541,10 +531,7 @@ export const MonitoringPage: React.FC = () => {
|
||||
|
||||
{/* Tab 0: Contracts & Legal Documents with PDF Upload */}
|
||||
{activeTab === 'contracts' && (
|
||||
<ContractsManagement
|
||||
isCreateModalOpenExternal={isCreateContractModalOpen}
|
||||
onCloseCreateModalExternal={() => setIsCreateContractModalOpen(false)}
|
||||
/>
|
||||
<ContractsManagement />
|
||||
)}
|
||||
|
||||
{/* Tab 1: Project & Milestone Evidence Review Hub */}
|
||||
|
||||
@@ -723,23 +723,23 @@ export const ProjectsPage: React.FC = () => {
|
||||
|
||||
{/* Projects Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs text-slate-300 border-collapse">
|
||||
<table className="w-full text-left text-xs text-slate-300 border-collapse min-w-[960px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800/80 bg-[#090d16] text-[11px] font-semibold text-slate-400 select-none">
|
||||
<th className="py-3 px-3 w-10 text-center"></th>
|
||||
<SortableTh column="name" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4">
|
||||
<SortableTh column="name" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4 min-w-[180px]">
|
||||
Project Name & ID
|
||||
</SortableTh>
|
||||
<SortableTh column="programme" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4">
|
||||
<SortableTh column="programme" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4 min-w-[140px]">
|
||||
Parent Programme
|
||||
</SortableTh>
|
||||
<SortableTh column="duration" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4">
|
||||
<SortableTh column="duration" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4 min-w-[140px] whitespace-nowrap">
|
||||
Schedule (Timeline)
|
||||
</SortableTh>
|
||||
<SortableTh column="progress" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4">
|
||||
<SortableTh column="progress" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4 w-28 whitespace-nowrap">
|
||||
WBS Progress
|
||||
</SortableTh>
|
||||
<SortableTh column="risk" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4">
|
||||
<SortableTh column="risk" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4 min-w-[125px] whitespace-nowrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Schedule Risk</span>
|
||||
<HelpTooltip
|
||||
@@ -750,10 +750,10 @@ export const ProjectsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</SortableTh>
|
||||
<SortableTh column="status" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4">
|
||||
<SortableTh column="status" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3 px-4 w-32 whitespace-nowrap">
|
||||
Status
|
||||
</SortableTh>
|
||||
<th className="py-3 px-4 text-right font-semibold">Resource Management Hub</th>
|
||||
<th className="py-3 px-4 text-right font-semibold whitespace-nowrap min-w-[170px]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 font-medium">
|
||||
@@ -952,8 +952,8 @@ export const ProjectsPage: React.FC = () => {
|
||||
</td>
|
||||
|
||||
{/* Governance Status */}
|
||||
<td className="py-4 px-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="space-y-1">
|
||||
<td className="py-4 px-4 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="w-32">
|
||||
{(() => {
|
||||
const canApproveThis = canApproveRecord('projects_milestones', p.createdBy);
|
||||
const isSelf = isMaker(p.createdBy);
|
||||
@@ -969,16 +969,16 @@ export const ProjectsPage: React.FC = () => {
|
||||
status: Number(e.target.value) as ApprovalStatus,
|
||||
})
|
||||
}
|
||||
className="text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
className="w-full text-xs font-semibold bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed truncate"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => {
|
||||
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
|
||||
const isOptDisabled = isApprovedOpt && !canApproveThis;
|
||||
let reasonText = '';
|
||||
if (isApprovedOpt && !canApprove('projects_milestones')) {
|
||||
reasonText = ' (Requires Update Permission)';
|
||||
reasonText = ' (Requires Update)';
|
||||
} else if (isApprovedOpt && isSelf) {
|
||||
reasonText = ' (Maker-Checker: Cannot approve own record)';
|
||||
reasonText = ' (Maker-Checker)';
|
||||
}
|
||||
return (
|
||||
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
|
||||
@@ -993,57 +993,57 @@ export const ProjectsPage: React.FC = () => {
|
||||
</td>
|
||||
|
||||
{/* Row Level Buttons: Milestones, Activities, Indicators, Risks, Edit */}
|
||||
<td className="py-4 px-4 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1.5 flex-wrap">
|
||||
<td className="py-4 px-4 text-right whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1.5 flex-nowrap">
|
||||
{/* Milestones Modal Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenMilestones(p, e)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-lg transition cursor-pointer shadow-sm"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-semibold text-blue-300 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer shadow-xs shrink-0"
|
||||
title="Manage Contractual Milestones"
|
||||
>
|
||||
<Flag className="w-3 h-3 text-blue-400" />
|
||||
<span>Milestones</span>
|
||||
<Flag className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
<span className="hidden xl:inline">Milestones</span>
|
||||
</button>
|
||||
|
||||
{/* Activities Modal Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenActivities(p, e)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-cyan-300 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 rounded-lg transition cursor-pointer shadow-sm"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-semibold text-cyan-300 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 rounded-xl transition cursor-pointer shadow-xs shrink-0"
|
||||
title="Manage Work Breakdown Activities"
|
||||
>
|
||||
<Briefcase className="w-3 h-3 text-cyan-400" />
|
||||
<span>Activities</span>
|
||||
<Briefcase className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||
<span className="hidden xl:inline">Activities</span>
|
||||
</button>
|
||||
|
||||
{/* Indicators Modal Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenIndicators(p, e)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-indigo-300 hover:text-white bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/30 rounded-lg transition cursor-pointer shadow-sm"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-semibold text-indigo-300 hover:text-white bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/30 rounded-xl transition cursor-pointer shadow-xs shrink-0"
|
||||
title="Manage M&E Performance Indicators"
|
||||
>
|
||||
<Target className="w-3 h-3 text-indigo-400" />
|
||||
<span>Indicators</span>
|
||||
<Target className="w-3.5 h-3.5 text-indigo-400 shrink-0" />
|
||||
<span className="hidden xl:inline">Indicators</span>
|
||||
</button>
|
||||
|
||||
{/* Risks Modal Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenRisks(p, e)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-rose-300 hover:text-white bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/30 rounded-lg transition cursor-pointer shadow-sm"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-semibold text-rose-300 hover:text-white bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/30 rounded-xl transition cursor-pointer shadow-xs shrink-0"
|
||||
title="Manage Risk Register (ISO 31000)"
|
||||
>
|
||||
<ShieldAlert className="w-3 h-3 text-rose-400" />
|
||||
<span>Risks</span>
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-rose-400 shrink-0" />
|
||||
<span className="hidden xl:inline">Risks</span>
|
||||
</button>
|
||||
|
||||
{/* Edit Project Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenEditProject(p, e)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 border border-transparent hover:border-slate-700/60 transition cursor-pointer shrink-0"
|
||||
title="Edit Project Scope & Schedule"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -1640,7 +1640,7 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto min-w-0 w-full">
|
||||
<table className="w-full text-left text-xs border-collapse min-w-[850px]">
|
||||
<table className="w-full text-left text-xs border-collapse min-w-[680px]">
|
||||
<thead>
|
||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
||||
<th className="py-3.5 px-3 w-10 text-center"></th>
|
||||
@@ -1653,29 +1653,23 @@ export const SettingsPage: React.FC = () => {
|
||||
<SortableTh column="action" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
|
||||
Action
|
||||
</SortableTh>
|
||||
<SortableTh column="entityName" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
|
||||
Entity
|
||||
</SortableTh>
|
||||
<SortableTh column="performedBy" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
|
||||
Performed By
|
||||
</SortableTh>
|
||||
<SortableTh column="changes" currentColumn={auditSortCol} direction={auditSortDir} onSort={handleAuditSort} className="py-3.5 px-4">
|
||||
Changes
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-5 text-right font-medium">Inspect</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingAuditLogs ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||
<td colSpan={6} className="py-12 text-center text-slate-500">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
|
||||
Loading governance audit records...
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedAuditLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-12 text-center text-slate-500">
|
||||
<td colSpan={6} className="py-12 text-center text-slate-500">
|
||||
<History className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all'
|
||||
? 'No audit log entries match your filter criteria.'
|
||||
@@ -1727,12 +1721,6 @@ export const SettingsPage: React.FC = () => {
|
||||
{badge.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||
<span className="font-semibold text-slate-200 block">{log.entityName || 'General'}</span>
|
||||
{log.entityId && (
|
||||
<span className="text-[10px] text-slate-500 font-mono">#{log.entityId}</span>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className="py-3.5 px-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -1760,25 +1748,6 @@ export const SettingsPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpandAuditLog(log.id);
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-lg bg-slate-800/90 hover:bg-slate-700 border border-slate-700/80 text-[11px] text-slate-300 hover:text-white transition cursor-pointer"
|
||||
title="Click to toggle changes shelf"
|
||||
>
|
||||
<Layers className="w-3 h-3 text-blue-400" />
|
||||
<span>{changesCount} {changesCount === 1 ? 'change' : 'changes'}</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 text-slate-400 transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-180 text-blue-400' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1798,7 +1767,7 @@ export const SettingsPage: React.FC = () => {
|
||||
{/* Expanding Shelf for Changes Content */}
|
||||
{isExpanded && (
|
||||
<tr className="bg-[#080c14] border-b border-slate-800/80">
|
||||
<td colSpan={8} className="p-4 sm:p-5 bg-gradient-to-r from-[#0d121f] via-[#090d16] to-[#0d121f]">
|
||||
<td colSpan={6} className="p-4 sm:p-5 bg-gradient-to-r from-[#0d121f] via-[#090d16] to-[#0d121f]">
|
||||
<div className="rounded-xl border border-slate-800/90 bg-[#070a12]/90 p-4 space-y-3 shadow-inner">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/80 pb-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user