Resolved reply recipient userId from TenantUsers and enabled Beneficiary compliance exploration modal

This commit is contained in:
2026-10-26 10:02:14 +02:00
parent f56cb70b55
commit 155a379ab4
5 changed files with 355 additions and 50 deletions
@@ -46,6 +46,7 @@ import {
getContractSecureLink, getContractSecureLink,
getEvidenceSecureLink, getEvidenceSecureLink,
submitActivityForApproval, submitActivityForApproval,
resolveUserIdByEmail,
} from '../../services/api'; } from '../../services/api';
import { import {
ApplicantData, ApplicantData,
@@ -490,7 +491,7 @@ export function DashboardPage() {
return (user?.email || (user as any)?.profile?.email || '') || (user as any)?.email || 'Partner User'; 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 senderEmail = getMessageSenderEmail(notification).trim().toLowerCase();
const currentUserEmail = ( const currentUserEmail = (
(user?.email || (user as any)?.profile?.email || '') || (user?.email || (user as any)?.profile?.email || '') ||
@@ -510,11 +511,20 @@ export function DashboardPage() {
? notification.subject ? notification.subject
: `Re: ${notification.subject || 'Message'}`; : `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({ setContactTarget({
organisation: partnerSummary.primaryFundingOrg, organisation: partnerSummary.primaryFundingOrg,
projectTitle: replySubject, projectTitle: replySubject,
recipientEmail: senderEmail, recipientEmail: senderEmail,
recipientId: notification.recipient, recipientId: senderUserId,
}); });
setMessageSubject(replySubject); setMessageSubject(replySubject);
setMessageBody(''); setMessageBody('');
@@ -1281,10 +1291,11 @@ export function DashboardPage() {
await sendMessage({ await sendMessage({
recipientEmail: targetEmail, recipientEmail: targetEmail,
recipient: targetEmail, recipient: targetEmail,
recipientId: contactTarget.recipientId,
subject: subj, subject: subj,
message: markdown, message: markdown,
platform: NotificationPlatform.InApp, 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}.`); showSuccessToast('Message Sent', `Your communication was delivered to ${targetEmail}.`);
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
@@ -11,13 +11,31 @@ import {
Info, Info,
Calendar, Calendar,
User, User,
Users,
CheckCircle2, CheckCircle2,
Clock, Clock,
Layers, Layers,
Sparkles, Sparkles,
Filter,
MapPin,
Target,
} from 'lucide-react'; } from 'lucide-react';
import { Project, Milestone, Activity, ComplianceItem, Risk, Rule, ComplianceStatus } from '../../services/api/models'; import {
import { getProjectComplianceItems, getProjectRisks, getRules } from '../../services/api'; 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'; import { formatDate } from '../../lib/formatters';
interface GovernanceInfoModalProps { interface GovernanceInfoModalProps {
@@ -26,7 +44,7 @@ interface GovernanceInfoModalProps {
project: Project; project: Project;
milestone?: Milestone | null; milestone?: Milestone | null;
activity?: Activity | null; activity?: Activity | null;
initialTab?: 'compliance' | 'risks' | 'rules'; initialTab?: 'compliance' | 'beneficiaries' | 'risks' | 'rules';
} }
export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
@@ -37,7 +55,8 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
activity, activity,
initialTab = 'compliance', initialTab = 'compliance',
}) => { }) => {
const [activeTab, setActiveTab] = useState<'compliance' | 'risks' | 'rules'>(initialTab); const [activeTab, setActiveTab] = useState<'compliance' | 'beneficiaries' | 'risks' | 'rules'>(initialTab);
const [selectedBeneficiaryFilter, setSelectedBeneficiaryFilter] = useState<string>('ALL');
const projectId = project?.id || 0; const projectId = project?.id || 0;
@@ -48,20 +67,53 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
enabled: isOpen && Boolean(projectId), 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<Beneficiary[]>({
queryKey: ['governance-beneficiaries', String(projectId)],
queryFn: () => getProjectBeneficiaries(projectId),
enabled: isOpen && Boolean(projectId),
});
// 3. Fetch risks for project
const { data: projectRisks = [], isLoading: isLoadingRisks } = useQuery<Risk[]>({ const { data: projectRisks = [], isLoading: isLoadingRisks } = useQuery<Risk[]>({
queryKey: ['governance-risks', String(projectId)], queryKey: ['governance-risks', String(projectId)],
queryFn: () => getProjectRisks(projectId), queryFn: () => getProjectRisks(projectId),
enabled: isOpen && Boolean(projectId), enabled: isOpen && Boolean(projectId),
}); });
// 3. Fetch rules // 4. Fetch associated governance rules
const { data: governanceRules = [], isLoading: isLoadingRules } = useQuery<Rule[]>({ const { data: governanceRules = [], isLoading: isLoadingRules } = useQuery<Rule[]>({
queryKey: ['governance-rules'], queryKey: ['governance-rules'],
queryFn: () => getRules({ page: 1, pageSize: 100 }), queryFn: () => getRules({ page: 1, pageSize: 100 }),
enabled: isOpen, enabled: isOpen,
}); });
// Map of beneficiary by ID for fast lookup
const beneficiaryMap = useMemo(() => {
const map = new Map<string, Beneficiary>();
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; if (!isOpen || typeof document === 'undefined') return null;
const renderComplianceStatus = (status?: number) => { const renderComplianceStatus = (status?: number) => {
@@ -77,7 +129,7 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
return ( return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30"> <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30">
<AlertTriangle className="w-3 h-3 text-rose-400" /> <AlertTriangle className="w-3 h-3 text-rose-400" />
<span>Action Required</span> <span>Action Required (Not Met)</span>
</span> </span>
); );
}; };
@@ -98,14 +150,14 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
) : milestone ? ( ) : milestone ? (
<span className="text-[11px] text-slate-400 font-mono">Milestone: {milestone.name}</span> <span className="text-[11px] text-slate-400 font-mono">Milestone: {milestone.name}</span>
) : ( ) : (
<span className="text-[11px] text-slate-400 font-mono">Project: {project.name}</span> <span className="text-[11px] text-slate-400 font-mono">Project: {project?.name || (project as any)?.title || `#${project?.id || 'PFM'}`}</span>
)} )}
</div> </div>
<h3 className="text-lg sm:text-xl font-bold text-white tracking-tight"> <h3 className="text-lg sm:text-xl font-bold text-white tracking-tight">
Compliance Items, Risks & Institutional Rules Project Compliance Items, Beneficiaries & Associated Rules
</h3> </h3>
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
Contractual obligations, risk mitigations, and execution requirements for grant fulfillment. Contractual obligations, beneficiary-linked compliance criteria, risk registers, and institutional rules.
</p> </p>
</div> </div>
<button <button
@@ -118,11 +170,11 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
</div> </div>
{/* Tab Navigation */} {/* Tab Navigation */}
<div className="flex items-center gap-2 border-b border-white/[0.08] pb-3 shrink-0"> <div className="flex items-center gap-2 border-b border-white/[0.08] pb-3 shrink-0 flex-wrap">
<button <button
type="button" type="button"
onClick={() => setActiveTab('compliance')} onClick={() => setActiveTab('compliance')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${ className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'compliance' activeTab === 'compliance'
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm' ? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]' : 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
@@ -132,10 +184,23 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
<span>Compliance Items ({complianceItems.length})</span> <span>Compliance Items ({complianceItems.length})</span>
</button> </button>
<button
type="button"
onClick={() => setActiveTab('beneficiaries')}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'beneficiaries'
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/40 shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
}`}
>
<Users className="w-4 h-4" />
<span>Beneficiaries ({projectBeneficiaries.length})</span>
</button>
<button <button
type="button" type="button"
onClick={() => setActiveTab('risks')} onClick={() => setActiveTab('risks')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${ className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'risks' activeTab === 'risks'
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 shadow-sm' ? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]' : 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
@@ -148,60 +213,174 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
<button <button
type="button" type="button"
onClick={() => setActiveTab('rules')} onClick={() => setActiveTab('rules')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${ className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'rules' activeTab === 'rules'
? 'bg-teal-500/20 text-teal-300 border border-teal-500/40 shadow-sm' ? 'bg-teal-500/20 text-teal-300 border border-teal-500/40 shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]' : 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
}`} }`}
> >
<Scale className="w-4 h-4" /> <Scale className="w-4 h-4" />
<span>Governance Rules ({governanceRules.length})</span> <span>Associated Rules ({governanceRules.length})</span>
</button> </button>
</div> </div>
{/* Tab Content Panels */} {/* Tab Content Panels */}
<div className="flex-1 overflow-y-auto space-y-4 pr-1 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]"> <div className="flex-1 overflow-y-auto space-y-4 pr-1 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]">
{/* TAB 1: COMPLIANCE ITEMS */} {/* TAB 1: COMPLIANCE ITEMS (With Beneficiary Explorer) */}
{activeTab === 'compliance' && ( {activeTab === 'compliance' && (
<div className="space-y-3"> <div className="space-y-4">
{/* Beneficiary Filter Bar */}
<div className="p-3.5 rounded-2xl bg-slate-950/70 border border-white/[0.08] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs text-slate-300">
<Filter className="w-4 h-4 text-emerald-400 shrink-0" />
<span className="font-semibold">Explore by Beneficiary:</span>
</div>
<div className="flex items-center gap-2">
<select
value={selectedBeneficiaryFilter}
onChange={e => setSelectedBeneficiaryFilter(e.target.value)}
className="px-3 py-1.5 rounded-xl bg-slate-900 border border-white/10 text-xs text-slate-200 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
>
<option value="ALL">All Compliance Items ({complianceItems.length})</option>
<option value="PROJECT_WIDE">
Project-Wide General Items (
{
complianceItems.filter(
i => !i.beneficiaryId || Number(i.beneficiaryId) === 0
).length
}
)
</option>
{projectBeneficiaries.map(b => {
const count = complianceItems.filter(
i => String(i.beneficiaryId) === String(b.id)
).length;
return (
<option key={b.id} value={String(b.id)}>
Beneficiary: {b.name || `Beneficiary #${b.id}`} ({count} items)
</option>
);
})}
</select>
</div>
</div>
{isLoadingCompliance ? ( {isLoadingCompliance ? (
<div className="p-8 text-center text-xs text-slate-400">Loading compliance requirements...</div> <div className="p-8 text-center text-xs text-slate-400">Loading compliance requirements...</div>
) : complianceItems.length === 0 ? ( ) : filteredComplianceItems.length === 0 ? (
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2"> <div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
<FileCheck className="w-8 h-8 text-slate-500 mx-auto" /> <FileCheck className="w-8 h-8 text-slate-500 mx-auto" />
<p>No specific compliance items registered for this project.</p> <p>No compliance items found matching this beneficiary filter.</p>
<p className="text-[11px] text-slate-500"> <p className="text-[11px] text-slate-500">
Standard grant statutory terms apply as per your signed funding agreement. Switch to &quot;All Compliance Items&quot; to inspect project-wide statutory deliverables.
</p> </p>
</div> </div>
) : ( ) : (
complianceItems.map((item, idx) => ( filteredComplianceItems.map((item, idx) => {
<div const linkedBeneficiary = item.beneficiaryId
key={item.id || idx} ? beneficiaryMap.get(String(item.beneficiaryId))
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5" : null;
> return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2"> <div
<div className="flex items-center gap-2 flex-wrap"> key={item.id || idx}
<span className="font-semibold text-sm text-slate-100"> className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5"
{item.title || `Compliance Item #${idx + 1}`} >
</span> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
{renderComplianceStatus(item.status)} <div className="flex items-center gap-2 flex-wrap">
</div> <span className="font-semibold text-sm text-slate-100">
<div className="text-[11px] text-slate-400 flex items-center gap-1 font-mono"> {item.title || `Compliance Item #${idx + 1}`}
</span>
{renderComplianceStatus(item.status)}
{linkedBeneficiary ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono bg-cyan-950/80 border border-cyan-500/30 text-cyan-300">
<Users className="w-3 h-3 text-cyan-400" />
<span>Beneficiary: {linkedBeneficiary.name}</span>
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono bg-slate-900 border border-white/10 text-slate-400">
<span>Project-Wide General</span>
</span>
)}
</div>
<div className="text-[11px] text-slate-400 flex items-center gap-1 font-mono">
<Calendar className="w-3.5 h-3.5 text-slate-500" /> <Calendar className="w-3.5 h-3.5 text-slate-500" />
<span>Created: {formatDate(item.createdAt)}</span> <span>Created: {formatDate(item.createdAt)}</span>
</div> </div>
</div> </div>
{item.requirements && ( {item.requirements && (
<p className="text-xs text-slate-300 leading-relaxed">{item.requirements}</p> <p className="text-xs text-slate-300 leading-relaxed">{item.requirements}</p>
)} )}
</div> </div>
)) );
})
)} )}
</div> </div>
)} )}
{/* TAB 2: RISK REGISTER */} {/* TAB 2: BENEFICIARIES LISTING & EXPLORATION */}
{activeTab === 'beneficiaries' && (
<div className="space-y-3">
{isLoadingBeneficiaries ? (
<div className="p-8 text-center text-xs text-slate-400">Loading project beneficiaries...</div>
) : projectBeneficiaries.length === 0 ? (
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
<Users className="w-8 h-8 text-slate-500 mx-auto" />
<p>No individual beneficiary cohorts registered for this project.</p>
<p className="text-[11px] text-slate-500">
Beneficiaries are captured during project execution and onboarding.
</p>
</div>
) : (
projectBeneficiaries.map(ben => {
const linkedItems = complianceItems.filter(
ci => String(ci.beneficiaryId) === String(ben.id)
);
return (
<div
key={ben.id}
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-3"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2.5 flex-wrap">
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
<Users className="w-4 h-4" />
</div>
<div>
<h4 className="text-sm font-bold text-slate-100">{ben.name || 'Unnamed Beneficiary'}</h4>
<p className="text-[11px] text-slate-400 font-mono">
Category: {ben.category || 'Direct Participant'}
{ben.location && ` • Location: ${ben.location}`}
</p>
</div>
</div>
<div className="flex items-center gap-3 text-xs">
<div className="text-right">
<span className="text-[10px] text-slate-500 block uppercase font-bold">Target / Reached</span>
<span className="text-emerald-400 font-mono font-semibold">
{ben.reachedCount ?? 0} / {ben.targetCount ?? 0}
</span>
</div>
<button
type="button"
onClick={() => {
setSelectedBeneficiaryFilter(String(ben.id));
setActiveTab('compliance');
}}
className="px-3 py-1.5 rounded-xl bg-cyan-500/15 hover:bg-cyan-500/25 border border-cyan-500/30 text-cyan-300 text-[11px] font-semibold transition-colors cursor-pointer"
>
Explore Compliance ({linkedItems.length})
</button>
</div>
</div>
</div>
);
})
)}
</div>
)}
{/* TAB 3: RISK REGISTER */}
{activeTab === 'risks' && ( {activeTab === 'risks' && (
<div className="space-y-3"> <div className="space-y-3">
{isLoadingRisks ? ( {isLoadingRisks ? (
@@ -247,7 +426,7 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
</div> </div>
)} )}
{/* TAB 3: GOVERNANCE & RULES */} {/* TAB 4: ASSOCIATED GOVERNANCE RULES */}
{activeTab === 'rules' && ( {activeTab === 'rules' && (
<div className="space-y-3"> <div className="space-y-3">
{isLoadingRules ? ( {isLoadingRules ? (
@@ -287,7 +466,7 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
{/* Modal Footer */} {/* Modal Footer */}
<div className="flex items-center justify-between pt-3 border-t border-white/[0.08] shrink-0"> <div className="flex items-center justify-between pt-3 border-t border-white/[0.08] shrink-0">
<span className="text-[11px] text-slate-500 font-mono"> <span className="text-[11px] text-slate-500 font-mono">
PFM Governance Architecture • Clean Audit Policy PFM Governance Architecture • Beneficiary & Compliance Tracking
</span> </span>
<button <button
type="button" type="button"
@@ -302,4 +481,3 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
document.body document.body
); );
}; };
@@ -101,7 +101,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
project: Project; project: Project;
milestone?: Milestone | null; milestone?: Milestone | null;
activity?: Activity | null; activity?: Activity | null;
initialTab?: 'compliance' | 'risks' | 'rules'; initialTab?: 'compliance' | 'beneficiaries' | 'risks' | 'rules';
} | null>(null); } | null>(null);
const { project, award, contract, fundingOrganisation, invitedOrganisation, disbursements } = item; const { project, award, contract, fundingOrganisation, invitedOrganisation, disbursements } = item;
@@ -335,7 +335,11 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{/* Requirement #6: Informational Compliance & Risk Modal Trigger */} {/* Requirement #6: Informational Compliance & Risk Modal Trigger */}
<button <button
type="button" type="button"
onClick={() => setGovernanceTarget({ project, initialTab: 'compliance' })} onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setGovernanceTarget({ project, initialTab: 'compliance' });
}}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/10 text-[11px] font-medium text-slate-300 hover:text-white transition-colors cursor-pointer" className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/10 text-[11px] font-medium text-slate-300 hover:text-white transition-colors cursor-pointer"
title="View Project Compliance, Risks & Institutional Rules" title="View Project Compliance, Risks & Institutional Rules"
> >
@@ -617,7 +621,11 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{/* Requirement #6: Milestone level Rules & Risks trigger */} {/* Requirement #6: Milestone level Rules & Risks trigger */}
<button <button
type="button" type="button"
onClick={() => setGovernanceTarget({ project, milestone, initialTab: 'rules' })} onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setGovernanceTarget({ project, milestone, initialTab: 'rules' });
}}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-white/[0.04] hover:bg-white/[0.08] text-[10px] font-medium text-slate-400 hover:text-white transition-colors cursor-pointer border border-white/5" className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-white/[0.04] hover:bg-white/[0.08] text-[10px] font-medium text-slate-400 hover:text-white transition-colors cursor-pointer border border-white/5"
title="View Milestone Rules and Risk Guidance" title="View Milestone Rules and Risk Guidance"
> >
@@ -929,6 +937,18 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
}} }}
/> />
)} )}
{/* COMPLIANCE & RISKS INFORMATIONAL MODAL (Requirement #6) */}
{governanceTarget && (
<GovernanceInfoModal
isOpen={true}
onClose={() => setGovernanceTarget(null)}
project={governanceTarget.project}
milestone={governanceTarget.milestone}
activity={governanceTarget.activity}
initialTab={governanceTarget.initialTab}
/>
)}
</div> </div>
); );
}; };
@@ -7,6 +7,7 @@ import {
createNotification, createNotification,
markNotificationAsRead, markNotificationAsRead,
getCurrentUserTenantId, getCurrentUserTenantId,
resolveUserIdByEmail,
} from '../services/api'; } from '../services/api';
import { import {
Notification, Notification,
@@ -282,7 +283,17 @@ export function useInAppNotifications() {
const orgId = payload.organisationId || getCurrentUserTenantId() || 1; const orgId = payload.organisationId || getCurrentUserTenantId() || 1;
const targetPlatform = payload.platform || NotificationPlatform.InApp; const targetPlatform = payload.platform || NotificationPlatform.InApp;
const email = payload.recipientEmail || (payload.recipient?.includes('@') ? payload.recipient : '') || 'support@ifri.khongisa.co.za'; 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 formattedDestination = `To: ${email} | From: ${userEmail || 'Partner User'}`;
const req: CreateNotificationRequest = { const req: CreateNotificationRequest = {
@@ -1996,6 +1996,91 @@ export const getTenantUsers = async (tenantId: string | number): Promise<TenantU
}; };
// ========================================== // ==========================================
/**
* Resolves a recipient user GUID by email address across TenantUsers and Directory.
* Automatically caches any found user emails and IDs in local memory.
*/
export const resolveUserIdByEmail = async (
email: string,
hintTenantOrOrgId?: number | string
): Promise<string | undefined> => {
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<User[]>('/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 // 10. Projects: Beneficiaries & Outcomes APIs
// ========================================== // ==========================================
export const getProjectBeneficiaries = async (projectId: string | number): Promise<Beneficiary[]> => { export const getProjectBeneficiaries = async (projectId: string | number): Promise<Beneficiary[]> => {