diff --git a/PostFundManagement.Api/Endpoints/Projects/CreateOutcomeEndpoint.cs b/PostFundManagement.Api/Endpoints/Projects/CreateOutcomeEndpoint.cs index f15db95..be87dbf 100644 --- a/PostFundManagement.Api/Endpoints/Projects/CreateOutcomeEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Projects/CreateOutcomeEndpoint.cs @@ -12,7 +12,14 @@ public sealed class CreateOutcomeEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapPost("api/projects/beneficiaries/outcomes", HandleCreateAsync) + builder.MapPost("api/projects/beneficiaries/outcomes", async (CreateOutcomeRequest request, HttpContext context, + IProjectService service, CancellationToken cancellationToken = default) => + await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch + { + { IsSuccess: true } => Results.Ok(), + var r when r.HasError() => Results.BadRequest(r.Errors.ToProblems()), + var r => Results.InternalServerError(r.Errors.ToProblems()) + }) .WithDescription("Add a new beneficiary outcome") .RequireAuthorization(Constants.MainScope) .WithName(typeof(CreateOutcomeEndpoint).ToEndpointName()) @@ -22,25 +29,5 @@ public sealed class CreateOutcomeEndpoint : IEndpoint .Produces(StatusCodes.Status500InternalServerError) .Produces(StatusCodes.Status401Unauthorized) .WithTags(EndpointTags.Projects); - - builder.MapPost("api/projects/beneficiaris/outcomes", HandleCreateAsync) - .WithDescription("Add a new beneficiary outcome (legacy route)") - .RequireAuthorization(Constants.MainScope) - .WithName(typeof(CreateOutcomeEndpoint).ToEndpointName("TypoAlias")) - .MapToApiVersion(new ApiVersion(1)) - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status500InternalServerError) - .Produces(StatusCodes.Status401Unauthorized) - .WithTags(EndpointTags.Projects); } - - private static async Task HandleCreateAsync(CreateOutcomeRequest request, HttpContext context, - IProjectService service, CancellationToken cancellationToken = default) => - await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch - { - { IsSuccess: true } => Results.Ok(), - var r when r.HasError() => Results.BadRequest(r.Errors.ToProblems()), - var r => Results.InternalServerError(r.Errors.ToProblems()) - }; } diff --git a/PostFundManagement.Application/Abstractions/Services/ICommunicationsService.cs b/PostFundManagement.Application/Abstractions/Services/ICommunicationsService.cs index 0d5135f..b410f13 100644 --- a/PostFundManagement.Application/Abstractions/Services/ICommunicationsService.cs +++ b/PostFundManagement.Application/Abstractions/Services/ICommunicationsService.cs @@ -25,7 +25,6 @@ public interface ICommunicationsService Task CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default); [Log] - [Audit] Task UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default); [Log] diff --git a/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx b/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx index d0422a7..c46b9b5 100644 --- a/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx +++ b/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx @@ -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('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>({}); @@ -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 */}
+ {/* Mobile Navigation Toggle Button (< md) */} + {/* Search Input */}
@@ -695,6 +812,37 @@ export function DashboardPage() {
+ {/* ===================================================================== + MOBILE NAVIGATION DRAWER (< md) (Requirement #6) + ===================================================================== */} + {showMobileMenu && ( +
+ {navTabs.map(tab => { + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+ )} + {/* ===================================================================== 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() {

{stripMarkdown(msg.message || '')}

+
+ From: {getMessageSenderEmail(msg)} +
@@ -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 && ( -
-
-
-
- -

Send Message

-
- -
+ 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'; -
-
- - -
-
- - 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" - /> -
-
- -