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>
);
}
}