diff --git a/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx b/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx index 779dd4a..e584410 100644 --- a/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx +++ b/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx @@ -46,6 +46,7 @@ import { getContractSecureLink, getEvidenceSecureLink, submitActivityForApproval, + resolveUserIdByEmail, } from '../../services/api'; import { ApplicantData, @@ -490,7 +491,7 @@ export function DashboardPage() { return (user?.email || (user as any)?.profile?.email || '') || (user as any)?.email || 'Partner User'; }; - const handleReplyToMessage = (notification: Notification) => { + const handleReplyToMessage = async (notification: Notification) => { const senderEmail = getMessageSenderEmail(notification).trim().toLowerCase(); const currentUserEmail = ( (user?.email || (user as any)?.profile?.email || '') || @@ -510,11 +511,20 @@ export function DashboardPage() { ? notification.subject : `Re: ${notification.subject || 'Message'}`; + // Requirement #1: Resolve sender's user ID from TenantUsers by email + let senderUserId: string | undefined = undefined; + if (senderEmail) { + senderUserId = await resolveUserIdByEmail( + senderEmail, + notification.organisationId || partnerSummary.primaryFundingOrg?.id || 1 + ); + } + setContactTarget({ organisation: partnerSummary.primaryFundingOrg, projectTitle: replySubject, recipientEmail: senderEmail, - recipientId: notification.recipient, + recipientId: senderUserId, }); setMessageSubject(replySubject); setMessageBody(''); @@ -1281,10 +1291,11 @@ export function DashboardPage() { await sendMessage({ recipientEmail: targetEmail, recipient: targetEmail, + recipientId: contactTarget.recipientId, subject: subj, message: markdown, platform: NotificationPlatform.InApp, - organisationId: partnerSummary.primaryFundingOrg?.id || undefined, + organisationId: contactTarget.organisation?.id || partnerSummary.primaryFundingOrg?.id || 1, }); showSuccessToast('Message Sent', `Your communication was delivered to ${targetEmail}.`); diff --git a/PostFundManagement.PartnerUI/src/components/dashboard/GovernanceInfoModal.tsx b/PostFundManagement.PartnerUI/src/components/dashboard/GovernanceInfoModal.tsx index 0676ad4..0ee713a 100644 --- a/PostFundManagement.PartnerUI/src/components/dashboard/GovernanceInfoModal.tsx +++ b/PostFundManagement.PartnerUI/src/components/dashboard/GovernanceInfoModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useQuery } from '@tanstack/react-query'; import { @@ -11,13 +11,31 @@ import { Info, Calendar, User, + Users, CheckCircle2, Clock, Layers, Sparkles, + Filter, + MapPin, + Target, } from 'lucide-react'; -import { Project, Milestone, Activity, ComplianceItem, Risk, Rule, ComplianceStatus } from '../../services/api/models'; -import { getProjectComplianceItems, getProjectRisks, getRules } from '../../services/api'; +import { + Project, + Milestone, + Activity, + ComplianceItem, + Risk, + Rule, + Beneficiary, + ComplianceStatus, +} from '../../services/api/models'; +import { + getProjectComplianceItems, + getProjectBeneficiaries, + getProjectRisks, + getRules, +} from '../../services/api'; import { formatDate } from '../../lib/formatters'; interface GovernanceInfoModalProps { @@ -26,7 +44,7 @@ interface GovernanceInfoModalProps { project: Project; milestone?: Milestone | null; activity?: Activity | null; - initialTab?: 'compliance' | 'risks' | 'rules'; + initialTab?: 'compliance' | 'beneficiaries' | 'risks' | 'rules'; } export const GovernanceInfoModal: React.FC = ({ @@ -37,7 +55,8 @@ export const GovernanceInfoModal: React.FC = ({ activity, initialTab = 'compliance', }) => { - const [activeTab, setActiveTab] = useState<'compliance' | 'risks' | 'rules'>(initialTab); + const [activeTab, setActiveTab] = useState<'compliance' | 'beneficiaries' | 'risks' | 'rules'>(initialTab); + const [selectedBeneficiaryFilter, setSelectedBeneficiaryFilter] = useState('ALL'); const projectId = project?.id || 0; @@ -48,20 +67,53 @@ export const GovernanceInfoModal: React.FC = ({ enabled: isOpen && Boolean(projectId), }); - // 2. Fetch risks for project + // 2. Fetch beneficiaries for project (Requirement: explore compliance items by beneficiary) + const { data: projectBeneficiaries = [], isLoading: isLoadingBeneficiaries } = useQuery({ + queryKey: ['governance-beneficiaries', String(projectId)], + queryFn: () => getProjectBeneficiaries(projectId), + enabled: isOpen && Boolean(projectId), + }); + + // 3. Fetch risks for project const { data: projectRisks = [], isLoading: isLoadingRisks } = useQuery({ queryKey: ['governance-risks', String(projectId)], queryFn: () => getProjectRisks(projectId), enabled: isOpen && Boolean(projectId), }); - // 3. Fetch rules + // 4. Fetch associated governance rules const { data: governanceRules = [], isLoading: isLoadingRules } = useQuery({ queryKey: ['governance-rules'], queryFn: () => getRules({ page: 1, pageSize: 100 }), enabled: isOpen, }); + // Map of beneficiary by ID for fast lookup + const beneficiaryMap = useMemo(() => { + const map = new Map(); + for (const b of projectBeneficiaries) { + if (b && b.id !== undefined && b.id !== null) { + map.set(String(b.id), b); + } + } + return map; + }, [projectBeneficiaries]); + + // Filter compliance items by selected beneficiary + const filteredComplianceItems = useMemo(() => { + if (selectedBeneficiaryFilter === 'ALL') { + return complianceItems; + } + if (selectedBeneficiaryFilter === 'PROJECT_WIDE') { + return complianceItems.filter( + item => !item.beneficiaryId || Number(item.beneficiaryId) === 0 + ); + } + return complianceItems.filter( + item => String(item.beneficiaryId) === selectedBeneficiaryFilter + ); + }, [complianceItems, selectedBeneficiaryFilter]); + if (!isOpen || typeof document === 'undefined') return null; const renderComplianceStatus = (status?: number) => { @@ -77,7 +129,7 @@ export const GovernanceInfoModal: React.FC = ({ return ( - Action Required + Action Required (Not Met) ); }; @@ -98,14 +150,14 @@ export const GovernanceInfoModal: React.FC = ({ ) : milestone ? ( Milestone: {milestone.name} ) : ( - Project: {project.name} + Project: {project?.name || (project as any)?.title || `#${project?.id || 'PFM'}`} )}

- Compliance Items, Risks & Institutional Rules + Project Compliance Items, Beneficiaries & Associated Rules

- Contractual obligations, risk mitigations, and execution requirements for grant fulfillment. + Contractual obligations, beneficiary-linked compliance criteria, risk registers, and institutional rules.

+ + {/* Tab Content Panels */}
- {/* TAB 1: COMPLIANCE ITEMS */} + {/* TAB 1: COMPLIANCE ITEMS (With Beneficiary Explorer) */} {activeTab === 'compliance' && ( -
+
+ {/* Beneficiary Filter Bar */} +
+
+ + Explore by Beneficiary: +
+
+ +
+
+ {isLoadingCompliance ? (
Loading compliance requirements...
- ) : complianceItems.length === 0 ? ( + ) : filteredComplianceItems.length === 0 ? (
-

No specific compliance items registered for this project.

+

No compliance items found matching this beneficiary filter.

- Standard grant statutory terms apply as per your signed funding agreement. + Switch to "All Compliance Items" to inspect project-wide statutory deliverables.

) : ( - complianceItems.map((item, idx) => ( -
-
-
- - {item.title || `Compliance Item #${idx + 1}`} - - {renderComplianceStatus(item.status)} -
-
+ filteredComplianceItems.map((item, idx) => { + const linkedBeneficiary = item.beneficiaryId + ? beneficiaryMap.get(String(item.beneficiaryId)) + : null; + return ( +
+
+
+ + {item.title || `Compliance Item #${idx + 1}`} + + {renderComplianceStatus(item.status)} + {linkedBeneficiary ? ( + + + Beneficiary: {linkedBeneficiary.name} + + ) : ( + + Project-Wide General + + )} +
+
Created: {formatDate(item.createdAt)}
- {item.requirements && ( -

{item.requirements}

- )} -
- )) + {item.requirements && ( +

{item.requirements}

+ )} +
+ ); + }) )}
)} - {/* TAB 2: RISK REGISTER */} + {/* TAB 2: BENEFICIARIES LISTING & EXPLORATION */} + {activeTab === 'beneficiaries' && ( +
+ {isLoadingBeneficiaries ? ( +
Loading project beneficiaries...
+ ) : projectBeneficiaries.length === 0 ? ( +
+ +

No individual beneficiary cohorts registered for this project.

+

+ Beneficiaries are captured during project execution and onboarding. +

+
+ ) : ( + projectBeneficiaries.map(ben => { + const linkedItems = complianceItems.filter( + ci => String(ci.beneficiaryId) === String(ben.id) + ); + return ( +
+
+
+
+ +
+
+

{ben.name || 'Unnamed Beneficiary'}

+

+ Category: {ben.category || 'Direct Participant'} + {ben.location && ` • Location: ${ben.location}`} +

+
+
+ +
+
+ Target / Reached + + {ben.reachedCount ?? 0} / {ben.targetCount ?? 0} + +
+ +
+
+
+ ); + }) + )} +
+ )} + + {/* TAB 3: RISK REGISTER */} {activeTab === 'risks' && (
{isLoadingRisks ? ( @@ -247,7 +426,7 @@ export const GovernanceInfoModal: React.FC = ({
)} - {/* TAB 3: GOVERNANCE & RULES */} + {/* TAB 4: ASSOCIATED GOVERNANCE RULES */} {activeTab === 'rules' && (
{isLoadingRules ? ( @@ -287,7 +466,7 @@ export const GovernanceInfoModal: React.FC = ({ {/* Modal Footer */}
- PFM Governance Architecture • Clean Audit Policy + PFM Governance Architecture • Beneficiary & Compliance Tracking
); }; diff --git a/PostFundManagement.PartnerUI/src/hooks/useInAppNotifications.ts b/PostFundManagement.PartnerUI/src/hooks/useInAppNotifications.ts index dd1189c..1822fb5 100644 --- a/PostFundManagement.PartnerUI/src/hooks/useInAppNotifications.ts +++ b/PostFundManagement.PartnerUI/src/hooks/useInAppNotifications.ts @@ -7,6 +7,7 @@ import { createNotification, markNotificationAsRead, getCurrentUserTenantId, + resolveUserIdByEmail, } from '../services/api'; import { Notification, @@ -282,7 +283,17 @@ export function useInAppNotifications() { const orgId = payload.organisationId || getCurrentUserTenantId() || 1; const targetPlatform = payload.platform || NotificationPlatform.InApp; const email = payload.recipientEmail || (payload.recipient?.includes('@') ? payload.recipient : '') || 'support@ifri.khongisa.co.za'; - const id = payload.recipientId || (!payload.recipient?.includes('@') ? payload.recipient : '') || '00000000-0000-0000-0000-000000000000'; + let id = payload.recipientId && payload.recipientId !== '00000000-0000-0000-0000-000000000000' + ? payload.recipientId + : (!payload.recipient?.includes('@') && payload.recipient !== '00000000-0000-0000-0000-000000000000' ? payload.recipient : ''); + + if (!id && email) { + id = (await resolveUserIdByEmail(email, orgId)) || ''; + } + + if (!id) { + id = '00000000-0000-0000-0000-000000000000'; + } const formattedDestination = `To: ${email} | From: ${userEmail || 'Partner User'}`; const req: CreateNotificationRequest = { diff --git a/PostFundManagement.PartnerUI/src/services/api/index.ts b/PostFundManagement.PartnerUI/src/services/api/index.ts index dd5007f..7aa303c 100644 --- a/PostFundManagement.PartnerUI/src/services/api/index.ts +++ b/PostFundManagement.PartnerUI/src/services/api/index.ts @@ -1996,6 +1996,91 @@ export const getTenantUsers = async (tenantId: string | number): Promise => { + if (!email || typeof email !== 'string' || !email.includes('@')) { + return undefined; + } + const cleanEmail = email.trim().toLowerCase(); + + // 1. Fast cache check + const cachedId = getUserIdByEmail(cleanEmail); + if (cachedId && cachedId !== '00000000-0000-0000-0000-000000000000') { + return cachedId; + } + + const matchInTenantUsers = (tenantUsers: TenantUser[]): string | undefined => { + if (!Array.isArray(tenantUsers)) return undefined; + let match: string | undefined = undefined; + for (const tu of tenantUsers) { + const uId = tu.userId || tu.user?.id; + const uEmail = tu.user?.email || (tu as any)?.email; + if (uId && uEmail) { + registerUserEmail(uId, uEmail); + if (String(uEmail).trim().toLowerCase() === cleanEmail) { + match = String(uId).trim(); + } + } + } + return match; + }; + + // 2. Query tenant users with hintTenantOrOrgId + if (hintTenantOrOrgId) { + try { + const users = await getTenantUsers(hintTenantOrOrgId); + const match = matchInTenantUsers(users); + if (match) return match; + } catch {} + } + + // 3. Query all tenants from getTenants and their users + try { + const tenants = await getTenants(); + for (const t of tenants) { + if (hintTenantOrOrgId && String(t.id) === String(hintTenantOrOrgId)) continue; + try { + const users = await getTenantUsers(t.id); + const match = matchInTenantUsers(users); + if (match) return match; + } catch {} + } + } catch {} + + // 4. Try standard fallback tenant IDs + for (const tid of [1, 2, 3]) { + if (hintTenantOrOrgId && String(tid) === String(hintTenantOrOrgId)) continue; + try { + const users = await getTenantUsers(tid); + const match = matchInTenantUsers(users); + if (match) return match; + } catch {} + } + + // 5. Query user directory fallback + try { + const dirUsers = await apiClient.post('/v1/api/users/1/200', {}); + if (Array.isArray(dirUsers.data)) { + for (const u of dirUsers.data) { + if (u && u.id && u.email) { + registerUserEmail(u.id, u.email); + if (u.email.trim().toLowerCase() === cleanEmail) { + return String(u.id).trim(); + } + } + } + } + } catch {} + + return undefined; +}; + // 10. Projects: Beneficiaries & Outcomes APIs // ========================================== export const getProjectBeneficiaries = async (projectId: string | number): Promise => {