From cb89817d88b3fe1ea632d7f3f008d2c8a4536c71 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 22 Oct 2026 18:01:22 +0200 Subject: [PATCH] Implemented mailbox in ConsoleUI --- .../CreateNotificationEndpoint.cs | 36 + .../GetUserNotificationsEndpoint.cs | 49 + .../UpdateNotificationEndpoint.cs | 37 + .../Services/ICommunicationsService.cs | 5 +- .../CommunicationsCacheService.cs | 5 +- .../Communications/CommunicationsService.cs | 38 +- .../Services/Communications/Records.cs | 14 +- PostFundManagement.ConsoleUI/src/App.tsx | 3 + .../src/components/layout/AppLayout.tsx | 18 + .../components/layout/NotificationBell.tsx | 216 ++++ .../notifications/ComposeMessageModal.tsx | 621 ++++++++++++ .../notifications/MarkdownViewer.tsx | 146 +++ .../notifications/UserSearchPicker.tsx | 373 +++++++ .../src/hooks/useInAppNotifications.ts | 330 ++++++ .../src/lib/formatters.ts | 3 + .../src/lib/markdownUtils.ts | 313 ++++++ .../src/pages/InboxPage.tsx | 951 ++++++++++++++++++ .../src/services/api.ts | 49 + PostFundManagement.ConsoleUI/src/types.ts | 19 + PostFundManagement.Domain/Enums.cs | 1 + 20 files changed, 3208 insertions(+), 19 deletions(-) create mode 100644 PostFundManagement.Api/Endpoints/Communications/CreateNotificationEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Communications/GetUserNotificationsEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Communications/UpdateNotificationEndpoint.cs create mode 100644 PostFundManagement.ConsoleUI/src/components/layout/NotificationBell.tsx create mode 100644 PostFundManagement.ConsoleUI/src/components/notifications/ComposeMessageModal.tsx create mode 100644 PostFundManagement.ConsoleUI/src/components/notifications/MarkdownViewer.tsx create mode 100644 PostFundManagement.ConsoleUI/src/components/notifications/UserSearchPicker.tsx create mode 100644 PostFundManagement.ConsoleUI/src/hooks/useInAppNotifications.ts create mode 100644 PostFundManagement.ConsoleUI/src/lib/markdownUtils.ts create mode 100644 PostFundManagement.ConsoleUI/src/pages/InboxPage.tsx diff --git a/PostFundManagement.Api/Endpoints/Communications/CreateNotificationEndpoint.cs b/PostFundManagement.Api/Endpoints/Communications/CreateNotificationEndpoint.cs new file mode 100644 index 0000000..2b88e47 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Communications/CreateNotificationEndpoint.cs @@ -0,0 +1,36 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Services.Communications; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Errors; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Communications; + +[ApiVersionTarget(1)] +public sealed class CreateNotificationEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPost("api/communications/notifications", async (CreateNotificationRequest request, + HttpContext context, ICommunicationsService service, CancellationToken cancellationToken = default) => + await service.CreateNotificationAsync(request, context.GetUserId(), cancellationToken) switch + { + { IsSuccess: true } => Results.Created(), + var r when r.HasError() => Results.BadRequest(r.Errors.ToProblems()), + var r when r.HasError() => Results.BadRequest(r.Errors.ToProblems()), + var r => Results.InternalServerError(r.Errors.ToProblems()) + }) + .WithDescription("Creates a new in-app or outbound notification record") + .RequireAuthorization(Constants.MainScope) + .WithName(typeof(CreateNotificationEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Communications); + } +} + diff --git a/PostFundManagement.Api/Endpoints/Communications/GetUserNotificationsEndpoint.cs b/PostFundManagement.Api/Endpoints/Communications/GetUserNotificationsEndpoint.cs new file mode 100644 index 0000000..6e14b82 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Communications/GetUserNotificationsEndpoint.cs @@ -0,0 +1,49 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Communications; + +[ApiVersionTarget(1)] +public sealed class GetUserNotificationsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/communications/notifications/user/{userId:guid}", async (Guid userId, + ICommunicationsService service, CancellationToken cancellationToken = default) => + await service.GetUserNotificationsAsync(userId, cancellationToken) switch + { + { IsSuccess: true, Value: var notifications } => Results.Ok(notifications), + var r => Results.InternalServerError(r.Errors.ToProblems()), + }) + .WithDescription("Retrieves all notifications targeted at a specific user") + .RequireAuthorization(Constants.MainScope) + .WithName(typeof(GetUserNotificationsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status500InternalServerError) + .WithTags(EndpointTags.Communications) + .CacheOutput(); + + builder.MapGet("api/communications/notifications/inbox", async (HttpContext context, + ICommunicationsService service, CancellationToken cancellationToken = default) => + await service.GetUserNotificationsAsync(context.GetUserId(), cancellationToken) switch + { + { IsSuccess: true, Value: var notifications } => Results.Ok(notifications), + var r => Results.InternalServerError(r.Errors.ToProblems()), + }) + .WithDescription("Retrieves all in-app and system notifications targeted at the authenticated caller") + .RequireAuthorization(Constants.MainScope) + .WithName($"{typeof(GetUserNotificationsEndpoint).ToEndpointName()}Inbox") + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status500InternalServerError) + .WithTags(EndpointTags.Communications) + .CacheOutput(); + } +} + diff --git a/PostFundManagement.Api/Endpoints/Communications/UpdateNotificationEndpoint.cs b/PostFundManagement.Api/Endpoints/Communications/UpdateNotificationEndpoint.cs new file mode 100644 index 0000000..0a22f45 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Communications/UpdateNotificationEndpoint.cs @@ -0,0 +1,37 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Services.Communications; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Errors; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Communications; + +[ApiVersionTarget(1)] +public sealed class UpdateNotificationEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapPut("api/communications/notifications", async (UpdateNotificationRequest request, + ICommunicationsService service, CancellationToken cancellationToken = default) => + await service.UpdateNotificationAsync(request, cancellationToken) switch + { + { IsSuccess: true } => Results.NoContent(), + var r when r.HasError() => Results.NotFound(r.Errors.ToProblems()), + var r when r.HasError() => Results.BadRequest(r.Errors.ToProblems()), + var r => Results.InternalServerError(r.Errors.ToProblems()) + }) + .WithDescription("Updates a notification status, delivery timestamp, or error state") + .RequireAuthorization(Constants.MainScope) + .WithName(typeof(UpdateNotificationEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError) + .Produces(StatusCodes.Status401Unauthorized) + .WithTags(EndpointTags.Communications); + } +} + diff --git a/PostFundManagement.Application/Abstractions/Services/ICommunicationsService.cs b/PostFundManagement.Application/Abstractions/Services/ICommunicationsService.cs index 3eaf3e6..0d5135f 100644 --- a/PostFundManagement.Application/Abstractions/Services/ICommunicationsService.cs +++ b/PostFundManagement.Application/Abstractions/Services/ICommunicationsService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Application.Services.Communications; +using PostFundManagement.Application.Services.Communications; using PostFundManagement.Domain; using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Models; @@ -31,6 +31,9 @@ public interface ICommunicationsService [Log] Task> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default); + [Log] + Task> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default); + [Log] Task> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default); diff --git a/PostFundManagement.Application/Services/Communications/CommunicationsCacheService.cs b/PostFundManagement.Application/Services/Communications/CommunicationsCacheService.cs index 8a185a4..d2c9f6d 100644 --- a/PostFundManagement.Application/Services/Communications/CommunicationsCacheService.cs +++ b/PostFundManagement.Application/Services/Communications/CommunicationsCacheService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; @@ -152,6 +152,9 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID return Result.Ok(cached.FromJson()); })); + public async Task> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default) => + await service.GetUserNotificationsAsync(userId, cancellationToken); + public async Task> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default) => await service.SendInviteAsync(request, userId, cancellationToken); diff --git a/PostFundManagement.Application/Services/Communications/CommunicationsService.cs b/PostFundManagement.Application/Services/Communications/CommunicationsService.cs index 90958d2..956b360 100644 --- a/PostFundManagement.Application/Services/Communications/CommunicationsService.cs +++ b/PostFundManagement.Application/Services/Communications/CommunicationsService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; using PostFundManagement.Domain.Abstractions; @@ -94,6 +94,8 @@ public sealed class CommunicationsService(IDbContextFactory n.Id == request.NotificationId) - .ExecuteUpdateAsync(f => f - .SetProperty(u => u.Destination, request.Destination) - .SetProperty(u => u.SentAt, request.SentAt) - .SetProperty(u => u.Error, request.Error) - .SetProperty(u => u.UpdatedAt, DateTime.UtcNow) - .SetProperty(u => u.Status, request.Status), cancellationToken); + var entity = await context.Notifications.FirstOrDefaultAsync(n => n.Id == request.NotificationId, cancellationToken); + if (entity is null) + return Result.Fail(NotFoundError.Create($"Notification #{request.NotificationId} not found")); - return Result.OkIf(changedTotal > 0, UpdateError.Create($"Failed to update notification: {request.NotificationId}, contact support")); + entity.Status = request.Status; + entity.UpdatedAt = DateTime.UtcNow; + if (request.SentAt.HasValue) entity.SentAt = request.SentAt.Value; + if (!string.IsNullOrWhiteSpace(request.Destination)) entity.Destination = request.Destination; + if (!string.IsNullOrWhiteSpace(request.Error)) entity.Error = request.Error; + + return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, UpdateError.Create($"Failed to update notification: {request.NotificationId}, contact support")); })); public async Task> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default) => @@ -147,6 +150,19 @@ public sealed class CommunicationsService(IDbContextFactory i.Map()).ToArray() ?? []); })); + public async Task> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default) => + await Result.Try((Func>>)(async () => + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var notifications = await context.Notifications.AsNoTracking() + .Where(n => n.Recipient == userId) + .OrderByDescending(o => o.CreatedAt) + .ToListAsync(cancellationToken); + + return Result.Ok(notifications?.Select(i => i.Map()).ToArray() ?? []); + })); + public async Task> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { diff --git a/PostFundManagement.Application/Services/Communications/Records.cs b/PostFundManagement.Application/Services/Communications/Records.cs index 93e340c..02dab8c 100644 --- a/PostFundManagement.Application/Services/Communications/Records.cs +++ b/PostFundManagement.Application/Services/Communications/Records.cs @@ -1,18 +1,20 @@ -using PostFundManagement.Domain; +using PostFundManagement.Domain; namespace PostFundManagement.Application.Services.Communications; -public sealed record UpdateNotificationRequest([Required] long NotificationId, string? Destination, - [Required] NotificationStatus Status, - [Required] DateTime SentAt, - [MaxLength(TextLength)] string? Error); +public sealed record UpdateNotificationRequest([Required] long NotificationId, + string? Destination = null, + [Required] NotificationStatus Status = NotificationStatus.Read, + DateTime? SentAt = null, + [MaxLength(TextLength)] string? Error = null); public sealed record CreateNotificationRequest([Required] long OrganisationId, [Required] Guid Recipient, [Required] NotificationPlatform Platform, [Required][MaxLength(LabelLength)] string? Subject, [Required][MaxLength(LabelLength)] string? Destination, - [Required][MaxLength(TextLength)] string Message); + [Required][MaxLength(LargeTextLength)] string Message, + NotificationStatus? Status = null); public sealed record InviteRequest([Required] long OrganisationId, [Required] long InvitedOrganisationId, diff --git a/PostFundManagement.ConsoleUI/src/App.tsx b/PostFundManagement.ConsoleUI/src/App.tsx index 558c633..af7cffb 100644 --- a/PostFundManagement.ConsoleUI/src/App.tsx +++ b/PostFundManagement.ConsoleUI/src/App.tsx @@ -19,6 +19,7 @@ const GrantsPage = lazy(() => import('./pages/GrantsPage').then(m => ({ default: const MonitoringPage = lazy(() => import('./pages/MonitoringPage').then(m => ({ default: m.MonitoringPage }))); const TenantManagementPage = lazy(() => import('./pages/TenantManagementPage').then(m => ({ default: m.TenantManagementPage }))); const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage }))); +const InboxPage = lazy(() => import('./pages/InboxPage').then(m => ({ default: m.InboxPage }))); const RouteLoading = () => (
@@ -181,6 +182,8 @@ export default function App() { } /> + } /> + } /> } /> diff --git a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx index f64bbc6..e8d4c9a 100644 --- a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx +++ b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom'; +import { NotificationBell } from './NotificationBell'; import { + Inbox, LayoutDashboard, Building2, FolderKanban, @@ -121,6 +123,12 @@ const navItems: NavItem[] = [ help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.', claim: 'tenant_management', }, + { + name: 'Mailbox & Inbox', + path: '/inbox', + icon: Inbox, + help: 'In-app notifications, communications, deliverable messages, drafts, and replies across active tenant users.', + }, { name: 'Settings & Communications', path: '/settings', @@ -525,6 +533,8 @@ export const AppLayout: React.FC = () => { {/* User Profile and Controls */}
+ {/* Notification Bell in close proximity to username */} +

@@ -562,6 +572,14 @@ export const AppLayout: React.FC = () => {

Role: {userRole}

+ setUserMenuOpen(false)} + className="flex items-center gap-2 px-3 py-2 text-xs text-slate-300 hover:text-white hover:bg-slate-800/60 rounded-xl transition" + > + + In-App Mailbox + setUserMenuOpen(false)} diff --git a/PostFundManagement.ConsoleUI/src/components/layout/NotificationBell.tsx b/PostFundManagement.ConsoleUI/src/components/layout/NotificationBell.tsx new file mode 100644 index 0000000..bb81cf2 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/layout/NotificationBell.tsx @@ -0,0 +1,216 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Bell, + Mail, + CheckCheck, + ChevronRight, + Inbox, + Clock, + Sparkles, +} from 'lucide-react'; +import { useInAppNotifications } from '../../hooks/useInAppNotifications'; +import { formatDate } from '../../lib/formatters'; +import { stripMarkdown } from '../../lib/markdownUtils'; + +export const NotificationBell: React.FC = () => { + const navigate = useNavigate(); + const { top5Unread, unreadCount, markAsRead, markAllAsRead } = useInAppNotifications(); + + const [isOpen, setIsOpen] = useState(false); + const leaveTimerRef = useRef(null); + const containerRef = useRef(null); + + // Clear timer on unmount + useEffect(() => { + return () => { + if (leaveTimerRef.current) { + window.clearTimeout(leaveTimerRef.current); + } + }; + }, []); + + const handleMouseEnter = () => { + if (leaveTimerRef.current) { + window.clearTimeout(leaveTimerRef.current); + leaveTimerRef.current = null; + } + setIsOpen(true); + }; + + const handleMouseLeave = () => { + leaveTimerRef.current = window.setTimeout(() => { + setIsOpen(false); + }, 250); + }; + + const handleNotificationClick = async (id: number | string) => { + await markAsRead(id); + setIsOpen(false); + navigate(`/inbox?id=${id}`); + }; + + const handleViewAll = () => { + setIsOpen(false); + navigate('/inbox'); + }; + + // Format relative timestamp + const getRelativeTime = (dateStr?: string) => { + if (!dateStr) return ''; + try { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return formatDate(dateStr); + } catch { + return ''; + } + }; + + return ( +
+ {/* Bell Button */} + + + {/* Floating Hover Popover */} + {isOpen && ( +
+ {/* Popover Header */} +
+
+ +

In-App Notifications

+ {unreadCount > 0 && ( + + {unreadCount} unread + + )} +
+ + {unreadCount > 0 && ( + + )} +
+ + {/* Top 5 Notifications List */} +
+ {top5Unread.length === 0 ? ( +
+
+ +
+

All caught up!

+

+ No unread in-app notifications at this time. +

+
+ ) : ( + top5Unread.map(item => { + const relativeTime = getRelativeTime(item.createdAt); + return ( +
handleNotificationClick(item.id)} + className="p-3.5 hover:bg-slate-800/60 transition cursor-pointer group flex items-start gap-3 relative" + > + {/* Unread Accent Indicator */} +
+ +
+
+

+ {item.subject || 'In-App Notification'} +

+ {relativeTime && ( + + + {relativeTime} + + )} +
+ +

+ {stripMarkdown(item.message || 'No additional message content.')} +

+ + {item.destination && ( +

+ {item.destination} +

+ )} +
+ + +
+ ); + }) + )} +
+ + {/* Popover Footer: Open Inbox */} +
+ +
+
+ )} +
+ ); +}; + diff --git a/PostFundManagement.ConsoleUI/src/components/notifications/ComposeMessageModal.tsx b/PostFundManagement.ConsoleUI/src/components/notifications/ComposeMessageModal.tsx new file mode 100644 index 0000000..29cb8dd --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/notifications/ComposeMessageModal.tsx @@ -0,0 +1,621 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + Send, + Save, + Trash2, + X, + Mail, + AlertCircle, + CheckCircle2, + Bold, + Italic, + Heading2, + List, + ListOrdered, + Quote, + Table, + Code, + Minus, + Sparkles, + Eye, + Edit3, + Smartphone, + ShieldCheck, +} from 'lucide-react'; +import { UserSearchPicker, SelectedUser } from './UserSearchPicker'; +import { useInAppNotifications, DraftMessage } from '../../hooks/useInAppNotifications'; +import { NotificationPlatform } from '../../types'; +import { renderMarkdownToHtml, parseMarkdownToHtml, PFM_LOGO_DATA_URI } from '../../lib/markdownUtils'; +import { BrandLogo } from '../ui/BrandLogo'; +import { useAuth } from '../../auth/AuthProvider'; + +interface ComposeMessageModalProps { + isOpen: boolean; + onClose: () => void; + initialRecipient?: SelectedUser; + initialSubject?: string; + initialMessage?: string; + initialPlatform?: NotificationPlatform; + draftToEdit?: DraftMessage | null; +} + +export const ComposeMessageModal: React.FC = ({ + isOpen, + onClose, + initialRecipient, + initialSubject = '', + initialMessage = '', + initialPlatform = NotificationPlatform.InApp, + draftToEdit, +}) => { + const { user, claims } = useAuth(); + const tenantName = (claims?.tenant as string) || user?.tenant || 'Department of Science, Innovation & Technology'; + const { sendMessage, isSending, saveDraft, deleteDraft } = useInAppNotifications(); + + const [selectedUsers, setSelectedUsers] = useState([]); + const [subject, setSubject] = useState(''); + const [message, setMessage] = useState(''); + const [platform, setPlatform] = useState(initialPlatform); + const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write'); + const [error, setError] = useState(null); + const [successNotice, setSuccessNotice] = useState(null); + + const textareaRef = useRef(null); + + // Initialize or populate from draft or props + useEffect(() => { + if (draftToEdit) { + setSelectedUsers( + draftToEdit.recipientId + ? [ + { + id: draftToEdit.recipientId, + email: draftToEdit.recipientEmail, + name: draftToEdit.recipientName, + }, + ] + : [] + ); + setSubject(draftToEdit.subject || ''); + setMessage(draftToEdit.message || ''); + setPlatform(NotificationPlatform.InApp); + } else { + setSelectedUsers(initialRecipient ? [initialRecipient] : []); + setSubject(initialSubject || ''); + setMessage(initialMessage || ''); + setPlatform(initialPlatform); + } + setActiveTab('write'); + setError(null); + setSuccessNotice(null); + }, [isOpen, initialRecipient, initialSubject, initialMessage, initialPlatform, draftToEdit]); + + if (!isOpen) return null; + + const handleSelectUser = (user: SelectedUser) => { + setSelectedUsers([user]); + setError(null); + }; + + const handleRemoveUser = () => { + setSelectedUsers([]); + }; + + // Helper to insert markdown text at cursor in textarea + const insertMarkdown = (before: string, after: string = '', defaultText: string = '') => { + const textarea = textareaRef.current; + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const text = textarea.value; + const selection = text.substring(start, end) || defaultText; + + const replacement = `${before}${selection}${after}`; + const newText = text.substring(0, start) + replacement + text.substring(end); + + setMessage(newText); + + setTimeout(() => { + textarea.focus(); + textarea.setSelectionRange( + start + before.length, + start + before.length + selection.length + ); + }, 0); + }; + + // Quick insertions for PFM branding and templates + const insertBrandedCallout = () => { + insertMarkdown( + `> [!NOTE]\n> **Post-Fund Management Official Notice**\n> Please verify all supporting evidence documentation before tranche disbursement sign-off.\n\n` + ); + }; + + const insertMilestoneTable = () => { + insertMarkdown( + `| Deliverable Code | Milestone Name | Status | Due Date |\n| :--- | :--- | :--- | :--- |\n| DEL-01 | Technical Architecture Review | Verified | 2026-11-15 |\n| DEL-02 | Financial Expenditure Proof | Under Review | 2026-12-01 |\n\n` + ); + }; + + const insertPlatformLogo = () => { + insertMarkdown( + `![PFM Platform Logo](${PFM_LOGO_DATA_URI})\n\n` + ); + }; + + const handleSaveDraft = () => { + if (!subject.trim() && !message.trim()) { + setError('Please add a subject or message to save as draft'); + return; + } + + const firstUser = selectedUsers[0]; + saveDraft({ + id: draftToEdit?.id, + recipientId: firstUser?.id || '', + recipientEmail: firstUser?.email || '', + recipientName: firstUser?.name || '', + subject: subject.trim() || 'Untitled Draft', + message: message.trim(), + }); + + setSuccessNotice('Draft saved successfully in Drafts folder'); + setTimeout(() => { + onClose(); + }, 800); + }; + + const handleSend = async (e: React.FormEvent) => { + e.preventDefault(); + + if (selectedUsers.length === 0) { + setError('Please select a recipient from the tenant directory'); + return; + } + + if (!subject.trim()) { + setError('Please enter a subject'); + return; + } + + if (!message.trim()) { + setError('Please enter a message body'); + return; + } + + setError(null); + try { + const recipient = selectedUsers[0]; + + // Format payload according to chosen platform: + // InApp messages are saved in markdown format. + // Email notifications are transformed from markdown to branded executive HTML before saving. + const finalMessage = + platform === NotificationPlatform.Email + ? renderMarkdownToHtml(message.trim(), { + applyBranding: true, + emailFormat: true, + subject: subject.trim(), + tenantName, + }) + : message.trim(); + + await sendMessage({ + recipientId: recipient.id, + recipientEmail: recipient.email, + recipientName: recipient.name, + subject: subject.trim(), + message: finalMessage, + draftId: draftToEdit?.id, + }); + + setSuccessNotice( + platform === NotificationPlatform.Email + ? 'Email notification rendered to HTML and dispatched!' + : 'In-App Markdown message sent successfully!' + ); + setTimeout(() => { + onClose(); + }, 700); + } catch (err: any) { + setError(err?.message || 'Failed to dispatch notification'); + } + }; + + const handleDiscard = () => { + if (draftToEdit?.id) { + deleteDraft(draftToEdit.id); + } + onClose(); + }; + + // Preview content based on chosen platform + const livePreviewHtml = + platform === NotificationPlatform.Email + ? renderMarkdownToHtml(message, { + applyBranding: true, + emailFormat: true, + subject: subject || 'Notice Preview', + tenantName, + }) + : parseMarkdownToHtml(message); + + return ( +
+
+ {/* Modal Header */} +
+
+ +
+
+

+ {draftToEdit + ? 'Edit Saved Draft' + : initialSubject.startsWith('Re:') + ? 'Reply to Message' + : 'Compose New Communication'} +

+ + Rich Markdown Engine + +
+

+ Author in Markdown with live preview, SVG branding, and dual InApp / Email delivery +

+
+
+ +
+ + {/* Form Body */} +
+ {error && ( +
+ + {error} +
+ )} + + {successNotice && ( +
+ + {successNotice} +
+ )} + + {/* Platform Selector (InApp vs Email) */} +
+
+ +

+ Select target transmission protocol for this notification +

+
+ +
+ + + +
+
+ + {/* Platform Description Indicator */} +
+ {platform === NotificationPlatform.InApp ? ( + + + Saved directly as Markdown. Delivered instantly to the recipient's InApp Mailbox & Notification Bell. + + ) : ( + + + Composed in Markdown, transformed into Branded Executive HTML with embedded SVG logo before dispatching. + + )} +
+ + {/* Recipient Picker */} +
+ + +
+ + {/* Subject Field */} +
+ + setSubject(e.target.value)} + placeholder="e.g. Funding Award Contract Review & Milestone Verification Notice" + className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-700/80 hover:border-slate-600 focus:border-blue-500 text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition shadow-inner font-medium" + /> +
+ + {/* Markdown Editor / Preview Tabs */} +
+
+ + + {/* Write vs Preview Tabs */} +
+ + +
+
+ + {/* Markdown Toolbar (Only when activeTab === 'write') */} + {activeTab === 'write' ? ( +
+ {/* Toolbar */} +
+ + + + + + + + + + + + +