diff --git a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx index f0f7121..ee9b324 100644 --- a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx +++ b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx @@ -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>({}); + + 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 (
- - `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} - > - - {item.name} - +
+ + `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} + > + + {item.name} + + {hasSub && ( + + )} +
{/* Sub-items under parent item */} - {hasSub && ( + {hasSub && !isCollapsed && (
{item.subItems?.map((subItem) => { const SubIcon = subItem.icon; diff --git a/PostFundManagement.ConsoleUI/src/components/projects/ProjectActivitiesModal.tsx b/PostFundManagement.ConsoleUI/src/components/projects/ProjectActivitiesModal.tsx index 889cad6..a5af078 100644 --- a/PostFundManagement.ConsoleUI/src/components/projects/ProjectActivitiesModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/projects/ProjectActivitiesModal.tsx @@ -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(), }); diff --git a/PostFundManagement.ConsoleUI/src/components/projects/ProjectExpandedRow.tsx b/PostFundManagement.ConsoleUI/src/components/projects/ProjectExpandedRow.tsx index e559e20..a563a6a 100644 --- a/PostFundManagement.ConsoleUI/src/components/projects/ProjectExpandedRow.tsx +++ b/PostFundManagement.ConsoleUI/src/components/projects/ProjectExpandedRow.tsx @@ -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 = ({ ); 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 (
@@ -108,6 +121,19 @@ export const ProjectExpandedRow: React.FC = ({ Approved Budget: {formatZAR(approvedBudgetTotal)} + • + + Project Value: {formatZAR(projectValue)} + + • + = 0 ? 'text-emerald-400' : 'text-rose-400' + }`} + title="Approved Budget minus Net Project Value" + > + Variance: {projectVariance >= 0 ? '+' : ''}{formatZAR(projectVariance)} + )}
diff --git a/PostFundManagement.ConsoleUI/src/components/projects/ProjectViewModal.tsx b/PostFundManagement.ConsoleUI/src/components/projects/ProjectViewModal.tsx index 4a73a63..5e48c10 100644 --- a/PostFundManagement.ConsoleUI/src/components/projects/ProjectViewModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/projects/ProjectViewModal.tsx @@ -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 = ({ ); 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 = ({ Budget: {formatZAR(approvedBudgetTotal)} • - - Project Value: {formatZAR(totalActivityBudget)} + + Project Value: {formatZAR(projectValue)} • = 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)}
diff --git a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx index a919e50..56cf8f2 100644 --- a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx @@ -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 = ({ }) => { 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 = ({ 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({ queryKey: ['user-detail', userId], @@ -260,7 +266,12 @@ export const UserSecurityModal: React.FC = ({ }); 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 = ({
- {effectiveUser?.status === ActivityStatus.Active ? ( + {isSelf ? ( + + Self-Account (Protected) + + ) : effectiveUser?.status === ActivityStatus.Active ? (
@@ -1680,11 +1696,15 @@ export const BeneficiariesPage: React.FC = () => { {...outcomeForm.register('status')} className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer" > - {COMPLIANCE_STATUS_OPTIONS.map((opt) => ( - - ))} + {COMPLIANCE_STATUS_OPTIONS.map((opt) => { + const isMetOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded; + const makerRestricted = editingOutcome && isMetOpt && isMaker(editingOutcome.createdBy); + return ( + + ); + })} diff --git a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx index 029073e..f775f9e 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx @@ -597,7 +597,7 @@ export const GovernancePage: React.FC = () => { organisationUser: '', idNumber: '', type: OrganisationType.Enterprise, - status: ActivityStatus.Active, + status: ActivityStatus.Inactive, }, }); @@ -779,7 +779,7 @@ export const GovernancePage: React.FC = () => { organisationUser: defaultUser?.id || '', idNumber: defaultUser?.idNumber || '', type: OrganisationType.Enterprise, - status: ActivityStatus.Active, + status: ActivityStatus.Inactive, }); setIsQuickAddUserOpen(false); setQuickUserError(null); diff --git a/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx index cdeb34f..05f38dc 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx @@ -124,7 +124,7 @@ export const GrantsPage: React.FC = () => { const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false); const [editingBudget, setEditingBudget] = useState(null); const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false); - const [selectedBudgetStatus, setSelectedBudgetStatus] = useState(ApprovalStatus.Approved); + const [selectedBudgetStatus, setSelectedBudgetStatus] = useState(ApprovalStatus.Pending); const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false); const [editingDisbursement, setEditingDisbursement] = useState(null); @@ -643,8 +643,14 @@ export const GrantsPage: React.FC = () => { handleSort: handleAwardSort, } = useTableSort(filteredAwards, null, null, { referenceNo: (a) => a.referenceNo, - organisationId: (a) => a.organisationName || `Org #${a.organisationId}`, - programmeId: (a) => a.programmeName || `Programme #${a.programmeId}`, + organisationId: (a) => { + const org = organisations.find((o) => String(o.id) === String(a.organisationId)); + return (org?.name || a.organisationName || `Organisation #${a.organisationId}`).toLowerCase(); + }, + programmeId: (a) => { + const prog = programmes.find((p) => String(p.id) === String(a.programmeId)); + return (prog?.name || a.programmeName || `Programme #${a.programmeId}`).toLowerCase(); + }, amount: (a) => a.amount, status: (a) => getApprovalStatusBadge(a.status).label, createdAt: (a) => a.createdAt, @@ -657,7 +663,10 @@ export const GrantsPage: React.FC = () => { handleSort: handleBudgetSort, } = useTableSort(filteredBudgets, null, null, { name: (b) => b.name, - projectId: (b) => b.projectId, + projectId: (b) => { + const proj = projects.find((p) => String(p.id) === String(b.projectId)); + return (proj?.name || `Project #${b.projectId}`).toLowerCase(); + }, amount: (b) => b.amount, createdAt: (b) => b.createdAt, status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label, @@ -671,8 +680,15 @@ export const GrantsPage: React.FC = () => { handleSort: handleDisbSort, } = useTableSort(filteredDisbursements, null, null, { id: (d) => d.id, - projectId: (d) => d.projectId, - milestoneId: (d) => d.milestoneId || 0, + projectId: (d) => { + const proj = projects.find((p) => String(p.id) === String(d.projectId)); + return (proj?.name || `Project #${d.projectId}`).toLowerCase(); + }, + milestoneId: (d) => { + if (!d.milestoneId) return 'Advance Tranche'; + const ms = milestones.find((m) => String(m.id) === String(d.milestoneId)); + return (ms?.name || `Milestone #${d.milestoneId}`).toLowerCase(); + }, amount: (d) => d.amount, paymentReference: (d) => d.paymentReference || '', status: (d) => getApprovalStatusBadge(d.status).label, @@ -907,10 +923,28 @@ export const GrantsPage: React.FC = () => { System ID: #{awd.id} - {awd.organisationName || `Org #${awd.organisationId}`} + {(() => { + const org = organisations.find((o) => String(o.id) === String(awd.organisationId)); + const orgName = org?.name || awd.organisationName || (awd.organisationId ? `Organisation #${awd.organisationId}` : '—'); + return ( +
+ {orgName} + {awd.organisationId && ID: #{awd.organisationId}} +
+ ); + })()} - {awd.programmeName || `Programme #${awd.programmeId}`} + {(() => { + const prog = programmes.find((p) => String(p.id) === String(awd.programmeId)); + const progName = prog?.name || awd.programmeName || (awd.programmeId ? `Programme #${awd.programmeId}` : '—'); + return ( +
+ {progName} + {awd.programmeId && Programme #{awd.programmeId}} +
+ ); + })()} {formatZAR(awd.amount)} @@ -1014,7 +1048,7 @@ export const GrantsPage: React.FC = () => { Budget Line Category - Project ID + Project Approved Amount (ZAR) @@ -1053,7 +1087,18 @@ export const GrantsPage: React.FC = () => { {b.name} ID: #{b.id} - Project #{b.projectId} + + {(() => { + const proj = projects.find((p) => String(p.id) === String(b.projectId)); + const projName = proj?.name || `Project #${b.projectId}`; + return ( +
+ {projName} + ID: #{b.projectId} +
+ ); + })()} + {formatZAR(b.amount)} @@ -1184,7 +1229,7 @@ export const GrantsPage: React.FC = () => { Disbursement ID
- Project ID + Project Milestone Association @@ -1223,9 +1268,32 @@ export const GrantsPage: React.FC = () => { DSB-{d.id} - Project #{d.projectId} + + {(() => { + const proj = projects.find((p) => String(p.id) === String(d.projectId)); + const projName = proj?.name || `Project #${d.projectId}`; + return ( +
+ {projName} + Project ID: #{d.projectId} +
+ ); + })()} + - {d.milestoneId ? `Milestone #${d.milestoneId}` : 'Initial Advance Tranche'} + {(() => { + if (!d.milestoneId) { + return Initial Advance Tranche; + } + const ms = milestones.find((m) => String(m.id) === String(d.milestoneId)); + const msName = ms?.name || `Milestone #${d.milestoneId}`; + return ( +
+ {msName} + Milestone ID: #{d.milestoneId} +
+ ); + })()} {formatZAR(d.amount)} @@ -1564,6 +1632,13 @@ export const GrantsPage: React.FC = () => { toastError('Record Approved', 'Approved awards cannot be modified.'); return; } + if ( + selectedAwardStatus === ApprovalStatus.Approved && + !canApproveRecord('grant_financials', editingAward.createdBy) + ) { + toastError('Maker-Checker Violation', 'Record creators cannot approve their own award records.'); + return; + } updateAwardMutation.mutate({ awardId: editingAward.id, organisationId: values.organisationId, @@ -1652,7 +1727,21 @@ export const GrantsPage: React.FC = () => { > - + {(() => { + const canApproveAwd = canApproveRecord('grant_financials', editingAward?.createdBy); + const isSelf = isMaker(editingAward?.createdBy); + let reason = ''; + if (!canApprove('grant_financials')) { + reason = ' (Requires Update Permission)'; + } else if (isSelf) { + reason = ' (Maker-Checker: Cannot approve own record)'; + } + return ( + + ); + })()} )} diff --git a/PostFundManagement.ConsoleUI/src/pages/InboxPage.tsx b/PostFundManagement.ConsoleUI/src/pages/InboxPage.tsx index 08974d0..ecdae4c 100644 --- a/PostFundManagement.ConsoleUI/src/pages/InboxPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/InboxPage.tsx @@ -148,7 +148,11 @@ export const InboxPage: React.FC = () => { } } } else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') { - setSelectedMessage(folderMessages[0]); + const first = folderMessages[0]; + setSelectedMessage(first); + if (activeFolder === 'inbox' && Number(first.status) !== NotificationStatus.Read) { + markAsRead(first.id); + } } }, [targetIdParam, folderMessages, activeFolder]); @@ -665,6 +669,22 @@ export const InboxPage: React.FC = () => { > + + + {formatDate(item.createdAt)} diff --git a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx index 525fee4..3bdd2f3 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx @@ -42,6 +42,7 @@ import { recordIndicatorActual, getBudgets, getActivities, + getDisbursements, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { HelpTooltip } from '../components/ui/HelpTooltip'; @@ -242,6 +243,11 @@ export const ProjectsPage: React.FC = () => { queryFn: () => getBudgets(1, 100), }); + const { data: allDisbursements = [] } = useQuery({ + queryKey: ['disbursements'], + queryFn: () => getDisbursements(1, 200), + }); + const { data: allProjectActivities = new Map() } = useQuery({ queryKey: ['allProjectActivities', projects.map((p) => p.id)], queryFn: async () => { @@ -311,6 +317,18 @@ export const ProjectsPage: React.FC = () => { return map; }, [allBudgets]); + // Disbursed capital per project + const disbursedByProject = useMemo(() => { + const map = new Map(); + allDisbursements.forEach((d) => { + if (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3) { + const current = map.get(d.projectId) || 0; + map.set(d.projectId, current + (Number(d.amount) || 0)); + } + }); + return map; + }, [allDisbursements]); + // Filtered Projects const filteredProjects = useMemo(() => { return projects.filter((p) => { @@ -380,9 +398,22 @@ export const ProjectsPage: React.FC = () => { return amt > 0 ? formatZAR(amt) : 'R 0'; }, }, + { + header: 'Project Variance (ZAR)', + key: 'id', + format: (_, p) => { + const approvedBudget = approvedBudgetsByProject.get(p.id) || 0; + const pActs = allProjectActivities.get(p.id) || []; + const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0); + const pDisbursedTotal = disbursedByProject.get(p.id) || 0; + const projectValue = pActsTotal - pDisbursedTotal; + const pVariance = approvedBudget - projectValue; + return `${pVariance >= 0 ? '+' : ''}${formatZAR(pVariance)}`; + }, + }, { header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label }, { header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) }, - ], [programmeMap, milestonesByProject, approvedBudgetsByProject]); + ], [programmeMap, milestonesByProject, approvedBudgetsByProject, allProjectActivities, disbursedByProject]); // Table Sorting const customExtractors = { @@ -404,6 +435,14 @@ export const ProjectsPage: React.FC = () => { if (risk.isAtRisk) return 2; return 3; }, + variance: (p: Project) => { + const approvedBudget = approvedBudgetsByProject.get(p.id) || 0; + const pActs = allProjectActivities.get(p.id) || []; + const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0); + const pDisbursedTotal = disbursedByProject.get(p.id) || 0; + const projectValue = pActsTotal - pDisbursedTotal; + return approvedBudget - projectValue; + }, }; const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort( @@ -1007,17 +1046,17 @@ export const ProjectsPage: React.FC = () => { />
- +
Project Variance
- +
Actions @@ -1212,7 +1251,9 @@ export const ProjectsPage: React.FC = () => { const approvedBudget = approvedBudgetsByProject.get(p.id) || 0; const pActs = allProjectActivities.get(p.id) || []; const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0); - const pVariance = approvedBudget - pActsTotal; + const pDisbursedTotal = disbursedByProject.get(p.id) || 0; + const projectValue = pActsTotal - pDisbursedTotal; + const pVariance = approvedBudget - projectValue; const isPos = pVariance >= 0; return ( e.stopPropagation()}> @@ -1222,7 +1263,7 @@ export const ProjectsPage: React.FC = () => { ? '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: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`} + title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Disbursements: ${formatZAR(pDisbursedTotal)} | Project Value: ${formatZAR(projectValue)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`} > {isPos ? `+${formatZAR(pVariance)}` : `-${formatZAR(Math.abs(pVariance))}`} diff --git a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx index 01c6b98..8ba8e97 100644 --- a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx @@ -57,6 +57,7 @@ import { getOrganisations, getAwards, getContracts, + getProjects, getAuditLogs, getNotifications, notifyInviteApproved, @@ -79,6 +80,8 @@ import { NotificationStatus, NotificationPlatform, ActivityStatus, + ApprovalStatus, + ContractStatus, User, Invite, InviteRequest, @@ -110,7 +113,7 @@ export const SettingsPage: React.FC = () => { const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users'); - const { canView, canAdd, canExport } = useRbac(); + const { canView, canAdd, canExport, canApproveRecord, isMaker } = useRbac(); const canViewUsers = canView('settings_users') || canView('tenant_self_service'); const canViewInvites = canView('settings_invites'); const canViewNotifications = canView('settings_notifications'); @@ -240,6 +243,7 @@ export const SettingsPage: React.FC = () => { const [invitedOrgId, setInvitedOrgId] = useState(''); const [inviteAwardId, setInviteAwardId] = useState(''); const [inviteContractId, setInviteContractId] = useState(''); + const [inviteProjectId, setInviteProjectId] = useState(''); const [inviteRecipientUuid, setInviteRecipientUuid] = useState(''); const [inviteMessage, setInviteMessage] = useState(''); const [invitePlatform, setInvitePlatform] = useState(NotificationPlatform.Email); @@ -252,6 +256,10 @@ export const SettingsPage: React.FC = () => { ); const handleApproveInvite = async (inv: Invite, idx: number) => { + if (isMaker(inv.createdBy)) { + toastError('Maker-Checker Violation', 'You cannot approve an invitation that you created.'); + return; + } const inviteKey = inv.id ? String(inv.id) : `inv_${idx}`; markInviteAsApprovedInStorage(inviteKey, currentTenantId); setApprovedInviteIds((prev) => (prev.includes(inviteKey) ? prev : [...prev, inviteKey])); @@ -269,32 +277,6 @@ export const SettingsPage: React.FC = () => { ); }; - // Invites filtering & search - const [inviteSearchTerm, setInviteSearchTerm] = useState(''); - const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all'); - - const { - data: invitesList = [], - isLoading: loadingInvites, - isFetching: isFetchingInvites, - refetch: refetchInvites, - } = useQuery({ - queryKey: ['invites'], - queryFn: () => getInvites(1, 100), - }); - - const filteredInvites = useMemo(() => { - return invitesList.filter((inv) => { - const matchSearch = - !inviteSearchTerm.trim() || - (inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) || - (inv.message && inv.message.toLowerCase().includes(inviteSearchTerm.toLowerCase())); - const matchPlatform = - invitePlatformFilter === 'all' || Number(inv.platform) === Number(invitePlatformFilter); - return matchSearch && matchPlatform; - }); - }, [invitesList, inviteSearchTerm, invitePlatformFilter]); - const { data: organisations = [] } = useQuery({ queryKey: ['organisations'], queryFn: () => getOrganisations(1, 50), @@ -310,6 +292,104 @@ export const SettingsPage: React.FC = () => { queryFn: () => getContracts(1, 50), }); + const { data: projects = [] } = useQuery({ + queryKey: ['projects'], + queryFn: () => getProjects(1, 100), + }); + + // Only awards that are Approved AND have an Approved/Active contract are eligible for invitations + const eligibleAwards = useMemo(() => { + return awards.filter((aw) => { + const isApprovedAward = Number(aw.status) === ApprovalStatus.Approved; + if (!isApprovedAward) return false; + const hasApprovedContract = contracts.some( + (c) => + String(c.awardId) === String(aw.id) && + (Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed) + ); + return hasApprovedContract; + }); + }, [awards, contracts]); + + const handleSelectAward = (awardId: string | number) => { + setInviteAwardId(awardId); + const selected = eligibleAwards.find((a) => String(a.id) === String(awardId)); + if (selected) { + setInvitedOrgId(selected.organisationId); + const matchingContract = contracts.find( + (c) => + String(c.awardId) === String(selected.id) && + (Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed) + ); + if (matchingContract) { + setInviteContractId(matchingContract.id); + } + setInviteProjectId(selected.projectId || ''); + } + }; + + React.useEffect(() => { + if (isSendInviteModalOpen) { + if (eligibleAwards.length > 0) { + const currentAward = + eligibleAwards.find((a) => String(a.id) === String(inviteAwardId)) || eligibleAwards[0]; + setInviteAwardId(currentAward.id); + setInvitedOrgId(currentAward.organisationId); + const matchingContract = contracts.find( + (c) => + String(c.awardId) === String(currentAward.id) && + (Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed) + ); + if (matchingContract) { + setInviteContractId(matchingContract.id); + } + setInviteProjectId(currentAward.projectId || ''); + } else { + setInviteAwardId(''); + setInvitedOrgId(''); + setInviteContractId(''); + setInviteProjectId(''); + } + } + }, [isSendInviteModalOpen, eligibleAwards, contracts]); + + // Invites filtering & search + const [inviteSearchTerm, setInviteSearchTerm] = useState(''); + const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all'); + + const { + data: invitesList = [], + isLoading: loadingInvites, + isFetching: isFetchingInvites, + refetch: refetchInvites, + } = useQuery({ + queryKey: ['invites'], + queryFn: () => getInvites(1, 100), + }); + + const getStoredInvitePlatform = (inviteId?: string | number): NotificationPlatform | undefined => { + if (!inviteId) return undefined; + try { + const raw = localStorage.getItem(`pfm_invite_platform_${inviteId}`); + return raw !== null ? (Number(raw) as NotificationPlatform) : undefined; + } catch { + return undefined; + } + }; + + const storeInvitePlatform = (inviteId: string | number, platform: NotificationPlatform) => { + try { + localStorage.setItem(`pfm_invite_platform_${inviteId}`, String(platform)); + } catch {} + }; + + const resolveUserEmail = (userIdOrUuid?: string | null): string => { + if (!userIdOrUuid) return '—'; + const u = usersDirectory.find((usr) => String(usr.id).toLowerCase() === String(userIdOrUuid).toLowerCase()); + const email = u?.email || getUserEmail(userIdOrUuid); + return email && email !== '—' ? email : String(userIdOrUuid); + }; + const sendInviteMutation = useMutation({ mutationFn: (payload: InviteRequest) => sendInvite(payload), onSuccess: (data, variables) => { @@ -319,8 +399,10 @@ export const SettingsPage: React.FC = () => { setInviteRecipientUuid(''); setInviteMessage(''); + const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`; + storeInvitePlatform(invId, variables.platform); + if (inviteAutoApprove) { - const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`; markInviteAsApprovedInStorage(invId, currentTenantId); setApprovedInviteIds((prev) => (prev.includes(invId) ? prev : [...prev, invId])); @@ -332,6 +414,7 @@ export const SettingsPage: React.FC = () => { organisationId: variables.organisationId, awardId: variables.awardId, contractId: variables.contractId, + projectId: variables.projectId, message: variables.message, }, }).then(() => { @@ -417,6 +500,38 @@ export const SettingsPage: React.FC = () => { handleSort: handleNotifSort, } = useTableSort(filteredNotifications, 'createdAt', 'desc'); + const resolveInvitePlatform = (inv: Invite): NotificationPlatform => { + if (inv.platform !== undefined && inv.platform !== null) { + return Number(inv.platform) as NotificationPlatform; + } + const stored = getStoredInvitePlatform(inv.id); + if (stored !== undefined) return stored; + + const linkedNotif = fetchedNotifications.find( + (n) => + (inv.recipient && n.recipient?.toLowerCase() === inv.recipient?.toLowerCase()) || + (inv.invitedOrganisationId && String(n.organisationId) === String(inv.invitedOrganisationId)) + ); + if (linkedNotif?.platform !== undefined && linkedNotif.platform !== null) { + return Number(linkedNotif.platform) as NotificationPlatform; + } + return NotificationPlatform.Email; + }; + + const filteredInvites = useMemo(() => { + return invitesList.filter((inv) => { + const recipientEmail = resolveUserEmail(inv.recipient).toLowerCase(); + const matchSearch = + !inviteSearchTerm.trim() || + (inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) || + recipientEmail.includes(inviteSearchTerm.toLowerCase()); + const resolvedPlatform = resolveInvitePlatform(inv); + const matchPlatform = + invitePlatformFilter === 'all' || Number(resolvedPlatform) === Number(invitePlatformFilter); + return matchSearch && matchPlatform; + }); + }, [invitesList, inviteSearchTerm, invitePlatformFilter, fetchedNotifications, usersDirectory]); + // ========================================== // 4. Audit Log Ledger State // ========================================== @@ -485,9 +600,22 @@ export const SettingsPage: React.FC = () => { { header: 'Changes / Arguments', key: 'changes', - format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'), + format: (val) => { + if (!val) return '—'; + let str = Array.isArray(val) ? val.join('; ') : typeof val === 'object' ? JSON.stringify(val) : String(val); + str = str.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim(); + return str.length > 250 ? `${str.slice(0, 247)}...` : str; + }, + }, + { + header: 'Error Details', + key: 'error', + format: (val) => { + if (!val) return '—'; + let str = String(val).replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim(); + return str.length > 250 ? `${str.slice(0, 247)}...` : str; + }, }, - { header: 'Error Details', key: 'error', format: (val) => val || '—' }, ]; const uniqueAuditEntities = Array.from( @@ -557,13 +685,25 @@ export const SettingsPage: React.FC = () => { ]; const inviteExportColumns: ExportColumn[] = [ - { header: 'Recipient (UUID)', key: 'recipient' }, - { header: 'Channel / Platform', key: 'platform', format: (val) => getNotificationPlatformLabel(val) }, - { header: 'Invitation Message', key: 'message' }, + { + header: 'Recipient Email', + key: 'recipient', + format: (val) => resolveUserEmail(val), + }, + { + header: 'Channel / Platform', + key: 'platform', + format: (_val, inv) => getNotificationPlatformLabel(resolveInvitePlatform(inv)), + }, { header: 'Target Organisation / Grant', key: 'invitedOrganisationId', - format: (val, inv) => `Org #${val || inv.organisationId || '—'} (Award #${inv.awardId || '—'})`, + format: (val, inv) => { + const orgId = val || inv.organisationId; + const org = organisations.find((o) => String(o.id) === String(orgId)); + const orgName = org?.name || `Organisation #${orgId || '—'}`; + return `${orgName} • Award #${inv.awardId || '—'}`; + }, }, { header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') }, ]; @@ -589,7 +729,15 @@ export const SettingsPage: React.FC = () => { sortColumn: inviteSortCol, sortDirection: inviteSortDir, handleSort: handleInviteSort, - } = useTableSort(filteredInvites, 'createdAt', 'desc'); + } = useTableSort(filteredInvites, 'createdAt', 'desc', { + recipient: (inv: Invite) => resolveUserEmail(inv.recipient), + platform: (inv: Invite) => getNotificationPlatformLabel(resolveInvitePlatform(inv)), + invitedOrganisationId: (inv: Invite) => { + const orgId = inv.invitedOrganisationId || inv.organisationId; + const org = organisations.find((o) => String(o.id) === String(orgId)); + return org?.name || `Organisation #${orgId || ''}`; + }, + }); const getActionBadge = (action?: string | null) => { const act = (action || 'GENERAL_EVENT').toUpperCase(); @@ -1180,14 +1328,11 @@ export const SettingsPage: React.FC = () => { - Recipient (UUID) + Recipient Email Channel - - Message - Target Org / Grant @@ -1205,13 +1350,13 @@ export const SettingsPage: React.FC = () => { {loadingInvites ? ( - + Loading invitations ledger... ) : sortedInvites.length === 0 ? ( - + No invitations recorded yet. Use “Dispatch Invitation” to send one. @@ -1221,22 +1366,34 @@ export const SettingsPage: React.FC = () => { approvedInviteIds.includes(String(inv.id)) || approvedInviteIds.includes(`inv_${idx}`) || approvedInviteIds.includes(String(idx)); + const resolvedPlatform = resolveInvitePlatform(inv); + const userEmail = resolveUserEmail(inv.recipient); + const orgId = inv.invitedOrganisationId || inv.organisationId; + const org = organisations.find((o) => String(o.id) === String(orgId)); + const orgName = org?.name || `Organisation #${orgId || '—'}`; + const isCreator = isMaker(inv.createdBy); return ( - - {inv.recipient} + +
+ + {userEmail} +
+
+ {inv.recipient} +
- {getNotificationPlatformLabel(inv.platform)} + {getNotificationPlatformLabel(resolvedPlatform)} - - {inv.message} - - - Org #{inv.invitedOrganisationId || inv.organisationId} • Award #{inv.awardId} + +
{orgName}
+
+ Award #{inv.awardId || '—'} +
{isApproved ? ( @@ -1260,7 +1417,7 @@ export const SettingsPage: React.FC = () => { Partner Notified - ) : ( + ) : canApproveRecord('settings_invites', inv.createdBy) ? ( + ) : isCreator ? ( + + Maker (Creator) + + ) : ( + + Pending Review + )} @@ -2490,15 +2658,20 @@ export const SettingsPage: React.FC = () => {
{ e.preventDefault(); + if (!inviteAwardId) { + toastError('Policy Violation', 'An approved award with an approved contract must be selected.'); + return; + } if (!inviteRecipientUuid || !inviteMessage) { - toastError('Validation Error', 'Recipient UUID and message content are mandatory.'); + toastError('Validation Error', 'Recipient identifier and message content are mandatory.'); return; } sendInviteMutation.mutate({ organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101), invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102), - awardId: inviteAwardId || (awards[0]?.id ? Number(awards[0].id) : 301), - contractId: inviteContractId || (contracts[0]?.id ? Number(contracts[0].id) : 401), + awardId: inviteAwardId, + contractId: inviteContractId, + projectId: inviteProjectId || 0, recipient: inviteRecipientUuid, message: inviteMessage, platform: invitePlatform, @@ -2506,18 +2679,51 @@ export const SettingsPage: React.FC = () => { }} className="space-y-4" > -
-
- - + {eligibleAwards.length === 0 ? ( +
+ +
+ No Approved Awards Eligible for Invitation + + System governance policy strictly requires that invitations may only be dispatched for awards that have been formally Approved and whose associated contract is Approved & Active. + +
+ ) : null} + +
+
+
+ + +
+ {usersDirectory.length > 0 && ( + Quick-select from registered users + )} +
+ {usersDirectory.length > 0 && ( + + )} { />
+
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+
+ +
+
+
+ +
+ { + const o = organisations.find((org) => String(org.id) === String(invitedOrgId)); + return o ? `${o.name} (#${o.id})` : invitedOrgId ? `Org #${invitedOrgId}` : '—'; + })()} + readOnly + className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed" + /> +
+ +
+
+ +
+ { + const prj = projects.find((p) => String(p.id) === String(inviteProjectId)); + return prj ? `${prj.name || 'Project'} (#${prj.id})` : inviteProjectId ? `Project #${inviteProjectId}` : 'Initial Award Project'; + })()} + readOnly + className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed" + /> +
+
+
@@ -2557,7 +2855,7 @@ export const SettingsPage: React.FC = () => {
{
-
-
-
- - -
- -
- -
-
- - -
- -
-
-
- - {formatFullDate(msg.createdAt)} - +
+ + {formatFullDate(msg.createdAt)} + + +
); }) @@ -1905,16 +1923,30 @@ export function DashboardPage() { />
- {/* Action Buttons: Reply and Close */} + {/* Action Buttons: Reply, Delete, and Close */}
- +
+ + +