PartnerUI evidence, notification, indicators and actuals fixes
This commit is contained in:
@@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user