Fixed UI misalignments
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
|
||||
import { NavLink, Outlet, useNavigate, Link, useLocation } from 'react-router-dom';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
import {
|
||||
Inbox,
|
||||
@@ -144,6 +144,27 @@ export const AppLayout: React.FC = () => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
||||
|
||||
const isSectionCollapsed = (path: string, subItems?: SubNavItem[]): boolean => {
|
||||
if (collapsedSections[path] !== undefined) {
|
||||
return collapsedSections[path];
|
||||
}
|
||||
if (subItems?.some((s) => location.pathname.startsWith(s.path))) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const toggleSection = (path: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[path]: !isSectionCollapsed(path),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
@@ -344,26 +365,45 @@ export const AppLayout: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const isCollapsed = isSectionCollapsed(item.path, item.subItems);
|
||||
|
||||
return (
|
||||
<div key={item.path} className="space-y-1">
|
||||
<NavLink
|
||||
to={item.path}
|
||||
end={hasSub}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||
}`
|
||||
}
|
||||
title={item.name}
|
||||
>
|
||||
<Icon className="w-4.5 h-4.5 shrink-0" />
|
||||
<span className="truncate flex-1">{item.name}</span>
|
||||
</NavLink>
|
||||
<div className="flex items-center gap-1">
|
||||
<NavLink
|
||||
to={item.path}
|
||||
end={hasSub}
|
||||
className={({ isActive }) =>
|
||||
`flex-1 flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||
}`
|
||||
}
|
||||
title={item.name}
|
||||
>
|
||||
<Icon className="w-4.5 h-4.5 shrink-0" />
|
||||
<span className="truncate flex-1">{item.name}</span>
|
||||
</NavLink>
|
||||
{hasSub && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => toggleSection(item.path, e)}
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800/80 rounded-lg transition-colors cursor-pointer"
|
||||
title={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
|
||||
aria-label={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-transform duration-200 ${
|
||||
isCollapsed ? '-rotate-90 text-slate-500' : 'rotate-0 text-slate-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sub-items under parent item */}
|
||||
{hasSub && (
|
||||
{hasSub && !isCollapsed && (
|
||||
<div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
|
||||
{item.subItems?.map((subItem) => {
|
||||
const SubIcon = subItem.icon;
|
||||
|
||||
@@ -37,7 +37,7 @@ const activitySchema = z.object({
|
||||
title: z.string().min(3, 'Activity title required'),
|
||||
description: z.string().min(5, 'Description required'),
|
||||
category: z.string().min(2, 'Category required'),
|
||||
amount: z.coerce.number().min(1, 'Amount must be positive'),
|
||||
amount: z.coerce.number().min(0, 'Amount must be non-negative'),
|
||||
milestoneId: z.string().optional().nullable(),
|
||||
performedAt: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getProjectIndicators,
|
||||
getProjectRisks,
|
||||
getBudgets,
|
||||
getDisbursements,
|
||||
} from '../../services/api';
|
||||
import { Project, ApprovalStatus, Budget } from '../../types';
|
||||
import {
|
||||
@@ -86,9 +87,21 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
);
|
||||
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
|
||||
const { data: disbursements = [] } = useQuery({
|
||||
queryKey: ['disbursements', projectId],
|
||||
queryFn: () => getDisbursements(1, 100),
|
||||
});
|
||||
|
||||
const projectDisbursements = disbursements.filter(
|
||||
(d) => String(d.projectId) === String(projectId) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
|
||||
);
|
||||
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
|
||||
|
||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
const projectValue = totalActivityBudget - totalDisbursed;
|
||||
const projectVariance = approvedBudgetTotal - projectValue;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-5 lg:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200 w-full min-w-0">
|
||||
@@ -108,6 +121,19 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
|
||||
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
|
||||
Approved Budget: {formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-xs text-blue-400 font-mono font-semibold shrink-0" title={`Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}`}>
|
||||
Project Value: {formatZAR(projectValue)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span
|
||||
className={`text-xs font-mono font-semibold shrink-0 ${
|
||||
projectVariance >= 0 ? 'text-emerald-400' : 'text-rose-400'
|
||||
}`}
|
||||
title="Approved Budget minus Net Project Value"
|
||||
>
|
||||
Variance: {projectVariance >= 0 ? '+' : ''}{formatZAR(projectVariance)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getProjectIndicators,
|
||||
getProjectRisks,
|
||||
getBudgets,
|
||||
getDisbursements,
|
||||
updateProjectStatus,
|
||||
} from '../../services/api';
|
||||
import { Project, ApprovalStatus, Programme } from '../../types';
|
||||
@@ -150,11 +151,23 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||
);
|
||||
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
|
||||
|
||||
// Milestone Progress
|
||||
// Milestone Progress & Disbursements
|
||||
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
|
||||
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
|
||||
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||
|
||||
const { data: disbursements = [] } = useQuery({
|
||||
queryKey: ['disbursements', project?.id],
|
||||
queryFn: () => getDisbursements(1, 100),
|
||||
enabled: !!project?.id,
|
||||
});
|
||||
const projectDisbursements = disbursements.filter(
|
||||
(d) => String(d.projectId) === String(project?.id) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
|
||||
);
|
||||
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
|
||||
const projectValue = totalActivityBudget - totalDisbursed;
|
||||
const projectVariance = approvedBudgetTotal - projectValue;
|
||||
|
||||
// Schedule Risk
|
||||
const scheduleRisk = useMemo(() => {
|
||||
if (!project) return null;
|
||||
@@ -244,20 +257,20 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
|
||||
Budget: {formatZAR(approvedBudgetTotal)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-blue-400 font-mono font-bold">
|
||||
Project Value: {formatZAR(totalActivityBudget)}
|
||||
<span className="text-blue-400 font-mono font-bold" title={`Activities Total: ${formatZAR(totalActivityBudget)} minus Disbursements: ${formatZAR(totalDisbursed)}`}>
|
||||
Project Value: {formatZAR(projectValue)}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span
|
||||
className={`font-mono font-bold px-2 py-0.5 rounded text-[11px] border ${
|
||||
approvedBudgetTotal - totalActivityBudget >= 0
|
||||
projectVariance >= 0
|
||||
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||||
}`}
|
||||
title="Approved Budget minus Total Project Value"
|
||||
title={`Approved Budget: ${formatZAR(approvedBudgetTotal)} | Net Project Value: ${formatZAR(projectValue)} (Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}) | Variance: ${projectVariance >= 0 ? '+' : ''}${formatZAR(projectVariance)}`}
|
||||
>
|
||||
Variance: {approvedBudgetTotal - totalActivityBudget >= 0 ? '+' : ''}
|
||||
{formatZAR(approvedBudgetTotal - totalActivityBudget)}
|
||||
Variance: {projectVariance >= 0 ? '+' : ''}
|
||||
{formatZAR(projectVariance)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/rol
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
|
||||
import { decodeIdNumberHex } from '../../utils/hashids';
|
||||
import { useAuth } from '../../auth/AuthProvider';
|
||||
import { User, Role, Claim, ActivityStatus } from '../../types';
|
||||
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
|
||||
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
|
||||
@@ -49,9 +50,15 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { user: currentAuthUser } = useAuth();
|
||||
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
|
||||
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
|
||||
|
||||
const userId = user?.id || '';
|
||||
const isSelf =
|
||||
Boolean(currentAuthUser?.id && userId && String(currentAuthUser.id).toLowerCase() === String(userId).toLowerCase()) ||
|
||||
Boolean(currentAuthUser?.email && user?.email && currentAuthUser.email.toLowerCase() === user.email.toLowerCase());
|
||||
|
||||
const canManageRolesAndClaims =
|
||||
canApprove('settings_users') ||
|
||||
canApprove('tenant_management') ||
|
||||
@@ -63,18 +70,17 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
hasTenantSelfHelpAccess ||
|
||||
isUnrestricted;
|
||||
const canChangeStatus =
|
||||
canAdd('settings_users') ||
|
||||
canAdd('tenant_management') ||
|
||||
canAdd('tenant_self_service') ||
|
||||
canApprove('settings_users') ||
|
||||
isUnrestricted;
|
||||
!isSelf &&
|
||||
(canAdd('settings_users') ||
|
||||
canAdd('tenant_management') ||
|
||||
canAdd('tenant_self_service') ||
|
||||
canApprove('settings_users') ||
|
||||
isUnrestricted);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
|
||||
|
||||
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
|
||||
|
||||
const userId = user?.id || '';
|
||||
|
||||
// 1. Fetch fresh user details
|
||||
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
|
||||
queryKey: ['user-detail', userId],
|
||||
@@ -260,7 +266,12 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (newStatus: ActivityStatus) => updateUserStatus(userId, newStatus),
|
||||
mutationFn: (newStatus: ActivityStatus) => {
|
||||
if (isSelf) {
|
||||
throw new Error('Maker-Checker Violation: You cannot alter your own account activity status.');
|
||||
}
|
||||
return updateUserStatus(userId, newStatus);
|
||||
},
|
||||
onSuccess: () => {
|
||||
success('Status Updated', 'User activity status changed.');
|
||||
refetchUser();
|
||||
@@ -396,7 +407,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{effectiveUser?.status === ActivityStatus.Active ? (
|
||||
{isSelf ? (
|
||||
<span
|
||||
className="px-2.5 py-1 text-[11px] font-semibold text-slate-400 bg-slate-900 border border-slate-800 rounded-lg"
|
||||
title="Maker-Checker policy: Administrators cannot change their own account activity status"
|
||||
>
|
||||
Self-Account (Protected)
|
||||
</span>
|
||||
) : effectiveUser?.status === ActivityStatus.Active ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
|
||||
|
||||
Reference in New Issue
Block a user