Resolved reply recipient userId from TenantUsers and enabled Beneficiary compliance exploration modal
This commit is contained in:
@@ -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}.`);
|
||||
|
||||
@@ -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<GovernanceInfoModalProps> = ({
|
||||
@@ -37,7 +55,8 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
|
||||
activity,
|
||||
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;
|
||||
|
||||
@@ -48,20 +67,53 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
|
||||
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[]>({
|
||||
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<Rule[]>({
|
||||
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<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;
|
||||
|
||||
const renderComplianceStatus = (status?: number) => {
|
||||
@@ -77,7 +129,7 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
|
||||
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">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
<span>Action Required</span>
|
||||
<span>Action Required (Not Met)</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -98,14 +150,14 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
|
||||
) : milestone ? (
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
@@ -118,11 +170,11 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
type="button"
|
||||
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'
|
||||
? '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]'
|
||||
@@ -132,10 +184,23 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
|
||||
<span>Compliance Items ({complianceItems.length})</span>
|
||||
</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
|
||||
type="button"
|
||||
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'
|
||||
? '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]'
|
||||
@@ -148,60 +213,174 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
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'
|
||||
? '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]'
|
||||
}`}
|
||||
>
|
||||
<Scale className="w-4 h-4" />
|
||||
<span>Governance Rules ({governanceRules.length})</span>
|
||||
<span>Associated Rules ({governanceRules.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content Panels */}
|
||||
<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' && (
|
||||
<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 ? (
|
||||
<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">
|
||||
<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">
|
||||
Standard grant statutory terms apply as per your signed funding agreement.
|
||||
Switch to "All Compliance Items" to inspect project-wide statutory deliverables.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
complianceItems.map((item, idx) => (
|
||||
<div
|
||||
key={item.id || idx}
|
||||
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-slate-100">
|
||||
{item.title || `Compliance Item #${idx + 1}`}
|
||||
</span>
|
||||
{renderComplianceStatus(item.status)}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 flex items-center gap-1 font-mono">
|
||||
filteredComplianceItems.map((item, idx) => {
|
||||
const linkedBeneficiary = item.beneficiaryId
|
||||
? beneficiaryMap.get(String(item.beneficiaryId))
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={item.id || idx}
|
||||
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-slate-100">
|
||||
{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" />
|
||||
<span>Created: {formatDate(item.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{item.requirements && (
|
||||
<p className="text-xs text-slate-300 leading-relaxed">{item.requirements}</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
{item.requirements && (
|
||||
<p className="text-xs text-slate-300 leading-relaxed">{item.requirements}</p>
|
||||
)}
|
||||
</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' && (
|
||||
<div className="space-y-3">
|
||||
{isLoadingRisks ? (
|
||||
@@ -247,7 +426,7 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: GOVERNANCE & RULES */}
|
||||
{/* TAB 4: ASSOCIATED GOVERNANCE RULES */}
|
||||
{activeTab === 'rules' && (
|
||||
<div className="space-y-3">
|
||||
{isLoadingRules ? (
|
||||
@@ -287,7 +466,7 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
|
||||
{/* Modal Footer */}
|
||||
<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">
|
||||
PFM Governance Architecture • Clean Audit Policy
|
||||
PFM Governance Architecture • Beneficiary & Compliance Tracking
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -302,4 +481,3 @@ export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
project: Project;
|
||||
milestone?: Milestone | null;
|
||||
activity?: Activity | null;
|
||||
initialTab?: 'compliance' | 'risks' | 'rules';
|
||||
initialTab?: 'compliance' | 'beneficiaries' | 'risks' | 'rules';
|
||||
} | null>(null);
|
||||
|
||||
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 */}
|
||||
<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"
|
||||
title="View Project Compliance, Risks & Institutional Rules"
|
||||
>
|
||||
@@ -617,7 +621,11 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
{/* Requirement #6: Milestone level Rules & Risks trigger */}
|
||||
<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"
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
// ==========================================
|
||||
export const getProjectBeneficiaries = async (projectId: string | number): Promise<Beneficiary[]> => {
|
||||
|
||||
Reference in New Issue
Block a user