PartnerUI evidence, notification, indicators and actuals fixes

This commit is contained in:
2026-10-26 08:53:05 +02:00
parent 5ac317cbe3
commit bb77a4dc9a
10 changed files with 1290 additions and 302 deletions
@@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Search,
@@ -26,6 +27,9 @@ import {
Info,
Sparkles,
Infinity as InfinityIcon,
Menu,
Reply,
Mail,
} from 'lucide-react';
import { useAuth } from '../../auth/AuthProvider';
import { useRbac } from '../../auth/rbac';
@@ -60,12 +64,13 @@ import {
Notification,
NotificationStatus,
} from '../../types';
import { formatZAR, formatCompactZAR } from '../../lib/formatters';
import { formatZAR, formatCompactZAR, getUserEmail } from '../../lib/formatters';
import { MessageViewer } from '../notifications/MessageViewer';
import { stripMarkdown } from '../../lib/markdownUtils';
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
import { ProjectCard, EnrichedPartnerProject } from './ProjectCard';
import { EvidenceUploadModal } from './EvidenceUploadModal';
import { PartnerMessageComposerModal } from '../notifications/PartnerMessageComposerModal';
type NavTab = 'dashboard' | 'messages' | 'contracts' | 'analytics' | 'documents';
type SortOption = 'date_added' | 'grant_amount' | 'days_left' | 'name';
@@ -130,6 +135,7 @@ export function DashboardPage() {
const [sortBy, setSortBy] = useState<SortOption>('date_added');
const [showBellDropdown, setShowBellDropdown] = useState(false);
const [showUserDropdown, setShowUserDropdown] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false);
// In-place expanded project state (Requirement #4: NO modal used for project details)
const [expandedProjectIds, setExpandedProjectIds] = useState<Record<string, boolean>>({});
@@ -420,6 +426,100 @@ export function DashboardPage() {
}
};
// Email resolution helpers for messages
const getMessageSenderEmail = (notification: Notification | null): string => {
if (!notification) {
return (
partnerSummary.primaryFundingOrg?.email ||
(partnerSummary.primaryFundingOrg as any)?.Email ||
'support@ifri.khongisa.co.za'
);
}
const dest = notification.destination || '';
let rawFrom = '';
if (dest.includes('From:')) {
rawFrom = dest.split('From:')[1]?.split('|')[0]?.trim().replace(/[<>]/g, '') || '';
} else if (!dest.includes('To:') && dest.includes('@')) {
rawFrom = dest.trim();
}
if (rawFrom) {
const resolved = getUserEmail(rawFrom);
if (resolved && resolved.includes('@')) {
return resolved;
}
if (rawFrom.includes('@')) {
return rawFrom;
}
}
return (
partnerSummary.primaryFundingOrg?.email ||
(partnerSummary.primaryFundingOrg as any)?.Email ||
'support@ifri.khongisa.co.za'
);
};
const getMessageRecipientEmail = (notification: Notification | null): string => {
if (!notification) return user?.profile?.email || (user as any)?.email || 'Partner User';
const dest = notification.destination || '';
let rawTo = '';
if (dest.includes('To:')) {
rawTo = dest.split('To:')[1]?.split('|')[0]?.trim().replace(/[<>]/g, '') || '';
}
if (rawTo) {
const resolved = getUserEmail(rawTo);
if (resolved && resolved.includes('@')) {
return resolved;
}
if (rawTo.includes('@')) {
return rawTo;
}
}
if (notification.recipient) {
const resolvedRecip = getUserEmail(notification.recipient);
if (resolvedRecip && resolvedRecip.includes('@')) {
return resolvedRecip;
}
}
return user?.profile?.email || (user as any)?.email || 'Partner User';
};
const handleReplyToMessage = (notification: Notification) => {
const senderEmail = getMessageSenderEmail(notification).trim().toLowerCase();
const currentUserEmail = (
user?.profile?.email ||
(user as any)?.email ||
''
).trim().toLowerCase();
if (currentUserEmail && senderEmail && senderEmail === currentUserEmail) {
showErrorToast(
'Action Not Allowed',
'You cannot reply to a message sent from your own email address.'
);
return;
}
const replySubject = notification.subject?.startsWith('Re:')
? notification.subject
: `Re: ${notification.subject || 'Message'}`;
setContactTarget({
organisation: partnerSummary.primaryFundingOrg,
projectTitle: replySubject,
recipientEmail: senderEmail,
recipientId: notification.recipient,
});
setMessageSubject(replySubject);
setMessageBody('');
setSelectedNotification(null);
};
// Secure download link handler
const handleOpenSecureFile = async (type: 'contract' | 'evidence', id: string | number) => {
try {
@@ -469,19 +569,22 @@ export function DashboardPage() {
return;
}
const recipient =
contactTarget?.recipientId ||
contactTarget?.recipientEmail ||
contactTarget?.organisation?.name ||
applicantUserId;
const fundingEmail =
partnerSummary.primaryFundingOrg?.email ||
(partnerSummary.primaryFundingOrg as any)?.Email ||
'support@ifri.khongisa.co.za';
const targetEmail = contactTarget?.recipientEmail || fundingEmail;
try {
await sendMessage({
recipient,
recipientEmail: targetEmail,
recipient: targetEmail,
subject: messageSubject.trim(),
message: messageBody.trim(),
organisationId: partnerSummary.primaryFundingOrg?.id || undefined,
});
showSuccessToast('Message Sent', 'Your communication was delivered to the programme team.');
showSuccessToast('Message Sent', `Your communication was delivered to ${targetEmail}.`);
setContactTarget(null);
setMessageSubject('');
setMessageBody('');
@@ -548,6 +651,20 @@ export function DashboardPage() {
{/* Right: Search, Notifications Bell, User Menu */}
<div className="flex items-center gap-3 sm:gap-4">
{/* Mobile Navigation Toggle Button (< md) */}
<button
type="button"
onClick={() => {
setShowMobileMenu(!showMobileMenu);
setShowBellDropdown(false);
setShowUserDropdown(false);
}}
className="md:hidden p-2 rounded-xl bg-slate-900/80 border border-white/10 text-slate-300 hover:text-emerald-400 hover:border-emerald-500/30 transition-colors cursor-pointer"
title="Toggle Navigation Menu"
aria-label="Toggle navigation menu"
>
{showMobileMenu ? <X className="w-4 h-4 text-emerald-400" /> : <Menu className="w-4 h-4" />}
</button>
{/* Search Input */}
<div className="relative hidden sm:block w-48 md:w-64">
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
@@ -695,6 +812,37 @@ export function DashboardPage() {
</div>
</header>
{/* =====================================================================
MOBILE NAVIGATION DRAWER (< md) (Requirement #6)
===================================================================== */}
{showMobileMenu && (
<div className="md:hidden sticky top-[72px] z-25 w-full bg-slate-950/95 border-b border-white/10 shadow-2xl backdrop-blur-xl px-4 py-3 space-y-1.5 transition-all">
{navTabs.map(tab => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => {
setActiveTab(tab.id);
setShowMobileMenu(false);
}}
className={`flex items-center justify-between w-full px-4 py-2.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
isActive
? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 shadow-[0_0_15px_rgba(16,185,129,0.15)]'
: 'text-slate-300 hover:text-white hover:bg-white/[0.04]'
}`}
>
<span>{tab.label}</span>
{isActive && (
<span className="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(16,185,129,0.8)]" />
)}
</button>
);
})}
</div>
)}
{/* =====================================================================
MAIN DASHBOARD BODY (Max-width 1440px, full-width horizontal layout)
(Overview & Inbox sidebar deleted per Requirements #1, #2, #3)
@@ -811,9 +959,15 @@ export function DashboardPage() {
<button
type="button"
onClick={() => {
const fundingEmail =
partnerSummary.primaryFundingOrg?.email ||
(partnerSummary.primaryFundingOrg as any)?.Email ||
'support@ifri.khongisa.co.za';
setContactTarget({
organisation: partnerSummary.primaryFundingOrg,
projectTitle: 'Partner Support',
projectTitle: 'General Support Enquiry',
recipientEmail: fundingEmail,
});
setMessageSubject('');
setMessageBody('');
@@ -858,6 +1012,9 @@ export function DashboardPage() {
)}
</div>
<p className="text-xs text-slate-400 mt-1 line-clamp-2">{stripMarkdown(msg.message || '')}</p>
<div className="flex items-center gap-2 mt-1.5 text-[11px] text-slate-500 font-mono">
<span>From: <strong className="text-slate-300 font-medium">{getMessageSenderEmail(msg)}</strong></span>
</div>
</div>
</div>
<span className="text-xs text-slate-400/70 shrink-0">
@@ -1090,105 +1247,99 @@ export function DashboardPage() {
)}
{/* =====================================================================
SEND MESSAGE MODAL
SEND MESSAGE MODAL (WYSIWYG Composer, strictly In-App, centered to window)
(Requirements #1, #2, #8)
===================================================================== */}
{contactTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-md p-4">
<div className="w-full max-w-lg rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl p-6 space-y-4">
<div className="flex items-center justify-between border-b border-white/[0.08] pb-3">
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-indigo-400" />
<h3 className="text-base font-bold text-white">Send Message</h3>
</div>
<button
type="button"
onClick={() => setContactTarget(null)}
className="p-1 rounded-lg text-slate-400 hover:text-white cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<PartnerMessageComposerModal
isOpen={true}
onClose={() => setContactTarget(null)}
recipientEmail={
contactTarget.recipientEmail ||
partnerSummary.primaryFundingOrg?.email ||
'support@ifri.khongisa.co.za'
}
organisationName={
contactTarget.organisation?.name ||
partnerSummary.primaryFundingOrg?.name ||
'Programme Management Office'
}
initialSubject={
messageSubject ||
(contactTarget.projectTitle?.startsWith('Re:') ? contactTarget.projectTitle : '')
}
isSending={isSending}
onSend={async (subj, markdown) => {
const fundingEmail =
partnerSummary.primaryFundingOrg?.email ||
(partnerSummary.primaryFundingOrg as any)?.Email ||
'support@ifri.khongisa.co.za';
<form onSubmit={handleSendContactMessage} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">Recipient</label>
<input
type="text"
disabled
value={`${contactTarget.organisation?.name || 'Programme Office'} (${contactTarget.projectTitle || 'Enquiry'})`}
className="w-full px-3.5 py-2 rounded-xl bg-slate-900 border border-white/10 text-xs text-slate-300"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">Subject</label>
<input
type="text"
required
value={messageSubject}
onChange={e => setMessageSubject(e.target.value)}
placeholder="How can the funding team help you?"
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs text-white focus:outline-none focus:border-indigo-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">Message</label>
<textarea
rows={4}
required
value={messageBody}
onChange={e => setMessageBody(e.target.value)}
placeholder="Write your message here..."
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs text-white focus:outline-none focus:border-indigo-400"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setContactTarget(null)}
className="px-4 py-2 rounded-xl bg-slate-800 text-slate-300 text-xs font-semibold cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={isSending}
className="px-5 py-2 rounded-xl bg-indigo-500 hover:bg-indigo-400 text-white font-semibold text-xs flex items-center gap-2 cursor-pointer disabled:opacity-50"
>
<Send className="w-3.5 h-3.5" />
<span>{isSending ? 'Sending...' : 'Send Message'}</span>
</button>
</div>
</form>
</div>
</div>
const targetEmail = contactTarget.recipientEmail || fundingEmail;
await sendMessage({
recipientEmail: targetEmail,
recipient: targetEmail,
subject: subj,
message: markdown,
platform: NotificationPlatform.InApp,
organisationId: partnerSummary.primaryFundingOrg?.id || undefined,
});
showSuccessToast('Message Sent', `Your communication was delivered to ${targetEmail}.`);
setContactTarget(null);
setMessageSubject('');
setMessageBody('');
}}
/>
)}
{/* =====================================================================
MESSAGE READER MODAL
MESSAGE READER MODAL (Centered to Window via Portal)
(Requirements #1, #2, #3, #4, #8)
===================================================================== */}
{selectedNotification && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-md p-4 animate-in fade-in duration-200">
<div className="w-full max-w-lg rounded-2xl bg-[#0d1322] border border-white/10 shadow-2xl p-6 space-y-4">
<div className="flex items-center justify-between border-b border-white/[0.08] pb-3">
<h3 className="text-base font-bold text-white truncate max-w-[360px]">
{selectedNotification.subject || 'Message Details'}
</h3>
<button
type="button"
onClick={() => setSelectedNotification(null)}
className="p-1 rounded-lg text-slate-400 hover:text-white cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3 text-xs">
<div className="flex items-center justify-between text-slate-400">
<span>From: {selectedNotification.destination?.split('|')[0] || 'Programme Management Office'}</span>
<span>{formatFullDate(selectedNotification.createdAt)}</span>
{selectedNotification &&
typeof document !== 'undefined' &&
createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-950/85 backdrop-blur-md p-4 sm:p-6 overflow-y-auto animate-in fade-in duration-200">
<div className="w-full max-w-3xl sm:max-w-4xl rounded-3xl bg-slate-900 border border-white/10 shadow-2xl p-6 sm:p-8 space-y-5 my-auto text-slate-100">
{/* Header */}
<div className="flex items-start justify-between border-b border-white/[0.08] pb-4 gap-4">
<div className="space-y-1.5 min-w-0">
<h3 className="text-lg sm:text-xl font-bold text-slate-100 truncate">
{selectedNotification.subject || 'Message Details'}
</h3>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-400">
<div>
From:{' '}
<span className="font-mono text-emerald-400 font-semibold">
{getMessageSenderEmail(selectedNotification)}
</span>
</div>
<div>
To:{' '}
<span className="font-mono text-slate-300">
{getMessageRecipientEmail(selectedNotification)}
</span>
</div>
<div className="text-slate-500">
{formatFullDate(selectedNotification.createdAt)}
</div>
</div>
</div>
<button
type="button"
onClick={() => setSelectedNotification(null)}
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>
<div className="p-4 rounded-xl bg-slate-950/60 border border-white/5 text-slate-200 whitespace-pre-wrap leading-relaxed max-h-80 overflow-y-auto">
{/* Message Content Viewer */}
<div className="p-5 sm:p-6 rounded-2xl bg-slate-950/80 border border-white/5 text-slate-200 leading-relaxed min-h-[180px] max-h-[60vh] overflow-y-auto">
<MessageViewer
content={selectedNotification.message || ''}
message={selectedNotification.message || ''}
subject={selectedNotification.subject || undefined}
createdDate={selectedNotification.createdAt}
@@ -1196,20 +1347,29 @@ export function DashboardPage() {
variant="card"
/>
</div>
</div>
<div className="flex justify-end pt-2">
<button
type="button"
onClick={() => setSelectedNotification(null)}
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold cursor-pointer"
>
Close
</button>
{/* Action Buttons: Reply and Close */}
<div className="flex items-center justify-between pt-2 border-t border-white/[0.08]">
<button
type="button"
onClick={() => handleReplyToMessage(selectedNotification)}
className="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all cursor-pointer"
>
<Reply className="w-3.5 h-3.5" />
<span>Reply</span>
</button>
<button
type="button"
onClick={() => setSelectedNotification(null)}
className="px-5 py-2.5 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>
</div>
)}
</div>,
document.body
)}
</div>
);
}
}
@@ -1,17 +1,26 @@
import React, { useState } from 'react';
import { uploadEvidenceFile, extractApiErrors, toIntegerId } from '../../services/api';
import { Activity, Milestone, Project, EvidenceType } from '../../services/api/models';
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query';
import {
uploadEvidenceFile,
extractApiErrors,
toIntegerId,
getProjectIndicators,
recordIndicatorActual,
} from '../../services/api';
import { Activity, Milestone, Project, Indicator } from '../../services/api/models';
import { EVIDENCE_TYPE_OPTIONS } from '../../lib/formatters';
import {
X,
UploadCloud,
FileText,
CheckCircle2,
AlertTriangle,
Loader2,
Plus,
import {
X,
UploadCloud,
FileText,
CheckCircle2,
AlertTriangle,
Loader2,
Plus,
Sparkles,
Link as LinkIcon
Target,
BarChart3,
} from 'lucide-react';
interface EvidenceUploadModalProps {
@@ -35,11 +44,43 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
const [title, setTitle] = useState('');
const [evidenceType, setEvidenceType] = useState<number>(4); // Default: FinancialInvoiceOrReceipt
const [description, setDescription] = useState('');
const [selectedIndicatorId, setSelectedIndicatorId] = useState<string>('');
const [actualAmount, setActualAmount] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessages, setErrorMessages] = useState<string[]>([]);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
if (!isOpen) return null;
const projectId = project?.id || 0;
// Requirement #4: Fetch linked indicators for project & activity
const { data: projectIndicators = [], isLoading: isLoadingIndicators } = useQuery<Indicator[]>({
queryKey: ['evidence-project-indicators', String(projectId)],
queryFn: () => getProjectIndicators(projectId),
enabled: isOpen && Boolean(projectId),
});
// Prioritize indicators directly linked to this activity, then project indicators
const availableIndicators = React.useMemo(() => {
if (!projectIndicators || projectIndicators.length === 0) return [];
const directMatches = projectIndicators.filter(
ind => ind.activityId && String(ind.activityId) === String(activity.id)
);
if (directMatches.length > 0) return directMatches;
return projectIndicators;
}, [projectIndicators, activity.id]);
// Auto-select first indicator if available
useEffect(() => {
if (availableIndicators.length > 0 && !selectedIndicatorId) {
const firstId = String(availableIndicators[0].id);
setSelectedIndicatorId(firstId);
if (availableIndicators[0].actualAmount !== undefined) {
setActualAmount(String(availableIndicators[0].actualAmount || ''));
}
}
}, [availableIndicators, selectedIndicatorId]);
if (!isOpen || typeof document === 'undefined') return null;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
@@ -51,6 +92,10 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
}
};
const selectedIndicatorObj = availableIndicators.find(
i => String(i.id) === String(selectedIndicatorId)
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessages([]);
@@ -64,6 +109,22 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
const numProjectId = toIntegerId(project.id);
const numActivityId = toIntegerId(activity.id);
const numMilestoneId = toIntegerId(milestone?.id || activity.milestoneId || 0);
const numIndicatorId = toIntegerId(selectedIndicatorId);
// Requirement #4: IndicatorId must not be 0 to maintain referential integrity in database
if (!numIndicatorId || numIndicatorId <= 0) {
setErrorMessages([
'A valid linked Indicator is mandatory for evidence verification. Please select an indicator from the list.',
]);
return;
}
if (!actualAmount.trim() || isNaN(Number(actualAmount))) {
setErrorMessages([
'Recording an Actual amount/metric against the selected indicator is mandatory. Please provide a numeric value.',
]);
return;
}
if (!numProjectId || !numActivityId) {
setErrorMessages(['Cannot upload evidence: Missing projectId or activityId reference.']);
@@ -73,22 +134,34 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
setIsSubmitting(true);
try {
// Build multipart/form-data adhering strictly to EvidenceService.CreateEvidenceAsync
// 1. Build multipart/form-data adhering strictly to EvidenceService.CreateEvidenceAsync
// Requirement #7: When uploading, IForm does not need the userId field in it; API detects userId from JWT sub
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('projectId', String(numProjectId));
formData.append('activityId', String(numActivityId));
formData.append('milestoneId', String(numMilestoneId));
formData.append('indicatorId', '0');
formData.append('indicatorId', String(numIndicatorId));
formData.append('type', String(Number(evidenceType) || 4));
formData.append('title', title.trim() || selectedFile.name);
if (description.trim()) {
formData.append('description', description.trim());
}
// 2. Upload evidence file
await uploadEvidenceFile(formData);
setSuccessMessage('Evidence successfully uploaded and linked to activity!');
// 3. Requirement #4: Record Actual against linked indicator
const parsedActual = Number(actualAmount);
if (!isNaN(parsedActual)) {
try {
await recordIndicatorActual(numIndicatorId, parsedActual);
} catch (indErr) {
console.warn('[Record Indicator Actual Warning]:', indErr);
}
}
setSuccessMessage('Evidence successfully uploaded and linked to activity with recorded indicator actual!');
setTimeout(() => {
onEvidenceUploaded();
onClose();
@@ -96,15 +169,17 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
} catch (err: any) {
console.error('[Evidence Upload Error]:', err);
const errors = extractApiErrors(err);
setErrorMessages(errors.length > 0 ? errors : ['Failed to upload evidence file. Please try again.']);
setErrorMessages(
errors.length > 0 ? errors : ['Failed to upload evidence file. Please verify parameters and try again.']
);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200">
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden">
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden my-auto">
{/* Ambient background glow */}
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-60 h-60 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none" />
@@ -192,13 +267,78 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
) : (
<div>
<span className="font-medium text-slate-300">Click to choose document</span>
<p className="text-[11px] text-slate-500 mt-0.5">PDF, DOCX, XLSX, PNG, JPG (Multiple uploads supported)</p>
<p className="text-[11px] text-slate-500 mt-0.5">
PDF, DOCX, XLSX, PNG, JPG (Multiple uploads supported)
</p>
</div>
)}
</div>
</div>
</div>
{/* Requirement #4: Mandatory Indicator Selection & Actual Amount Recording */}
<div className="p-3.5 rounded-2xl bg-slate-950/80 border border-emerald-500/30 space-y-3">
<div className="flex items-center gap-2 text-emerald-400 font-semibold text-xs">
<Target className="w-4 h-4" />
<span>Linked Indicator & Performance Actual (Mandatory)</span>
</div>
<div>
<label className="block text-slate-300 font-medium mb-1">
Select Linked Indicator <span className="text-emerald-400">*</span>
</label>
{isLoadingIndicators ? (
<div className="text-[11px] text-slate-400 py-1">Loading indicators...</div>
) : availableIndicators.length === 0 ? (
<div className="text-[11px] text-amber-300 bg-amber-950/40 border border-amber-500/30 p-2.5 rounded-xl">
No indicators currently recorded for this project. Please contact programme management or select standard output.
</div>
) : (
<select
required
value={selectedIndicatorId}
onChange={e => setSelectedIndicatorId(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
>
<option value="">-- Choose Indicator for this Deliverable --</option>
{availableIndicators.map(ind => (
<option key={ind.id} value={ind.id} className="bg-slate-900 text-slate-100">
{ind.name || `Indicator #${ind.id}`} (Target: {ind.targetAmount} • Baseline: {ind.baselineAmount})
</option>
))}
</select>
)}
</div>
<div>
<label className="block text-slate-300 font-medium mb-1">
Actual Amount / Metric Recorded <span className="text-emerald-400">*</span>
</label>
<div className="relative">
<input
type="number"
step="any"
min="0"
required
placeholder={
selectedIndicatorObj
? `Target is ${selectedIndicatorObj.targetAmount}`
: 'e.g. 10 or 1500'
}
value={actualAmount}
onChange={e => setActualAmount(e.target.value)}
className="w-full px-3.5 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
/>
<BarChart3 className="w-4 h-4 text-emerald-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
{selectedIndicatorObj && (
<p className="text-[11px] text-slate-400 mt-1">
Baseline: {selectedIndicatorObj.baselineAmount} • Target: {selectedIndicatorObj.targetAmount} • Current Actual: {selectedIndicatorObj.actualAmount}
</p>
)}
</div>
</div>
{/* Evidence Title & Type */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
@@ -207,7 +347,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
type="text"
placeholder="e.g. Activity Verification Proof"
value={title}
onChange={(e) => setTitle(e.target.value)}
onChange={e => setTitle(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
/>
</div>
@@ -215,10 +355,10 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
<label className="block text-slate-300 font-medium mb-1">Evidence Type</label>
<select
value={evidenceType}
onChange={(e) => setEvidenceType(Number(e.target.value))}
onChange={e => setEvidenceType(Number(e.target.value))}
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
>
{EVIDENCE_TYPE_OPTIONS.map((opt) => (
{EVIDENCE_TYPE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value} className="bg-slate-900 text-slate-100">
{opt.label}
</option>
@@ -234,7 +374,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
rows={2}
placeholder="Provide verification notes or audit references..."
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50 resize-none"
/>
</div>
@@ -262,13 +402,14 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
) : (
<>
<Plus className="w-4 h-4" />
<span>Upload & Attach Evidence</span>
<span>Upload & Record Actual</span>
</>
)}
</button>
</div>
</form>
</div>
</div>
</div>,
document.body
);
};
@@ -0,0 +1,333 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query';
import {
X,
ShieldAlert,
ShieldCheck,
AlertTriangle,
Scale,
FileCheck,
Info,
Calendar,
User,
CheckCircle2,
Clock,
Layers,
Sparkles,
} from 'lucide-react';
import { Project, Milestone, Activity, ComplianceItem, Risk, Rule, ComplianceStatus } from '../../services/api/models';
import { getProjectComplianceItems, 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' | 'risks' | 'rules';
}
export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
isOpen,
onClose,
project,
milestone,
activity,
initialTab = 'compliance',
}) => {
const [activeTab, setActiveTab] = useState<'compliance' | 'risks' | 'rules'>(initialTab);
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 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
const { data: governanceRules = [], isLoading: isLoadingRules } = useQuery<Rule[]>({
queryKey: ['governance-rules'],
queryFn: () => getRules(1, 100),
enabled: isOpen,
});
if (!isOpen || typeof document === 'undefined') return null;
const renderComplianceStatus = (status?: number) => {
switch (status) {
case ComplianceStatus.Compliant:
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>Compliant</span>
</span>
);
case ComplianceStatus.UnderReview:
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
<Clock className="w-3 h-3 text-amber-400" />
<span>Under Review</span>
</span>
);
case ComplianceStatus.NonCompliant:
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>Non-Compliant</span>
</span>
);
default:
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-slate-800 text-slate-300 border border-white/10">
<span>Pending Submission</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}</span>
)}
</div>
<h3 className="text-lg sm:text-xl font-bold text-white tracking-tight">
Compliance Items, Risks & Institutional Rules
</h3>
<p className="text-xs text-slate-400">
Contractual obligations, risk mitigations, and execution requirements for grant fulfillment.
</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">
<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 ${
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('risks')}
className={`flex items-center gap-2 px-4 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-4 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>
</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 */}
{activeTab === 'compliance' && (
<div className="space-y-3">
{isLoadingCompliance ? (
<div className="p-8 text-center text-xs text-slate-400">Loading compliance requirements...</div>
) : complianceItems.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 className="text-[11px] text-slate-500">
Standard grant statutory terms apply as per your signed funding agreement.
</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>
{item.dueDate && (
<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>Due: {formatDate(item.dueDate)}</span>
</div>
)}
</div>
{item.description && (
<p className="text-xs text-slate-300 leading-relaxed">{item.description}</p>
)}
{item.notes && (
<div className="text-[11px] text-emerald-400/90 bg-emerald-950/40 border border-emerald-500/20 p-2.5 rounded-xl font-mono">
Note: {item.notes}
</div>
)}
</div>
))
)}
</div>
)}
{/* TAB 2: 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 3: 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>
{rule.category && (
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-slate-800 text-slate-300 border border-white/10">
{rule.category}
</span>
)}
</div>
{rule.description && (
<p className="text-xs text-slate-300 leading-relaxed">{rule.description}</p>
)}
{rule.condition && (
<div className="text-[11px] text-slate-400 bg-slate-900 border border-white/5 p-2 rounded-xl font-mono">
Condition: {rule.condition}
</div>
)}
</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 • Clean Audit Policy
</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
);
};
@@ -10,7 +10,7 @@ import {
Organisation,
ApprovalStatus
} from '../../services/api/models';
import { encodeHashIdSync } from '../../services/toolsService';
import { encodeHashIdSync, decodeHashIdSync } from '../../services/toolsService';
import {
toIntegerId,
getMilestones,
@@ -24,6 +24,7 @@ import {
getProjectTypeLabel
} from '../../lib/formatters';
import { EvidenceUploadModal } from './EvidenceUploadModal';
import { GovernanceInfoModal } from './GovernanceInfoModal';
import {
FolderKanban,
ChevronDown,
@@ -87,6 +88,13 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
activity: Activity;
} | null>(null);
const [governanceTarget, setGovernanceTarget] = useState<{
project: Project;
milestone?: Milestone | null;
activity?: Activity | null;
initialTab?: 'compliance' | 'risks' | 'rules';
} | null>(null);
const { project, award, contract, fundingOrganisation, invitedOrganisation, disbursements } = item;
const hashedProjectId = encodeHashIdSync(project.id);
@@ -136,7 +144,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
};
// Status Badge Helper using Landing Page emerald/teal/amber color palette
const renderStatusBadge = (status?: any, fallback = 'Active') => {
const renderStatusBadge = (status?: any, fallback = 'In Progress') => {
const num = Number(status);
if (num === ApprovalStatus.Approved || String(status).toLowerCase().includes('approved')) {
return (
@@ -154,10 +162,19 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</span>
);
}
if (num === ApprovalStatus.Pending || String(status).toLowerCase().includes('pending')) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-teal-950/80 border border-teal-500/40 text-teal-300 shadow-sm shadow-teal-950/50">
<span className="w-1.5 h-1.5 rounded-full bg-teal-400 animate-pulse" />
<span>In Progress</span>
</span>
);
}
const label = (status !== undefined && status !== null && !isNaN(num)) ? fallback : String(status || fallback);
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-teal-950/80 border border-teal-500/40 text-teal-300 shadow-sm shadow-teal-950/50">
<span className="w-1.5 h-1.5 rounded-full bg-teal-400 animate-pulse" />
<span>{String(status || fallback)}</span>
<span>{label}</span>
</span>
);
};
@@ -279,7 +296,30 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
#{hashedProjectId}
</span>
)}
{renderStatusBadge(project.status, 'Active Implementation')}
{hasOverdueMilestone ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-rose-950/90 border border-rose-500/50 text-rose-300 shadow-sm shadow-rose-950/50">
<AlertTriangle className="w-3.5 h-3.5 text-rose-400" />
<span>Late (Overdue Milestone)</span>
</span>
) : hasAtRiskMilestone ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-950/90 border border-amber-500/50 text-amber-300 shadow-sm shadow-amber-950/50">
<Clock className="w-3.5 h-3.5 text-amber-400" />
<span>At Risk (Milestone Due Soon)</span>
</span>
) : (
renderStatusBadge(project.status, 'Active Implementation')
)}
{/* Requirement #6: Informational Compliance & Risk Modal Trigger */}
<button
type="button"
onClick={() => 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"
>
<ShieldAlert className="w-3.5 h-3.5 text-emerald-400" />
<span>Compliance & Risks</span>
</button>
{/* Requirement #3: Display enum string description instead of integer value */}
{sectorDescription && (
@@ -504,7 +544,11 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
const evs = getEvidenceForActivity(a.id);
return evs.length > 0 || Number(a.status) === ApprovalStatus.Approved || Number(a.status) === ApprovalStatus.Review;
});
const isMilestoneFullyFulfilled = milestoneActs.length > 0 && milestoneFulfilledActs.length === milestoneActs.length;
const isMilestoneFullyFulfilled = (Number(milestone.status) === ApprovalStatus.Approved) || (milestoneActs.length > 0 && milestoneFulfilledActs.length === milestoneActs.length);
const actualMilestoneId = decodeHashIdSync(milestone.id);
const dueTime = milestone.dueAt ? new Date(milestone.dueAt).getTime() : null;
const isMilestoneOverdue = dueTime !== null && dueTime < nowTime && !isMilestoneFullyFulfilled;
const isMilestoneAtRisk = dueTime !== null && !isMilestoneOverdue && !isMilestoneFullyFulfilled && (dueTime - nowTime <= 3 * 24 * 60 * 60 * 1000);
return (
<div
@@ -521,13 +565,43 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<span className="font-mono text-[10px] text-slate-400">
(ID: #{encodeHashIdSync(milestone.id)})
</span>
{renderStatusBadge(milestone.status, 'In Progress')}
{/* Requirement #3: Decode hashId to show actual milestone ID */}
<span className="font-mono text-[10px] font-semibold text-slate-300 bg-slate-900 border border-white/10 px-2 py-0.5 rounded-md">
Actual ID: #{actualMilestoneId}
</span>
{/* Requirement #5: Milestone late and at risk badges */}
{isMilestoneOverdue ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-950/90 border border-rose-500/50 text-rose-300 shadow-sm">
<AlertTriangle className="w-3 h-3 text-rose-400" />
<span>Late</span>
</span>
) : isMilestoneAtRisk ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-950/90 border border-amber-500/50 text-amber-300 shadow-sm">
<Clock className="w-3 h-3 text-amber-400" />
<span>At Risk (Due Soon)</span>
</span>
) : (
renderStatusBadge(milestone.status, 'In Progress')
)}
{isMilestoneFullyFulfilled && (
<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">
<ShieldCheck className="w-3 h-3 text-emerald-400" />
<span>Milestone Fulfilled</span>
</span>
)}
{/* Requirement #6: Milestone level Rules & Risks trigger */}
<button
type="button"
onClick={() => 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"
>
<Scale className="w-3 h-3 text-teal-400" />
<span>Rules & Risks</span>
</button>
</div>
<h5 className="text-base font-bold text-slate-100 mt-1">
{milestone.name || `Milestone Target #${mIdx + 1}`}