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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user