Files
pfm/PostFundManagement.PartnerUI/src/components/dashboard/GovernanceInfoModal.tsx
T

484 lines
22 KiB
TypeScript

import React, { useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query';
import {
X,
ShieldAlert,
ShieldCheck,
AlertTriangle,
Scale,
FileCheck,
Info,
Calendar,
User,
Users,
CheckCircle2,
Clock,
Layers,
Sparkles,
Filter,
MapPin,
Target,
} from 'lucide-react';
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 {
isOpen: boolean;
onClose: () => void;
project: Project;
milestone?: Milestone | null;
activity?: Activity | null;
initialTab?: 'compliance' | 'beneficiaries' | 'risks' | 'rules';
}
export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
isOpen,
onClose,
project,
milestone,
activity,
initialTab = 'compliance',
}) => {
const [activeTab, setActiveTab] = useState<'compliance' | 'beneficiaries' | 'risks' | 'rules'>(initialTab);
const [selectedBeneficiaryFilter, setSelectedBeneficiaryFilter] = useState<string>('ALL');
const projectId = project?.id || 0;
// 1. Fetch compliance items for project
const { data: complianceItems = [], isLoading: isLoadingCompliance } = useQuery<ComplianceItem[]>({
queryKey: ['governance-compliance', String(projectId)],
queryFn: () => getProjectComplianceItems(projectId),
enabled: isOpen && Boolean(projectId),
});
// 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),
});
// 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) => {
const s = Number(status);
if (s === ComplianceStatus.Met || s === ComplianceStatus.Exceeded) {
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
<span>{s === ComplianceStatus.Exceeded ? 'Exceeded' : 'Compliant'}</span>
</span>
);
}
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 (Not Met)</span>
</span>
);
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
<div className="relative w-full max-w-4xl rounded-3xl border border-white/10 bg-slate-900 shadow-2xl p-6 sm:p-8 space-y-6 my-auto text-slate-100 max-h-[90vh] flex flex-col">
{/* Modal Header */}
<div className="flex items-start justify-between border-b border-white/[0.08] pb-4 shrink-0">
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
<ShieldAlert className="w-3 h-3 text-emerald-400" />
<span>Governance & Compliance Guidance</span>
</span>
{activity ? (
<span className="text-[11px] text-slate-400 font-mono">Activity: {activity.title}</span>
) : 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 || (project as any)?.title || `#${project?.id || 'PFM'}`}</span>
)}
</div>
<h3 className="text-lg sm:text-xl font-bold text-white tracking-tight">
Project Compliance Items, Beneficiaries & Associated Rules
</h3>
<p className="text-xs text-slate-400">
Contractual obligations, beneficiary-linked compliance criteria, risk registers, and institutional rules.
</p>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tab Navigation */}
<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-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]'
}`}
>
<FileCheck className="w-4 h-4" />
<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-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]'
}`}
>
<AlertTriangle className="w-4 h-4" />
<span>Risk Register ({projectRisks.length})</span>
</button>
<button
type="button"
onClick={() => setActiveTab('rules')}
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>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 (With Beneficiary Explorer) */}
{activeTab === 'compliance' && (
<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>
) : 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 compliance items found matching this beneficiary filter.</p>
<p className="text-[11px] text-slate-500">
Switch to &quot;All Compliance Items&quot; to inspect project-wide statutory deliverables.
</p>
</div>
) : (
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>
);
})
)}
</div>
)}
{/* 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 ? (
<div className="p-8 text-center text-xs text-slate-400">Loading risk register...</div>
) : projectRisks.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">
<ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto" />
<p>No high-severity risks recorded for this project.</p>
<p className="text-[11px] text-slate-500">
Maintain milestone delivery cadence to preserve low-risk standing.
</p>
</div>
) : (
projectRisks.map((risk, idx) => (
<div
key={risk.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">
<h4 className="font-semibold text-sm text-slate-100">
{risk.title || `Risk Item #${idx + 1}`}
</h4>
<div className="flex items-center gap-2 text-[10px] font-mono">
<span className="px-2 py-0.5 rounded-md bg-amber-500/15 text-amber-300 border border-amber-500/30">
Likelihood: {risk.likelihood ?? 'Medium'}
</span>
<span className="px-2 py-0.5 rounded-md bg-rose-500/15 text-rose-300 border border-rose-500/30">
Impact: {risk.impact ?? 'Moderate'}
</span>
</div>
</div>
{risk.description && (
<p className="text-xs text-slate-300 leading-relaxed">{risk.description}</p>
)}
{risk.mitigation && (
<div className="text-[11px] text-teal-300 bg-teal-950/40 border border-teal-500/20 p-2.5 rounded-xl">
<strong>Mitigation Strategy:</strong> {risk.mitigation}
</div>
)}
</div>
))
)}
</div>
)}
{/* TAB 4: ASSOCIATED GOVERNANCE RULES */}
{activeTab === 'rules' && (
<div className="space-y-3">
{isLoadingRules ? (
<div className="p-8 text-center text-xs text-slate-400">Loading institutional rules...</div>
) : governanceRules.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">
<Scale className="w-8 h-8 text-slate-500 mx-auto" />
<p>Standard PFM Funding Portfolio Governance Rules apply.</p>
<p className="text-[11px] text-slate-500">
All disbursements gate against verified milestone deliverables and evidence.
</p>
</div>
) : (
governanceRules.map((rule, idx) => (
<div
key={rule.id || idx}
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2"
>
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-sm text-slate-100">
{rule.name || `Rule #${idx + 1}`}
</h4>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-slate-800 text-slate-300 border border-white/10">
v{rule.version || 1}
</span>
</div>
{rule.description && (
<p className="text-xs text-slate-300 leading-relaxed">{rule.description}</p>
)}
</div>
))
)}
</div>
)}
</div>
{/* 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 • Beneficiary & Compliance Tracking
</span>
<button
type="button"
onClick={onClose}
className="px-5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold transition-colors cursor-pointer"
>
Close
</button>
</div>
</div>
</div>,
document.body
);
};