Merge pull request 'Implemented mailbox in ConsoleUI' (#155) from finishline into main
Reviewed-on: #155
This commit was merged in pull request #155.
This commit is contained in:
@@ -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<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
|
||||
var r when r.HasError<CreateError>() => 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<Problems>(StatusCodes.Status400BadRequest)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Communications);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Notification[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(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<Notification[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.WithTags(EndpointTags.Communications)
|
||||
.CacheOutput();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
|
||||
var r when r.HasError<UpdateError>() => 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<Problems>(StatusCodes.Status404NotFound)
|
||||
.Produces<Problems>(StatusCodes.Status400BadRequest)
|
||||
.Produces<Problems>(StatusCodes.Status500InternalServerError)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.WithTags(EndpointTags.Communications);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Notification[]>> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
[Log]
|
||||
Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
+4
-1
@@ -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<Notification[]>());
|
||||
}));
|
||||
|
||||
public async Task<Result<Notification[]>> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.GetUserNotificationsAsync(userId, cancellationToken);
|
||||
|
||||
public async Task<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await service.SendInviteAsync(request, userId, cancellationToken);
|
||||
|
||||
|
||||
@@ -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<ApplicationDbContext
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var initialStatus = request.Status ?? (request.Platform == NotificationPlatform.InApp ? NotificationStatus.Delivered : NotificationStatus.Queued);
|
||||
|
||||
context.Notifications.Add(new Domain.Entities.Notification
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
@@ -103,7 +105,7 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
||||
Subject = request.Subject,
|
||||
Message = request.Message,
|
||||
Platform = request.Platform,
|
||||
Status = NotificationStatus.Queued,
|
||||
Status = initialStatus,
|
||||
TenantId = tenantContext.TenantId
|
||||
});
|
||||
|
||||
@@ -116,16 +118,17 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var changedTotal = await context.Notifications
|
||||
.Where(n => 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<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default) =>
|
||||
@@ -147,6 +150,19 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
||||
return Result.Ok(notifications?.Select(i => i.Map()).ToArray() ?? []);
|
||||
}));
|
||||
|
||||
public async Task<Result<Notification[]>> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Notification[]>>>)(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<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<Notification>>>)(async () =>
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = () => (
|
||||
<div className="flex items-center justify-center min-h-[400px] w-full text-slate-400">
|
||||
@@ -181,6 +182,8 @@ export default function App() {
|
||||
</TenantManagementProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/inbox" element={<InboxPage />} />
|
||||
<Route path="/mailbox" element={<InboxPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
<div className="relative flex items-center gap-3">
|
||||
{/* Notification Bell in close proximity to username */}
|
||||
<NotificationBell />
|
||||
<div className="text-right hidden sm:block">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<p className="text-xs font-semibold text-white truncate max-w-[150px]" title={tokenUserName}>
|
||||
@@ -562,6 +572,14 @@ export const AppLayout: React.FC = () => {
|
||||
<p className="text-[10px] text-slate-400 mt-1">Role: <strong className="text-blue-300">{userRole}</strong></p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/inbox"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Inbox className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>In-App Mailbox</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Bell Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
id="header-notification-bell-btn"
|
||||
className={`relative h-9 w-9 rounded-full flex items-center justify-center transition cursor-pointer ${
|
||||
isOpen
|
||||
? 'bg-blue-600/30 text-white border border-blue-400/50 shadow-md shadow-blue-500/20'
|
||||
: 'bg-slate-900/80 hover:bg-slate-800 text-slate-300 hover:text-white border border-slate-700/80 hover:border-slate-600 shadow-xs'
|
||||
}`}
|
||||
title={`Notifications • ${unreadCount} unread`}
|
||||
aria-label="In-App Notifications"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
|
||||
{/* Unread Count Badge */}
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
id="notification-unread-count-badge"
|
||||
className="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-rose-600 text-[10px] font-extrabold text-white shadow-lg ring-2 ring-[#0d121f] animate-pulse"
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Floating Hover Popover */}
|
||||
{isOpen && (
|
||||
<div
|
||||
id="notification-bell-dropdown"
|
||||
className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-[#0d1322] border border-slate-700/80 rounded-2xl shadow-2xl p-0 z-50 backdrop-blur-xl animate-in fade-in slide-in-from-top-2 overflow-hidden"
|
||||
>
|
||||
{/* Popover Header */}
|
||||
<div className="px-4 py-3 border-b border-slate-800 flex items-center justify-between bg-gradient-to-r from-blue-950/40 via-slate-900/40 to-slate-900/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-blue-400" />
|
||||
<h4 className="text-xs font-bold text-white tracking-tight">In-App Notifications</h4>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-[10px] font-semibold text-rose-300 bg-rose-500/20 border border-rose-500/30 px-1.5 py-0.2 rounded-full">
|
||||
{unreadCount} unread
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllAsRead}
|
||||
className="flex items-center gap-1 text-[11px] text-blue-400 hover:text-blue-300 transition cursor-pointer"
|
||||
title="Mark all notifications as read"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
<span>Mark read</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top 5 Notifications List */}
|
||||
<div className="max-h-80 overflow-y-auto divide-y divide-slate-800/60">
|
||||
{top5Unread.length === 0 ? (
|
||||
<div className="py-8 px-4 text-center">
|
||||
<div className="w-10 h-10 mx-auto rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 mb-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<p className="text-xs font-semibold text-slate-200">All caught up!</p>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
No unread in-app notifications at this time.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
top5Unread.map(item => {
|
||||
const relativeTime = getRelativeTime(item.createdAt);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => 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 */}
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 mt-1.5 shrink-0 shadow-sm shadow-blue-500" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1 mb-0.5">
|
||||
<p className="text-xs font-semibold text-white group-hover:text-blue-400 transition truncate">
|
||||
{item.subject || 'In-App Notification'}
|
||||
</p>
|
||||
{relativeTime && (
|
||||
<span className="text-[10px] text-slate-400 shrink-0 font-mono flex items-center gap-1">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{relativeTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-300 line-clamp-2 leading-relaxed">
|
||||
{stripMarkdown(item.message || 'No additional message content.')}
|
||||
</p>
|
||||
|
||||
{item.destination && (
|
||||
<p className="text-[10px] text-slate-400 font-mono mt-1 truncate">
|
||||
{item.destination}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronRight className="w-4 h-4 text-slate-400 group-hover:text-white group-hover:translate-x-0.5 transition shrink-0 mt-1" />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Popover Footer: Open Inbox */}
|
||||
<div className="p-2.5 border-t border-slate-800 bg-[#090d16]/90 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleViewAll}
|
||||
id="view-mailbox-btn"
|
||||
className="w-full flex items-center justify-center gap-1.5 py-1.5 px-3 rounded-xl bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 hover:text-white border border-blue-500/30 text-xs font-semibold transition cursor-pointer shadow-xs"
|
||||
>
|
||||
<Inbox className="w-3.5 h-3.5" />
|
||||
<span>Go to Mailbox ({unreadCount} unread)</span>
|
||||
<ChevronRight className="w-3 h-3 ml-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<ComposeMessageModalProps> = ({
|
||||
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<SelectedUser[]>([]);
|
||||
const [subject, setSubject] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [platform, setPlatform] = useState<NotificationPlatform>(initialPlatform);
|
||||
const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successNotice, setSuccessNotice] = useState<string | null>(null);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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(
|
||||
`\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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-150">
|
||||
<div className="w-full max-w-3xl bg-[#0d1322] border border-slate-700/80 rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[92vh]">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-gradient-to-r from-blue-950/50 via-slate-900/60 to-slate-900/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrandLogo size={28} showGlow animateOnHover={false} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-bold text-white tracking-tight">
|
||||
{draftToEdit
|
||||
? 'Edit Saved Draft'
|
||||
: initialSubject.startsWith('Re:')
|
||||
? 'Reply to Message'
|
||||
: 'Compose New Communication'}
|
||||
</h3>
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-full">
|
||||
Rich Markdown Engine
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Author in Markdown with live preview, SVG branding, and dual InApp / Email delivery
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Body */}
|
||||
<form onSubmit={handleSend} className="p-6 space-y-4 overflow-y-auto flex-1">
|
||||
{error && (
|
||||
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/25 text-rose-300 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successNotice && (
|
||||
<div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/25 text-emerald-300 text-xs flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-400" />
|
||||
<span>{successNotice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Platform Selector (InApp vs Email) */}
|
||||
<div className="p-3 rounded-2xl bg-slate-900/60 border border-slate-800 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-200 block">
|
||||
Dispatch Platform Mode
|
||||
</label>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Select target transmission protocol for this notification
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-xl bg-slate-950 p-1 border border-slate-700/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatform(NotificationPlatform.InApp)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold transition cursor-pointer ${
|
||||
platform === NotificationPlatform.InApp
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Smartphone className="w-3.5 h-3.5" />
|
||||
<span>In-App (Markdown)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatform(NotificationPlatform.Email)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold transition cursor-pointer ${
|
||||
platform === NotificationPlatform.Email
|
||||
? 'bg-emerald-600 text-white shadow-md shadow-emerald-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
<span>Email (HTML + Brand)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Description Indicator */}
|
||||
<div className="px-1 text-[11px]">
|
||||
{platform === NotificationPlatform.InApp ? (
|
||||
<span className="text-blue-300 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
Saved directly as <strong>Markdown</strong>. Delivered instantly to the recipient's InApp Mailbox & Notification Bell.
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-emerald-300 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Composed in Markdown, transformed into <strong>Branded Executive HTML</strong> with embedded SVG logo before dispatching.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recipient Picker */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
To (Recipient) <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<UserSearchPicker
|
||||
selectedUsers={selectedUsers}
|
||||
onSelectUser={handleSelectUser}
|
||||
onRemoveUser={handleRemoveUser}
|
||||
multiple={false}
|
||||
placeholder="Search tenant users by name, email, or functional role..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subject Field */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Subject Line <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Markdown Editor / Preview Tabs */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Message Content <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
|
||||
{/* Write vs Preview Tabs */}
|
||||
<div className="flex items-center rounded-lg bg-slate-900 p-0.5 border border-slate-700/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('write')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
|
||||
activeTab === 'write'
|
||||
? 'bg-blue-600 text-white font-semibold shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
<span>Write Markdown</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('preview')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition cursor-pointer ${
|
||||
activeTab === 'preview'
|
||||
? 'bg-blue-600 text-white font-semibold shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>Preview Rendered</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markdown Toolbar (Only when activeTab === 'write') */}
|
||||
{activeTab === 'write' ? (
|
||||
<div className="rounded-2xl border border-slate-700/80 bg-slate-900/90 overflow-hidden shadow-inner focus-within:border-blue-500 transition">
|
||||
{/* Toolbar */}
|
||||
<div className="px-3 py-2 border-b border-slate-800 bg-slate-950/70 flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('**', '**', 'bold text')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Bold (**text**)"
|
||||
>
|
||||
<Bold className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('*', '*', 'italic text')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Italic (*text*)"
|
||||
>
|
||||
<Italic className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('### ', '', 'Heading Title')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Heading (### )"
|
||||
>
|
||||
<Heading2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-slate-800 mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('- ', '', 'List item')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Bullet List (- )"
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('1. ', '', 'First item')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Numbered List (1. )"
|
||||
>
|
||||
<ListOrdered className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('> ', '', 'Quoted text')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Blockquote (> )"
|
||||
>
|
||||
<Quote className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-slate-800 mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={insertMilestoneTable}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Insert Deliverable Table"
|
||||
>
|
||||
<Table className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('```\n', '\n```', 'code block')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Code Block (```)"
|
||||
>
|
||||
<Code className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown('\n---\n')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Horizontal Rule (---)"
|
||||
>
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<span className="w-px h-4 bg-slate-800 mx-1" />
|
||||
|
||||
{/* Platform Brand Insertions */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={insertBrandedCallout}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-300 text-[11px] font-medium border border-blue-500/25 transition cursor-pointer"
|
||||
title="Insert official PFM callout block"
|
||||
>
|
||||
<Sparkles className="w-3 h-3 text-blue-400" />
|
||||
<span>PFM Notice</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={insertPlatformLogo}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 text-[11px] font-medium border border-emerald-500/25 transition cursor-pointer"
|
||||
title="Embed official SVG PFM logo into markdown"
|
||||
>
|
||||
<BrandLogo size={14} showGlow={false} animateOnHover={false} />
|
||||
<span>Embed Logo</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={8}
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
placeholder="Compose your message in Markdown... Use formatting buttons above for headings, bullet points, deliverable tables, and official PFM notices."
|
||||
className="w-full p-4 bg-transparent text-slate-100 placeholder-slate-400 text-xs focus:outline-none transition resize-y leading-relaxed font-mono"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Live Fully-Rendered Markdown / HTML Preview */
|
||||
<div className="rounded-2xl border border-slate-700/80 bg-slate-950/80 p-5 overflow-y-auto max-h-72 min-h-56">
|
||||
<div className="mb-3 pb-2 border-b border-slate-800 flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span className="flex items-center gap-1.5 text-blue-300 font-semibold">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||
Live Rendered Preview ({platform === NotificationPlatform.Email ? 'HTML Email' : 'InApp Markdown'})
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
Subject: {subject || 'Untitled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{message.trim() ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-xs leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: livePreviewHtml }}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-12 text-center text-slate-400 text-xs italic">
|
||||
Type your markdown message in the Write tab to see it live-rendered here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Metadata */}
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400 px-1 pt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<span>Tenant Partition:</span>
|
||||
<strong className="text-slate-300">{tenantName}</strong>
|
||||
</span>
|
||||
<span className="font-mono">{message.length} characters</span>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer Action Buttons */}
|
||||
<div className="pt-4 border-t border-slate-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDiscard}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-slate-400 hover:text-rose-400 hover:bg-rose-950/20 text-xs font-medium transition cursor-pointer"
|
||||
title="Discard draft"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span>Discard</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveDraft}
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-medium border border-slate-700 transition cursor-pointer"
|
||||
title="Save as Draft"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Save Draft</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-300 text-xs font-medium border border-slate-800 transition cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSending}
|
||||
className="flex items-center gap-2 px-5 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{isSending ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{platform === NotificationPlatform.Email
|
||||
? 'Dispatch Email (HTML)'
|
||||
: 'Send In-App Message'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Code,
|
||||
Eye,
|
||||
Copy,
|
||||
Check,
|
||||
ShieldCheck,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { BrandLogo } from '../ui/BrandLogo';
|
||||
import { parseMarkdownToHtml, isHtmlContent } from '../../lib/markdownUtils';
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string;
|
||||
subject?: string;
|
||||
showBrandingHeader?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||
content,
|
||||
subject,
|
||||
showBrandingHeader = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'rendered' | 'raw'>('rendered');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const isHtml = useMemo(() => isHtmlContent(content), [content]);
|
||||
|
||||
const renderedHtml = useMemo(() => {
|
||||
if (!content) return '<p class="text-slate-400 italic">No content</p>';
|
||||
if (isHtml) return content;
|
||||
return parseMarkdownToHtml(content);
|
||||
}, [content, isHtml]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col rounded-2xl bg-slate-900/40 border border-slate-800/80 overflow-hidden ${className}`}>
|
||||
{/* Top Controls Bar */}
|
||||
<div className="px-4 py-2 border-b border-slate-800 bg-[#0d1322]/80 flex items-center justify-between text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
{showBrandingHeader && (
|
||||
<div className="flex items-center gap-1.5 text-slate-300 font-semibold pr-2 border-r border-slate-700/60">
|
||||
<BrandLogo size={18} showGlow={false} animateOnHover={false} />
|
||||
<span className="text-[11px] tracking-tight">PFM Markdown Engine</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[10px] text-blue-400 font-mono">
|
||||
{isHtml ? 'Format: HTML Email' : 'Format: GitHub Flavored Markdown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Toggle View Mode */}
|
||||
<div className="flex items-center rounded-lg bg-slate-950/70 p-0.5 border border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('rendered')}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition cursor-pointer ${
|
||||
viewMode === 'rendered'
|
||||
? 'bg-blue-600/30 text-white font-semibold shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>Rendered</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('raw')}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition cursor-pointer ${
|
||||
viewMode === 'raw'
|
||||
? 'bg-blue-600/30 text-white font-semibold shadow-xs'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Code className="w-3 h-3" />
|
||||
<span>Raw Source</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-slate-700/60"
|
||||
title="Copy content to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-400" />
|
||||
<span className="text-emerald-300">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Rendering */}
|
||||
{viewMode === 'rendered' ? (
|
||||
<div className="p-6 overflow-x-auto text-sm leading-relaxed text-slate-200 font-sans">
|
||||
{isHtml ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="prose prose-invert max-w-none [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_h4]:text-white [&_a]:text-blue-400 [&_a:hover]:underline [&_table]:border-collapse [&_th]:border [&_th]:border-slate-700 [&_th]:p-2 [&_td]:border [&_td]:border-slate-800 [&_td]:p-2 [&_blockquote]:border-l-4 [&_blockquote]:border-blue-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-blue-300"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="p-5 overflow-x-auto font-mono text-xs text-slate-300 bg-[#070b13] select-all whitespace-pre-wrap leading-relaxed">
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Branded Footer Stamp */}
|
||||
{showBrandingHeader && (
|
||||
<div className="px-5 py-2.5 border-t border-slate-800/80 bg-[#0a0f1b] flex items-center justify-between text-[11px] text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Verified PFM Secure Platform Formatting</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-slate-400">
|
||||
{content.length} characters
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Search,
|
||||
User as UserIcon,
|
||||
Check,
|
||||
X,
|
||||
Shield,
|
||||
Briefcase,
|
||||
Coins,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { getUsers } from '../../services/api';
|
||||
import { User, ActivityStatus } from '../../types';
|
||||
|
||||
export interface SelectedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface UserSearchPickerProps {
|
||||
selectedUsers: SelectedUser[];
|
||||
onSelectUser: (user: SelectedUser) => void;
|
||||
onRemoveUser: (userId: string) => void;
|
||||
multiple?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface UserGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
|
||||
selectedUsers,
|
||||
onSelectUser,
|
||||
onRemoveUser,
|
||||
multiple = false,
|
||||
placeholder = 'Search by name, email, or role...',
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Query tenant users
|
||||
const { data: users = [], isLoading } = useQuery<User[]>({
|
||||
queryKey: ['users-directory'],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Filter active users
|
||||
const activeUsers = useMemo(() => {
|
||||
return users.filter(u => u.status === ActivityStatus.Active && !!u.email);
|
||||
}, [users]);
|
||||
|
||||
// Organize users into intuitive functional groups
|
||||
const groupedUsers = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
|
||||
const filtered = activeUsers.filter(u => {
|
||||
if (!term) return true;
|
||||
const email = (u.email || '').toLowerCase();
|
||||
const role = (u.role || '').toLowerCase();
|
||||
return email.includes(term) || role.includes(term);
|
||||
});
|
||||
|
||||
const adminUsers: User[] = [];
|
||||
const pmUsers: User[] = [];
|
||||
const financeUsers: User[] = [];
|
||||
const meUsers: User[] = [];
|
||||
const otherUsers: User[] = [];
|
||||
|
||||
filtered.forEach(u => {
|
||||
const r = (u.role || '').toLowerCase();
|
||||
if (r.includes('admin') || r.includes('executive') || r.includes('director')) {
|
||||
adminUsers.push(u);
|
||||
} else if (r.includes('programme') || r.includes('project') || r.includes('manager')) {
|
||||
pmUsers.push(u);
|
||||
} else if (r.includes('finance') || r.includes('disbursement') || r.includes('accountant') || r.includes('budget')) {
|
||||
financeUsers.push(u);
|
||||
} else if (r.includes('m&e') || r.includes('monitoring') || r.includes('eval') || r.includes('risk') || r.includes('compliance')) {
|
||||
meUsers.push(u);
|
||||
} else {
|
||||
otherUsers.push(u);
|
||||
}
|
||||
});
|
||||
|
||||
const groups: UserGroup[] = [];
|
||||
|
||||
if (adminUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'admin',
|
||||
label: 'Leadership & Administration',
|
||||
icon: Shield,
|
||||
color: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
users: adminUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (pmUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'pm',
|
||||
label: 'Programme & Project Management',
|
||||
icon: Briefcase,
|
||||
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
||||
users: pmUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (financeUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'finance',
|
||||
label: 'Finance & Disbursements',
|
||||
icon: Coins,
|
||||
color: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
|
||||
users: financeUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (meUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'me',
|
||||
label: 'M&E, Compliance & Risks',
|
||||
icon: ShieldAlert,
|
||||
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
|
||||
users: meUsers,
|
||||
});
|
||||
}
|
||||
|
||||
if (otherUsers.length > 0) {
|
||||
groups.push({
|
||||
id: 'team',
|
||||
label: 'Tenant Staff & Team Members',
|
||||
icon: Users,
|
||||
color: 'text-slate-400 bg-slate-500/10 border-slate-500/20',
|
||||
users: otherUsers,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [activeUsers, searchTerm]);
|
||||
|
||||
const toggleGroupCollapse = (groupId: string) => {
|
||||
setCollapsedGroups(prev => ({
|
||||
...prev,
|
||||
[groupId]: !prev[groupId],
|
||||
}));
|
||||
};
|
||||
|
||||
const isUserSelected = (userId: string) => {
|
||||
return selectedUsers.some(u => u.id === userId);
|
||||
};
|
||||
|
||||
const handleUserClick = (u: User) => {
|
||||
if (!u.email) return;
|
||||
|
||||
if (isUserSelected(u.id)) {
|
||||
onRemoveUser(u.id);
|
||||
} else {
|
||||
const displayName = u.email.split('@')[0].replace(/[._-]/g, ' ');
|
||||
const formattedName = displayName
|
||||
.split(' ')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
|
||||
if (!multiple) {
|
||||
// Single selection replaces
|
||||
onSelectUser({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: formattedName,
|
||||
role: u.role || 'User',
|
||||
});
|
||||
setIsDropdownOpen(false);
|
||||
} else {
|
||||
onSelectUser({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: formattedName,
|
||||
role: u.role || 'User',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{/* Selected Users Chips */}
|
||||
{selectedUsers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{selectedUsers.map(su => (
|
||||
<span
|
||||
key={su.id}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-blue-500/15 border border-blue-500/30 text-blue-200 text-xs font-medium shadow-xs"
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-blue-600/40 text-[9px] font-bold flex items-center justify-center text-blue-300">
|
||||
{su.name ? su.name.slice(0, 2).toUpperCase() : su.email.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span className="truncate max-w-[180px]">{su.name || su.email}</span>
|
||||
{su.role && (
|
||||
<span className="text-[10px] text-blue-400 bg-blue-900/40 px-1 py-0.2 rounded border border-blue-800/40">
|
||||
{su.role}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveUser(su.id)}
|
||||
className="text-blue-400 hover:text-white hover:bg-blue-600/30 p-0.5 rounded transition cursor-pointer"
|
||||
title="Remove recipient"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Input Box */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-400">
|
||||
<Search className="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={e => {
|
||||
setSearchTerm(e.target.value);
|
||||
if (!isDropdownOpen) setIsDropdownOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsDropdownOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-9 pr-8 py-2 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"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Organized User Directory Dropdown */}
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1.5 max-h-72 overflow-y-auto bg-[#0d1322] border border-slate-700/80 rounded-2xl shadow-2xl z-50 p-2 space-y-3 backdrop-blur-md animate-in fade-in slide-in-from-top-1">
|
||||
{isLoading ? (
|
||||
<div className="py-6 text-center text-xs text-slate-400 flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span>Loading tenant users directory...</span>
|
||||
</div>
|
||||
) : groupedUsers.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-slate-400">
|
||||
No matching users found for "{searchTerm}".
|
||||
</div>
|
||||
) : (
|
||||
groupedUsers.map(group => {
|
||||
const GroupIcon = group.icon;
|
||||
const isCollapsed = collapsedGroups[group.id];
|
||||
|
||||
return (
|
||||
<div key={group.id} className="space-y-1">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroupCollapse(group.id)}
|
||||
className="w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-slate-900/60 hover:bg-slate-800/60 text-slate-300 text-[11px] font-semibold transition cursor-pointer border border-slate-800/60"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`p-1 rounded-md border ${group.color}`}>
|
||||
<GroupIcon className="w-3 h-3" />
|
||||
</span>
|
||||
<span>{group.label}</span>
|
||||
<span className="text-[10px] text-slate-400 bg-slate-800 px-1.5 py-0.2 rounded-full border border-slate-700 font-mono">
|
||||
{group.users.length}
|
||||
</span>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronUp className="w-3.5 h-3.5 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Users inside group */}
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-1 gap-1 pl-1 pr-1 pt-0.5">
|
||||
{group.users.map(u => {
|
||||
const selected = isUserSelected(u.id);
|
||||
const displayName = (u.email || '').split('@')[0].replace(/[._-]/g, ' ');
|
||||
const formattedName = displayName
|
||||
.split(' ')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
onClick={() => handleUserClick(u)}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-xl text-xs transition cursor-pointer border ${
|
||||
selected
|
||||
? 'bg-blue-600/20 border-blue-500/40 text-white'
|
||||
: 'bg-slate-900/40 hover:bg-slate-800/70 border-slate-800/40 text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div
|
||||
className={`w-7 h-7 shrink-0 rounded-full flex items-center justify-center font-bold text-[10px] border ${
|
||||
selected
|
||||
? 'bg-blue-600 border-blue-400 text-white'
|
||||
: 'bg-slate-800 border-slate-700 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{formattedName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-xs truncate text-white">{formattedName}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono truncate">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{u.role && (
|
||||
<span className="text-[10px] font-semibold text-blue-400 bg-blue-500/10 border border-blue-500/20 px-1.5 py-0.5 rounded truncate max-w-[120px]">
|
||||
{u.role}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`w-4 h-4 rounded-md border flex items-center justify-center ${
|
||||
selected
|
||||
? 'bg-blue-600 border-blue-500 text-white'
|
||||
: 'border-slate-700 bg-slate-950/60'
|
||||
}`}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3 stroke-[3]" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Close button at bottom */}
|
||||
<div className="pt-2 border-t border-slate-800 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
className="text-[11px] text-slate-400 hover:text-white px-3 py-1 rounded-lg hover:bg-slate-800 transition cursor-pointer"
|
||||
>
|
||||
Done Selecting
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import {
|
||||
getUserNotifications,
|
||||
getNotifications,
|
||||
createNotification,
|
||||
markNotificationAsRead,
|
||||
updateNotification,
|
||||
getCurrentUserTenantId,
|
||||
} from '../services/api';
|
||||
import {
|
||||
Notification,
|
||||
NotificationPlatform,
|
||||
NotificationStatus,
|
||||
CreateNotificationRequest,
|
||||
} from '../types';
|
||||
|
||||
export interface DraftMessage {
|
||||
id: string;
|
||||
recipientId: string;
|
||||
recipientEmail: string;
|
||||
recipientName?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
platform?: NotificationPlatform;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function useInAppNotifications() {
|
||||
const { user, claims } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const userId = (user?.id || claims?.sub || '') as string;
|
||||
const userEmail = (user?.email || claims?.email || '') as string;
|
||||
|
||||
// Local storage keys for client-side starred and drafts
|
||||
const draftsStorageKey = `pfm_mailbox_drafts_${userId || 'default'}`;
|
||||
const starredStorageKey = `pfm_mailbox_starred_${userId || 'default'}`;
|
||||
const trashStorageKey = `pfm_mailbox_trash_${userId || 'default'}`;
|
||||
const sentStorageKey = `pfm_mailbox_sent_${userId || 'default'}`;
|
||||
|
||||
// Starred messages set
|
||||
const [starredIds, setStarredIds] = useState<string[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(starredStorageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Trashed messages set
|
||||
const [trashedIds, setTrashedIds] = useState<string[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(trashStorageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Local drafts list
|
||||
const [drafts, setDrafts] = useState<DraftMessage[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(draftsStorageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Local sent messages history
|
||||
const [localSent, setLocalSent] = useState<Notification[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(sentStorageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Sync state to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(starredStorageKey, JSON.stringify(starredIds));
|
||||
} catch {}
|
||||
}, [starredIds, starredStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(trashStorageKey, JSON.stringify(trashedIds));
|
||||
} catch {}
|
||||
}, [trashedIds, trashStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(draftsStorageKey, JSON.stringify(drafts));
|
||||
} catch {}
|
||||
}, [drafts, draftsStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(sentStorageKey, JSON.stringify(localSent));
|
||||
} catch {}
|
||||
}, [localSent, sentStorageKey]);
|
||||
|
||||
// Fetch notifications from backend
|
||||
const { data: rawNotifications = [], isLoading, refetch } = useQuery<Notification[]>({
|
||||
queryKey: ['inapp-notifications', userId],
|
||||
queryFn: async () => {
|
||||
// 1. Try user-specific notifications
|
||||
let list = await getUserNotifications(userId || undefined);
|
||||
// 2. If list is empty or fallback needed, also check organisation notifications
|
||||
if (!list || list.length === 0) {
|
||||
const orgList = await getNotifications();
|
||||
if (orgList && orgList.length > 0) {
|
||||
list = orgList;
|
||||
}
|
||||
}
|
||||
return list || [];
|
||||
},
|
||||
// Live polling every 30 seconds
|
||||
refetchInterval: 30000,
|
||||
staleTime: 10000,
|
||||
});
|
||||
|
||||
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
|
||||
const inAppNotifications = useMemo(() => {
|
||||
return rawNotifications.filter(
|
||||
n => Number(n.platform) === NotificationPlatform.InApp || n.platform === (NotificationPlatform.InApp as any)
|
||||
);
|
||||
}, [rawNotifications]);
|
||||
|
||||
// Active inbox notifications (InApp + Email for mailbox, excluding trashed and drafts)
|
||||
const allMailboxNotifications = useMemo(() => {
|
||||
return rawNotifications.filter(
|
||||
n =>
|
||||
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email) &&
|
||||
!trashedIds.includes(String(n.id)) &&
|
||||
Number(n.status) !== NotificationStatus.Draft
|
||||
);
|
||||
}, [rawNotifications, trashedIds]);
|
||||
|
||||
// Active in-app notifications in inbox
|
||||
const inboxNotifications = useMemo(() => {
|
||||
return inAppNotifications.filter(
|
||||
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
|
||||
);
|
||||
}, [inAppNotifications, trashedIds]);
|
||||
|
||||
// Unread InApp notifications: status !== Read && status !== Draft && status !== Cancelled && status !== Failed
|
||||
const unreadNotifications = useMemo(() => {
|
||||
return inboxNotifications.filter(
|
||||
n =>
|
||||
Number(n.status) !== NotificationStatus.Read &&
|
||||
Number(n.status) !== NotificationStatus.Cancelled &&
|
||||
Number(n.status) !== NotificationStatus.Failed
|
||||
);
|
||||
}, [inboxNotifications]);
|
||||
|
||||
const unreadCount = unreadNotifications.length;
|
||||
const top5Unread = useMemo(() => unreadNotifications.slice(0, 5), [unreadNotifications]);
|
||||
|
||||
// Starred notifications
|
||||
const starredNotifications = useMemo(() => {
|
||||
return rawNotifications.filter(
|
||||
n =>
|
||||
starredIds.includes(String(n.id)) &&
|
||||
!trashedIds.includes(String(n.id)) &&
|
||||
(Number(n.platform) === NotificationPlatform.InApp || Number(n.platform) === NotificationPlatform.Email)
|
||||
);
|
||||
}, [rawNotifications, starredIds, trashedIds]);
|
||||
|
||||
// Trashed notifications
|
||||
const trashedNotifications = useMemo(() => {
|
||||
return rawNotifications.filter(n => trashedIds.includes(String(n.id)));
|
||||
}, [rawNotifications, trashedIds]);
|
||||
|
||||
// Mutation to mark notification as read
|
||||
const markAsReadMutation = useMutation({
|
||||
mutationFn: async (id: number | string) => {
|
||||
await markNotificationAsRead(id);
|
||||
},
|
||||
onMutate: async (id) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['inapp-notifications', userId] });
|
||||
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
|
||||
old.map(n => (String(n.id) === String(id) ? { ...n, status: NotificationStatus.Read } : n))
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Mark all unread as read
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
const unread = unreadNotifications;
|
||||
for (const item of unread) {
|
||||
try {
|
||||
await markNotificationAsRead(item.id);
|
||||
} catch {}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
}, [unreadNotifications, queryClient]);
|
||||
|
||||
// Toggle star
|
||||
const toggleStar = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setStarredIds(prev => (prev.includes(strId) ? prev.filter(x => x !== strId) : [...prev, strId]));
|
||||
}, []);
|
||||
|
||||
// Delete message (move to trash)
|
||||
const deleteMessage = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => (prev.includes(strId) ? prev : [...prev, strId]));
|
||||
}, []);
|
||||
|
||||
// Restore from trash
|
||||
const restoreMessage = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => prev.filter(x => x !== strId));
|
||||
}, []);
|
||||
|
||||
// Permanent delete
|
||||
const permanentDelete = useCallback((id: string | number) => {
|
||||
const strId = String(id);
|
||||
setTrashedIds(prev => prev.filter(x => x !== strId));
|
||||
setLocalSent(prev => prev.filter(n => String(n.id) !== strId));
|
||||
}, []);
|
||||
|
||||
// Draft management
|
||||
const saveDraft = useCallback((draft: Omit<DraftMessage, 'id' | 'updatedAt'> & { id?: string }) => {
|
||||
const draftId = draft.id || `draft_${Date.now()}`;
|
||||
const newDraft: DraftMessage = {
|
||||
...draft,
|
||||
id: draftId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setDrafts(prev => {
|
||||
const filtered = prev.filter(d => d.id !== draftId);
|
||||
return [newDraft, ...filtered];
|
||||
});
|
||||
return draftId;
|
||||
}, []);
|
||||
|
||||
const deleteDraft = useCallback((id: string) => {
|
||||
setDrafts(prev => prev.filter(d => d.id !== id));
|
||||
}, []);
|
||||
|
||||
// Send new notification (InApp or Email)
|
||||
const sendMessageMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
recipientId: string;
|
||||
recipientEmail: string;
|
||||
recipientName?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
platform?: NotificationPlatform;
|
||||
draftId?: string;
|
||||
}) => {
|
||||
const orgId = getCurrentUserTenantId() || 1;
|
||||
const targetPlatform = payload.platform || NotificationPlatform.InApp;
|
||||
const formattedDestination = `To: ${payload.recipientEmail} | From: ${userEmail || 'System User'}`;
|
||||
|
||||
const req: CreateNotificationRequest = {
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipientId,
|
||||
platform: targetPlatform,
|
||||
subject: payload.subject,
|
||||
destination: formattedDestination,
|
||||
message: payload.message,
|
||||
status: targetPlatform === NotificationPlatform.InApp ? NotificationStatus.Delivered : NotificationStatus.Queued,
|
||||
};
|
||||
|
||||
await createNotification(req);
|
||||
|
||||
// Record in local sent messages
|
||||
const sentItem: Notification = {
|
||||
id: `sent_${Date.now()}`,
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipientId,
|
||||
platform: targetPlatform,
|
||||
status: NotificationStatus.Sent,
|
||||
subject: payload.subject,
|
||||
message: payload.message,
|
||||
destination: formattedDestination,
|
||||
createdAt: new Date().toISOString(),
|
||||
sentAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setLocalSent(prev => [sentItem, ...prev]);
|
||||
|
||||
// If sending a draft, remove the draft
|
||||
if (payload.draftId) {
|
||||
deleteDraft(payload.draftId);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
rawNotifications,
|
||||
inAppNotifications,
|
||||
allMailboxNotifications,
|
||||
inboxNotifications,
|
||||
unreadNotifications,
|
||||
unreadCount,
|
||||
top5Unread,
|
||||
starredNotifications,
|
||||
trashedNotifications,
|
||||
sentNotifications: localSent,
|
||||
drafts,
|
||||
starredIds,
|
||||
trashedIds,
|
||||
isLoading,
|
||||
refetch,
|
||||
markAsRead: (id: string | number) => markAsReadMutation.mutate(id),
|
||||
markAllAsRead,
|
||||
toggleStar,
|
||||
deleteMessage,
|
||||
restoreMessage,
|
||||
permanentDelete,
|
||||
saveDraft,
|
||||
deleteDraft,
|
||||
sendMessage: sendMessageMutation.mutateAsync,
|
||||
isSending: sendMessageMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -79,6 +79,7 @@ export const NOTIFICATION_STATUS_OPTIONS = [
|
||||
{ value: NotificationStatus.Failed, label: 'Failed' },
|
||||
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
||||
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
||||
{ value: NotificationStatus.Draft, label: 'Draft' },
|
||||
];
|
||||
|
||||
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
||||
@@ -509,6 +510,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
|
||||
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
||||
const s = Number(status);
|
||||
switch (s) {
|
||||
case NotificationStatus.Draft:
|
||||
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
|
||||
case NotificationStatus.Delivered:
|
||||
case NotificationStatus.Read:
|
||||
return { label: getNotificationStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Markdown & Branded HTML Formatting Utilities for PostFundManagement (PFM)
|
||||
*/
|
||||
|
||||
export const PFM_BRAND_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="40" height="40" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="pfm-ambient" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.4" />
|
||||
<stop offset="70%" stop-color="#2563eb" stop-opacity="0.1" />
|
||||
<stop offset="100%" stop-color="#1e1b4b" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient id="pfm-top" x1="12" y1="8" x2="52" y2="26" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#a5b4fc" />
|
||||
<stop offset="35%" stop-color="#6366f1" />
|
||||
<stop offset="100%" stop-color="#4338ca" />
|
||||
</linearGradient>
|
||||
<linearGradient id="pfm-left" x1="10" y1="22" x2="30" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#38bdf8" />
|
||||
<stop offset="45%" stop-color="#2563eb" />
|
||||
<stop offset="100%" stop-color="#1e40af" />
|
||||
</linearGradient>
|
||||
<linearGradient id="pfm-right" x1="34" y1="28" x2="54" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#22d3ee" />
|
||||
<stop offset="50%" stop-color="#06b6d4" />
|
||||
<stop offset="100%" stop-color="#10b981" />
|
||||
</linearGradient>
|
||||
<linearGradient id="pfm-core" x1="27" y1="27" x2="37" y2="37" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="40%" stop-color="#e0f2fe" />
|
||||
<stop offset="100%" stop-color="#38bdf8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="31" fill="#090d16" />
|
||||
<circle cx="32" cy="32" r="30" stroke="#1e293b" stroke-width="1" />
|
||||
<circle cx="32" cy="32" r="28" fill="url(#pfm-ambient)" />
|
||||
<path d="M32 6.5 L53.5 19 C55 19.8 56 21.5 56 23.2 L56 40.8 C56 42.5 55 44.2 53.5 45 L32 57.5 C30.5 58.3 28.5 58.3 27 57.5 L5.5 45 C4 44.2 3 42.5 3 40.8 L3 23.2 C3 21.5 4 19.8 5.5 19 L27 6.5 C28.5 5.7 30.5 5.7 32 6.5 Z" stroke="#3b82f6" stroke-opacity="0.3" stroke-width="1.2" fill="none" />
|
||||
<path d="M32 9 L51.5 20.2 L43 25.2 L32 18.8 L21 25.2 L12.5 20.2 Z" fill="url(#pfm-top)" />
|
||||
<path d="M10 23 L19.2 28.4 L19.2 44.5 L30 50.8 L30 55.5 L10 43.8 Z" fill="url(#pfm-left)" />
|
||||
<path d="M54 23 L54 43.8 L34 55.5 L34 50.8 L44.8 44.5 L44.8 28.4 Z" fill="url(#pfm-right)" />
|
||||
<path d="M22 28.5 L32 22.8 L42 28.5 L42 35.5 L32 41.2 L22 35.5 Z" fill="#0b1120" stroke="#334155" stroke-width="1" />
|
||||
<path d="M25 31.5 L32 27.5 L39 31.5 L32 35.5 Z" fill="url(#pfm-top)" opacity="0.9" />
|
||||
<path d="M25 37 L32 41 L39 37 L32 33 L25 37 Z" fill="url(#pfm-right)" opacity="0.9" />
|
||||
<polygon points="32,27 36.5,32 32,37 27.5,32" fill="url(#pfm-core)" />
|
||||
<circle cx="32" cy="32" r="1.5" fill="#ffffff" />
|
||||
</svg>`;
|
||||
|
||||
export const PFM_LOGO_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent(PFM_BRAND_LOGO_SVG)}`;
|
||||
|
||||
export interface MarkdownRenderOptions {
|
||||
applyBranding?: boolean;
|
||||
emailFormat?: boolean;
|
||||
subject?: string;
|
||||
tenantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the content string is formatted HTML or Markdown.
|
||||
*/
|
||||
export function isHtmlContent(content: string): boolean {
|
||||
if (!content) return false;
|
||||
const trimmed = content.trim();
|
||||
return (
|
||||
trimmed.startsWith('<!DOCTYPE') ||
|
||||
trimmed.startsWith('<html') ||
|
||||
trimmed.startsWith('<div') ||
|
||||
trimmed.startsWith('<table') ||
|
||||
trimmed.startsWith('<p>') ||
|
||||
(trimmed.includes('</') && trimmed.includes('>'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts raw plain text from markdown or HTML for concise notification snippets.
|
||||
*/
|
||||
export function stripMarkdown(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/<[^>]*>/g, ' ') // Strip HTML tags
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // Strip images
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // Extract link text
|
||||
.replace(/#{1,6}\s+/g, '') // Strip headers
|
||||
.replace(/(\*\*|__)(.*?)\1/g, '$2') // Strip bold
|
||||
.replace(/(\*|_)(.*?)\1/g, '$2') // Strip italic
|
||||
.replace(/~~(.*?)~~/g, '$1') // Strip strikethrough
|
||||
.replace(/`{1,3}.*?`{1,3}/gs, '') // Strip code blocks
|
||||
.replace(/^>\s+/gm, '') // Strip blockquotes
|
||||
.replace(/^[-*+]\s+/gm, '') // Strip list markers
|
||||
.replace(/^\d+\.\s+/gm, '') // Strip ordered list markers
|
||||
.replace(/\|/g, ' ') // Replace table pipes
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses markdown text into semantic HTML.
|
||||
*/
|
||||
export function parseMarkdownToHtml(markdown: string): string {
|
||||
if (!markdown) return '';
|
||||
|
||||
let html = markdown
|
||||
// Sanitize script tags
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
// Normalize line endings
|
||||
.replace(/\r\n/g, '\n');
|
||||
|
||||
// 1. Code blocks (```lang\ncode\n```)
|
||||
html = html.replace(/```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g, (_match, _lang, code) => {
|
||||
const escaped = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return `<pre style="background:#0b1120;border:1px solid #1e293b;border-radius:12px;padding:14px;overflow-x:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;color:#e2e8f0;margin:12px 0;"><code>${escaped}</code></pre>`;
|
||||
});
|
||||
|
||||
// 2. Inline code (`code`)
|
||||
html = html.replace(/`([^`]+)`/g, (_match, code) => {
|
||||
const escaped = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return `<code style="background:#1e293b;color:#38bdf8;padding:2px 6px;border-radius:6px;font-family:monospace;font-size:12px;">${escaped}</code>`;
|
||||
});
|
||||
|
||||
// 3. Tables (| A | B |\n|---|---|\n| C | D |)
|
||||
html = html.replace(/((?:\|[^\n]+\|\n?)+)/g, (match) => {
|
||||
const rows = match.trim().split('\n');
|
||||
if (rows.length < 2) return match;
|
||||
|
||||
const hasSeparator = rows[1] && rows[1].includes('---');
|
||||
if (!hasSeparator) return match;
|
||||
|
||||
const parseCells = (row: string) =>
|
||||
row
|
||||
.split('|')
|
||||
.slice(1, -1)
|
||||
.map((c) => c.trim());
|
||||
|
||||
const headers = parseCells(rows[0]);
|
||||
const bodyRows = rows.slice(2);
|
||||
|
||||
let tableHtml = `<div style="overflow-x:auto;margin:14px 0;"><table style="width:100%;border-collapse:collapse;border:1px solid #334155;border-radius:8px;font-size:12px;text-align:left;">`;
|
||||
tableHtml += `<thead><tr style="background:#1e293b;color:#f8fafc;">`;
|
||||
headers.forEach((h) => {
|
||||
tableHtml += `<th style="padding:8px 12px;border:1px solid #334155;font-weight:600;">${h}</th>`;
|
||||
});
|
||||
tableHtml += `</tr></thead><tbody>`;
|
||||
|
||||
bodyRows.forEach((row, i) => {
|
||||
const cells = parseCells(row);
|
||||
const bg = i % 2 === 0 ? '#0f172a' : '#1e293b/40';
|
||||
tableHtml += `<tr style="background:${bg};color:#cbd5e1;">`;
|
||||
cells.forEach((c) => {
|
||||
tableHtml += `<td style="padding:8px 12px;border:1px solid #334155;">${c}</td>`;
|
||||
});
|
||||
tableHtml += `</tr>`;
|
||||
});
|
||||
|
||||
tableHtml += `</tbody></table></div>`;
|
||||
return tableHtml;
|
||||
});
|
||||
|
||||
// 4. Headers (# through ####)
|
||||
html = html.replace(/^#### (.*?)$/gm, '<h4 style="font-size:14px;font-weight:700;color:#f8fafc;margin:12px 0 6px 0;">$1</h4>');
|
||||
html = html.replace(/^### (.*?)$/gm, '<h3 style="font-size:16px;font-weight:700;color:#f8fafc;margin:14px 0 8px 0;">$1</h3>');
|
||||
html = html.replace(/^## (.*?)$/gm, '<h2 style="font-size:18px;font-weight:800;color:#ffffff;margin:16px 0 8px 0;border-bottom:1px solid #1e293b;padding-bottom:6px;">$1</h2>');
|
||||
html = html.replace(/^# (.*?)$/gm, '<h1 style="font-size:22px;font-weight:800;color:#ffffff;margin:18px 0 10px 0;border-bottom:1px solid #334155;padding-bottom:8px;">$1</h1>');
|
||||
|
||||
// 5. Blockquotes and GitHub alerts (> [!NOTE], > [!IMPORTANT], etc.)
|
||||
html = html.replace(/^>\s*\[!NOTE\]\s*(.*?)$/gim, '<div style="background:#0c4a6e22;border-left:4px solid #0284c7;padding:10px 14px;border-radius:8px;margin:10px 0;color:#bae6fd;font-size:12px;"><strong>NOTE:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!IMPORTANT\]\s*(.*?)$/gim, '<div style="background:#83184322;border-left:4px solid #db2777;padding:10px 14px;border-radius:8px;margin:10px 0;color:#fbcfe8;font-size:12px;"><strong>IMPORTANT:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!WARNING\]\s*(.*?)$/gim, '<div style="background:#713f1222;border-left:4px solid #d97706;padding:10px 14px;border-radius:8px;margin:10px 0;color:#fef08a;font-size:12px;"><strong>WARNING:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*\[!TIP\]\s*(.*?)$/gim, '<div style="background:#064e3b22;border-left:4px solid #059669;padding:10px 14px;border-radius:8px;margin:10px 0;color:#a7f3d0;font-size:12px;"><strong>TIP:</strong> $1</div>');
|
||||
html = html.replace(/^>\s*(.*?)$/gm, '<blockquote style="border-left:3px solid #3b82f6;padding-left:12px;margin:8px 0;color:#94a3b8;font-style:italic;">$1</blockquote>');
|
||||
|
||||
// 6. Horizontal Rules (---)
|
||||
html = html.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr style="border:none;border-top:1px solid #334155;margin:16px 0;" />');
|
||||
|
||||
// 7. Bold & Italic
|
||||
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#ffffff;">$1</strong>');
|
||||
html = html.replace(/__(.*?)__/g, '<strong style="color:#ffffff;">$1</strong>');
|
||||
html = html.replace(/\*(.*?)\*/g, '<em style="color:#cbd5e1;">$1</em>');
|
||||
html = html.replace(/_(.*?)_/g, '<em style="color:#cbd5e1;">$1</em>');
|
||||
html = html.replace(/~~(.*?)~~/g, '<del style="color:#64748b;">$1</del>');
|
||||
|
||||
// 8. Images and Links
|
||||
html = html.replace(/!\[(.*?)\]\((.*?)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:8px;margin:10px 0;box-shadow:0 4px 12px rgba(0,0,0,0.5);" />');
|
||||
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#38bdf8;text-decoration:underline;">$1</a>');
|
||||
|
||||
// 9. Unordered Lists (- or *)
|
||||
html = html.replace(/^[-*+]\s+(.*?)$/gm, '<li style="margin-left:20px;list-style-type:disc;color:#cbd5e1;padding:2px 0;">$1</li>');
|
||||
|
||||
// 10. Ordered Lists (1. 2.)
|
||||
html = html.replace(/^\d+\.\s+(.*?)$/gm, '<li style="margin-left:20px;list-style-type:decimal;color:#cbd5e1;padding:2px 0;">$1</li>');
|
||||
|
||||
// 11. Paragraphs
|
||||
const paragraphs = html.split(/\n{2,}/);
|
||||
html = paragraphs
|
||||
.map((p) => {
|
||||
const trimmed = p.trim();
|
||||
if (!trimmed) return '';
|
||||
if (
|
||||
trimmed.startsWith('<h') ||
|
||||
trimmed.startsWith('<div') ||
|
||||
trimmed.startsWith('<table') ||
|
||||
trimmed.startsWith('<pre') ||
|
||||
trimmed.startsWith('<blockquote') ||
|
||||
trimmed.startsWith('<hr') ||
|
||||
trimmed.startsWith('<li')
|
||||
) {
|
||||
return trimmed;
|
||||
}
|
||||
return `<p style="margin:8px 0;line-height:1.6;color:#cbd5e1;">${trimmed.replace(/\n/g, '<br />')}</p>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms Markdown into full branded executive HTML for email notifications.
|
||||
*/
|
||||
export function renderMarkdownToHtml(
|
||||
markdown: string,
|
||||
options?: MarkdownRenderOptions
|
||||
): string {
|
||||
const contentHtml = parseMarkdownToHtml(markdown);
|
||||
|
||||
if (!options?.applyBranding && !options?.emailFormat) {
|
||||
return contentHtml;
|
||||
}
|
||||
|
||||
const subject = options?.subject || 'Statutory Platform Notification';
|
||||
const tenantName = options?.tenantName || 'Post-Fund Management (PFM) Platform';
|
||||
const dateFormatted = new Date().toLocaleDateString('en-ZA', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${subject}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:24px 12px;background-color:#090d16;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#cbd5e1;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:640px;margin:0 auto;background-color:#0d1322;border:1px solid #1e293b;border-radius:16px;overflow:hidden;box-shadow:0 12px 36px rgba(0,0,0,0.6);">
|
||||
<!-- Executive Brand Header -->
|
||||
<tr>
|
||||
<td style="padding:20px 24px;background:linear-gradient(135deg, #0b1120 0%, #1e1b4b 50%, #0f172a 100%);border-bottom:1px solid #1e293b;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td width="48" style="vertical-align:middle;">
|
||||
<img src="${PFM_LOGO_DATA_URI}" alt="PFM Logo" width="40" height="40" style="display:block;border-radius:10px;" />
|
||||
</td>
|
||||
<td style="padding-left:14px;vertical-align:middle;">
|
||||
<div style="font-size:14px;font-weight:800;color:#ffffff;letter-spacing:-0.02em;text-transform:uppercase;">
|
||||
Post-Fund Management (PFM)
|
||||
</div>
|
||||
<div style="font-size:11px;color:#38bdf8;font-weight:600;margin-top:2px;">
|
||||
${tenantName}
|
||||
</div>
|
||||
</td>
|
||||
<td align="right" style="vertical-align:middle;">
|
||||
<span style="font-size:10px;font-weight:700;color:#38bdf8;background-color:rgba(56,189,248,0.12);border:1px solid rgba(56,189,248,0.25);padding:4px 8px;border-radius:20px;text-transform:uppercase;letter-spacing:0.04em;">
|
||||
Official Notice
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Subject Banner -->
|
||||
<tr>
|
||||
<td style="padding:16px 24px;background-color:#0b101b;border-bottom:1px solid #1e293b;">
|
||||
<h2 style="margin:0;font-size:16px;font-weight:700;color:#ffffff;line-height:1.4;">
|
||||
${subject}
|
||||
</h2>
|
||||
<div style="font-size:11px;color:#64748b;margin-top:4px;">
|
||||
Dispatched on ${dateFormatted} • Electronic System Communication
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Message Content Body -->
|
||||
<tr>
|
||||
<td style="padding:24px;font-size:13px;line-height:1.65;color:#e2e8f0;">
|
||||
${contentHtml}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Executive Footer -->
|
||||
<tr>
|
||||
<td style="padding:18px 24px;background-color:#080c14;border-top:1px solid #1e293b;font-size:11px;color:#64748b;text-align:center;">
|
||||
<div style="font-weight:600;color:#94a3b8;margin-bottom:4px;">
|
||||
Post-Fund Management, Performance & Impact Governance Platform
|
||||
</div>
|
||||
<div>
|
||||
This message was transmitted securely under active multi-tenant encryption. Please do not reply directly to automated dispatch notices.
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:10px;color:#475569;">
|
||||
© ${new Date().getFullYear()} PFM Governance Architecture. All rights reserved.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Inbox,
|
||||
Send,
|
||||
FileText,
|
||||
Star,
|
||||
Trash2,
|
||||
Mail,
|
||||
MailOpen,
|
||||
Search,
|
||||
PenSquare,
|
||||
RefreshCw,
|
||||
Reply,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
RotateCcw,
|
||||
X,
|
||||
Smartphone,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { useInAppNotifications, DraftMessage } from '../hooks/useInAppNotifications';
|
||||
import { ComposeMessageModal } from '../components/notifications/ComposeMessageModal';
|
||||
import { MarkdownViewer } from '../components/notifications/MarkdownViewer';
|
||||
import { formatDate } from '../lib/formatters';
|
||||
import { Notification, NotificationPlatform, NotificationStatus } from '../types';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { stripMarkdown } from '../lib/markdownUtils';
|
||||
|
||||
type MailboxFolder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'trash';
|
||||
type PlatformFilter = 'all' | 'inapp' | 'email';
|
||||
|
||||
export const InboxPage: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const targetIdParam = searchParams.get('id');
|
||||
|
||||
const { user, claims } = useAuth();
|
||||
const currentUserEmail = (user?.email || claims?.email || '') as string;
|
||||
|
||||
const {
|
||||
allMailboxNotifications,
|
||||
inboxNotifications,
|
||||
unreadCount,
|
||||
starredNotifications,
|
||||
trashedNotifications,
|
||||
sentNotifications,
|
||||
drafts,
|
||||
starredIds,
|
||||
isLoading,
|
||||
refetch,
|
||||
markAsRead,
|
||||
toggleStar,
|
||||
deleteMessage,
|
||||
restoreMessage,
|
||||
permanentDelete,
|
||||
deleteDraft,
|
||||
sendMessage,
|
||||
isSending,
|
||||
} = useInAppNotifications();
|
||||
|
||||
// Active folder
|
||||
const [activeFolder, setActiveFolder] = useState<MailboxFolder>('inbox');
|
||||
// Platform filter
|
||||
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
|
||||
// Search query
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
// Filter for unread only in Inbox
|
||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||
// Selected message to view in detail
|
||||
const [selectedMessage, setSelectedMessage] = useState<Notification | null>(null);
|
||||
// Selected draft to edit/resume
|
||||
const [selectedDraft, setSelectedDraft] = useState<DraftMessage | null>(null);
|
||||
|
||||
// Compose modal state
|
||||
const [isComposeOpen, setIsComposeOpen] = useState(false);
|
||||
const [composeInitialRecipient, setComposeInitialRecipient] = useState<any>(undefined);
|
||||
const [composeInitialSubject, setComposeInitialSubject] = useState('');
|
||||
const [composeInitialMessage, setComposeInitialMessage] = useState('');
|
||||
const [composeInitialPlatform, setComposeInitialPlatform] = useState<NotificationPlatform>(NotificationPlatform.InApp);
|
||||
|
||||
// Inline quick reply state
|
||||
const [inlineReplyText, setInlineReplyText] = useState('');
|
||||
const [inlineReplySuccess, setInlineReplySuccess] = useState(false);
|
||||
|
||||
// Active folder message items
|
||||
const folderMessages = useMemo(() => {
|
||||
switch (activeFolder) {
|
||||
case 'inbox':
|
||||
return allMailboxNotifications;
|
||||
case 'starred':
|
||||
return starredNotifications;
|
||||
case 'sent':
|
||||
return sentNotifications;
|
||||
case 'trash':
|
||||
return trashedNotifications;
|
||||
case 'drafts':
|
||||
return [];
|
||||
default:
|
||||
return allMailboxNotifications;
|
||||
}
|
||||
}, [activeFolder, allMailboxNotifications, starredNotifications, sentNotifications, trashedNotifications]);
|
||||
|
||||
// Filtered by search, platform, and unread toggle
|
||||
const filteredMessages = useMemo(() => {
|
||||
return folderMessages.filter(item => {
|
||||
// Platform filter
|
||||
if (platformFilter === 'inapp' && Number(item.platform) !== NotificationPlatform.InApp) {
|
||||
return false;
|
||||
}
|
||||
if (platformFilter === 'email' && Number(item.platform) !== NotificationPlatform.Email) {
|
||||
return false;
|
||||
}
|
||||
// Unread only filter
|
||||
if (activeFolder === 'inbox' && showUnreadOnly) {
|
||||
if (Number(item.status) === NotificationStatus.Read) return false;
|
||||
}
|
||||
// Search term
|
||||
if (!searchTerm.trim()) return true;
|
||||
const term = searchTerm.toLowerCase();
|
||||
const subject = (item.subject || '').toLowerCase();
|
||||
const message = (item.message || '').toLowerCase();
|
||||
const dest = (item.destination || '').toLowerCase();
|
||||
return subject.includes(term) || message.includes(term) || dest.includes(term);
|
||||
});
|
||||
}, [folderMessages, platformFilter, activeFolder, showUnreadOnly, searchTerm]);
|
||||
|
||||
// Filtered drafts for Drafts folder
|
||||
const filteredDrafts = useMemo(() => {
|
||||
if (activeFolder !== 'drafts') return [];
|
||||
if (!searchTerm.trim()) return drafts;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return drafts.filter(
|
||||
d =>
|
||||
d.subject.toLowerCase().includes(term) ||
|
||||
d.message.toLowerCase().includes(term) ||
|
||||
d.recipientEmail.toLowerCase().includes(term)
|
||||
);
|
||||
}, [drafts, activeFolder, searchTerm]);
|
||||
|
||||
// Automatically select message when URL param ?id=... is present
|
||||
useEffect(() => {
|
||||
if (targetIdParam) {
|
||||
const found = folderMessages.find(m => String(m.id) === targetIdParam);
|
||||
if (found) {
|
||||
setSelectedMessage(found);
|
||||
if (Number(found.status) !== NotificationStatus.Read) {
|
||||
markAsRead(found.id);
|
||||
}
|
||||
}
|
||||
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
|
||||
setSelectedMessage(folderMessages[0]);
|
||||
}
|
||||
}, [targetIdParam, folderMessages, activeFolder]);
|
||||
|
||||
// Handle message selection
|
||||
const handleSelectMessage = (item: Notification) => {
|
||||
setSelectedMessage(item);
|
||||
setSearchParams({ id: String(item.id) });
|
||||
if (Number(item.status) !== NotificationStatus.Read) {
|
||||
markAsRead(item.id);
|
||||
}
|
||||
setInlineReplyText('');
|
||||
setInlineReplySuccess(false);
|
||||
};
|
||||
|
||||
// Open Compose Modal for a new message
|
||||
const handleOpenCompose = (defaultPlatform: NotificationPlatform = NotificationPlatform.InApp) => {
|
||||
setSelectedDraft(null);
|
||||
setComposeInitialRecipient(undefined);
|
||||
setComposeInitialSubject('');
|
||||
setComposeInitialMessage('');
|
||||
setComposeInitialPlatform(defaultPlatform);
|
||||
setIsComposeOpen(true);
|
||||
};
|
||||
|
||||
// Open Compose Modal for a Reply
|
||||
const handleReplyMessage = (msg: Notification) => {
|
||||
const rawDest = msg.destination || '';
|
||||
let replyEmail = '';
|
||||
if (rawDest.includes('From:')) {
|
||||
const fromPart = rawDest.split('From:')[1]?.trim() || '';
|
||||
replyEmail = fromPart.replace(/[<>]/g, '').trim();
|
||||
} else if (rawDest.includes('@')) {
|
||||
replyEmail = rawDest.trim();
|
||||
}
|
||||
|
||||
const replySubject = msg.subject?.startsWith('Re:') ? msg.subject : `Re: ${msg.subject || 'Communication'}`;
|
||||
const quotedBody = `\n\n--- Original Message (${formatDate(msg.createdAt)}) ---\n> ${stripMarkdown(msg.message || '')}`;
|
||||
|
||||
setSelectedDraft(null);
|
||||
setComposeInitialRecipient(
|
||||
replyEmail
|
||||
? {
|
||||
id: String(msg.recipient),
|
||||
email: replyEmail,
|
||||
name: replyEmail.split('@')[0],
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
setComposeInitialSubject(replySubject);
|
||||
setComposeInitialMessage(quotedBody);
|
||||
setComposeInitialPlatform(msg.platform as NotificationPlatform || NotificationPlatform.InApp);
|
||||
setIsComposeOpen(true);
|
||||
};
|
||||
|
||||
// Resume editing a saved draft
|
||||
const handleEditDraft = (draft: DraftMessage) => {
|
||||
setSelectedDraft(draft);
|
||||
setComposeInitialRecipient(
|
||||
draft.recipientId
|
||||
? {
|
||||
id: draft.recipientId,
|
||||
email: draft.recipientEmail,
|
||||
name: draft.recipientName,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
setComposeInitialSubject(draft.subject);
|
||||
setComposeInitialMessage(draft.message);
|
||||
setComposeInitialPlatform(draft.platform || NotificationPlatform.InApp);
|
||||
setIsComposeOpen(true);
|
||||
};
|
||||
|
||||
// Send inline quick reply
|
||||
const handleSendInlineReply = async () => {
|
||||
if (!selectedMessage || !inlineReplyText.trim()) return;
|
||||
|
||||
const rawDest = selectedMessage.destination || '';
|
||||
let replyEmail = '';
|
||||
if (rawDest.includes('From:')) {
|
||||
replyEmail = rawDest.split('From:')[1]?.trim().replace(/[<>]/g, '') || '';
|
||||
} else if (rawDest.includes('@')) {
|
||||
replyEmail = rawDest.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMessage({
|
||||
recipientId: String(selectedMessage.recipient),
|
||||
recipientEmail: replyEmail || currentUserEmail || 'team@dsi.gov.za',
|
||||
subject: selectedMessage.subject?.startsWith('Re:')
|
||||
? selectedMessage.subject
|
||||
: `Re: ${selectedMessage.subject || 'Notification'}`,
|
||||
message: inlineReplyText.trim(),
|
||||
platform: selectedMessage.platform as NotificationPlatform || NotificationPlatform.InApp,
|
||||
});
|
||||
|
||||
setInlineReplySuccess(true);
|
||||
setInlineReplyText('');
|
||||
setTimeout(() => {
|
||||
setInlineReplySuccess(false);
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Failed to send inline reply:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-[#090d16] text-slate-100 overflow-hidden font-sans">
|
||||
{/* Top Mailbox Breadcrumb & Action Banner */}
|
||||
<div className="h-14 px-6 border-b border-slate-800/80 bg-[#0d121f]/90 backdrop-blur-md flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400">
|
||||
<Mail className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-bold text-white tracking-tight">Mailbox & Communications</h1>
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/10 border border-blue-500/30 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
Markdown & Branded HTML Engine
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
In-App markdown messaging and executive branded email notifications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 text-xs font-medium text-slate-300 hover:text-white transition cursor-pointer shadow-xs"
|
||||
title="Refresh mailbox messages"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin text-blue-400' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCompose()}
|
||||
id="compose-message-main-btn"
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition cursor-pointer"
|
||||
>
|
||||
<PenSquare className="w-3.5 h-3.5" />
|
||||
<span>Compose</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Mailbox Three-Pane Layout */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* ======================================================== */}
|
||||
{/* PANE 1: Mailbox Folders Sidebar */}
|
||||
{/* ======================================================== */}
|
||||
<aside className="w-60 shrink-0 border-r border-slate-800/80 bg-[#0b101b]/95 p-3 flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
{/* Primary Compose Action */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCompose()}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-2xl bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-bold shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
>
|
||||
<PenSquare className="w-4 h-4" />
|
||||
<span>New Message</span>
|
||||
</button>
|
||||
|
||||
{/* Folder Navigation */}
|
||||
<nav className="space-y-1">
|
||||
{/* Inbox */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('inbox');
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'inbox'
|
||||
? 'bg-blue-600/20 text-white border border-blue-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Inbox className="w-4 h-4 text-blue-400" />
|
||||
<span>Inbox</span>
|
||||
</div>
|
||||
{unreadCount > 0 ? (
|
||||
<span className="text-[10px] font-bold text-white bg-blue-600 px-2 py-0.5 rounded-full shadow-xs">
|
||||
{unreadCount}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
{allMailboxNotifications.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Starred */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('starred');
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'starred'
|
||||
? 'bg-amber-500/20 text-white border border-amber-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Star className="w-4 h-4 text-amber-400" />
|
||||
<span>Starred</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
{starredNotifications.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Sent */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('sent');
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'sent'
|
||||
? 'bg-indigo-600/20 text-white border border-indigo-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Send className="w-4 h-4 text-indigo-400" />
|
||||
<span>Sent</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
{sentNotifications.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Drafts */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('drafts');
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'drafts'
|
||||
? 'bg-purple-600/20 text-white border border-purple-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FileText className="w-4 h-4 text-purple-400" />
|
||||
<span>Drafts</span>
|
||||
</div>
|
||||
{drafts.length > 0 && (
|
||||
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/20 border border-purple-500/30 px-2 py-0.5 rounded-full">
|
||||
{drafts.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Trash */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFolder('trash');
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition cursor-pointer ${
|
||||
activeFolder === 'trash'
|
||||
? 'bg-rose-600/20 text-white border border-rose-500/30'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Trash2 className="w-4 h-4 text-rose-400" />
|
||||
<span>Trash</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
{trashedNotifications.length}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Platform Filter Buttons */}
|
||||
<div className="pt-3 border-t border-slate-800/80 space-y-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 px-2 block">
|
||||
Channel Filter
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatformFilter('all')}
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
|
||||
platformFilter === 'all'
|
||||
? 'bg-slate-800 text-white font-semibold'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>All Channels</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatformFilter('inapp')}
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
|
||||
platformFilter === 'inapp'
|
||||
? 'bg-blue-600/25 text-blue-200 font-semibold border border-blue-500/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>In-App Markdown</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatformFilter('email')}
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs font-medium transition cursor-pointer ${
|
||||
platformFilter === 'email'
|
||||
? 'bg-emerald-600/25 text-emerald-200 font-semibold border border-emerald-500/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Email HTML</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Filter: Unread Toggle */}
|
||||
{activeFolder === 'inbox' && (
|
||||
<div className="pt-2 border-t border-slate-800/80">
|
||||
<label className="flex items-center gap-2 px-2.5 py-1.5 rounded-xl hover:bg-slate-800/50 cursor-pointer text-xs text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showUnreadOnly}
|
||||
onChange={e => setShowUnreadOnly(e.target.checked)}
|
||||
className="rounded border-slate-700 bg-slate-900 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>Unread only</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar Footer Branding */}
|
||||
<div className="p-3 rounded-2xl bg-slate-900/40 border border-slate-800/60 space-y-1 text-[11px] text-slate-400">
|
||||
<div className="flex items-center gap-1.5 text-slate-300 font-semibold">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span>Format Standards</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">
|
||||
InApp messages are stored as Markdown. Email notifications are transformed to branded HTML.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ======================================================== */}
|
||||
{/* PANE 2: Message List / Summary Cards */}
|
||||
{/* ======================================================== */}
|
||||
<section className="w-80 sm:w-96 shrink-0 border-r border-slate-800/80 bg-[#0d121f]/60 flex flex-col overflow-hidden">
|
||||
{/* Search Box Header */}
|
||||
<div className="p-3 border-b border-slate-800/80 bg-[#0d121f]/90 space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
placeholder="Search messages by subject, sender, or text..."
|
||||
className="w-full pl-8 pr-7 py-1.5 rounded-xl bg-slate-900 border border-slate-700/80 text-xs text-slate-100 placeholder-slate-400 focus:border-blue-500 focus:outline-none transition shadow-inner"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400 px-1">
|
||||
<span className="capitalize font-semibold text-slate-200">
|
||||
{activeFolder} ({activeFolder === 'drafts' ? filteredDrafts.length : filteredMessages.length})
|
||||
</span>
|
||||
{activeFolder === 'inbox' && unreadCount > 0 && (
|
||||
<span className="text-blue-400 font-medium">{unreadCount} unread</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List of Messages or Drafts */}
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-slate-800/60">
|
||||
{activeFolder === 'drafts' ? (
|
||||
filteredDrafts.length === 0 ? (
|
||||
<div className="py-16 px-6 text-center text-slate-400">
|
||||
<FileText className="w-8 h-8 mx-auto text-slate-600 mb-2" />
|
||||
<p className="text-xs font-semibold text-slate-300">No Drafts Saved</p>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
When you save unfinished messages, they will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredDrafts.map(d => (
|
||||
<div
|
||||
key={d.id}
|
||||
onClick={() => handleEditDraft(d)}
|
||||
className="p-3.5 hover:bg-slate-800/60 transition cursor-pointer group flex flex-col gap-1.5 relative"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[10px] font-bold text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.2 rounded">
|
||||
Draft (Markdown)
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{formatDate(d.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="text-xs font-semibold text-white group-hover:text-purple-300 transition truncate">
|
||||
{d.subject || 'Untitled Draft'}
|
||||
</h4>
|
||||
|
||||
<p className="text-[11px] text-slate-300 line-clamp-2 leading-relaxed">
|
||||
{stripMarkdown(d.message || 'No content written yet.')}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-[10px] text-slate-400 truncate">
|
||||
To: {d.recipientEmail || 'No recipient selected'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
deleteDraft(d.id);
|
||||
}}
|
||||
className="text-slate-400 hover:text-rose-400 p-1 rounded transition"
|
||||
title="Delete draft"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
) : filteredMessages.length === 0 ? (
|
||||
<div className="py-16 px-6 text-center text-slate-400">
|
||||
<MailOpen className="w-8 h-8 mx-auto text-slate-600 mb-2" />
|
||||
<p className="text-xs font-semibold text-slate-300">No Messages Found</p>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
{searchTerm
|
||||
? `No messages matched "${searchTerm}"`
|
||||
: `No items in ${activeFolder}`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredMessages.map(item => {
|
||||
const isSelected = selectedMessage?.id === item.id;
|
||||
const isUnread = Number(item.status) !== NotificationStatus.Read;
|
||||
const isStarred = starredIds.includes(String(item.id));
|
||||
const isEmail = Number(item.platform) === NotificationPlatform.Email;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleSelectMessage(item)}
|
||||
className={`p-3.5 transition cursor-pointer flex flex-col gap-1 relative ${
|
||||
isSelected
|
||||
? 'bg-blue-600/15 border-l-4 border-l-blue-500 text-white shadow-inner'
|
||||
: 'hover:bg-slate-800/50 border-l-4 border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
{/* Top line: unread dot, platform badge, star, time */}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{isUnread && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-blue-500 shrink-0 shadow-sm shadow-blue-500 animate-pulse"
|
||||
title="Unread notification"
|
||||
/>
|
||||
)}
|
||||
<span className="text-[11px] font-semibold text-slate-300 truncate">
|
||||
{item.destination?.includes('From:')
|
||||
? item.destination.split('From:')[1]?.trim().replace(/[<>]/g, '')
|
||||
: item.destination || 'PFM System'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{isEmail ? (
|
||||
<span className="text-[9px] font-bold text-emerald-300 bg-emerald-500/15 border border-emerald-500/30 px-1.5 py-0.2 rounded font-mono">
|
||||
Email
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-bold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-1.5 py-0.2 rounded font-mono">
|
||||
InApp
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
toggleStar(item.id);
|
||||
}}
|
||||
className={`p-0.5 rounded transition ${
|
||||
isStarred ? 'text-amber-400 hover:text-amber-300' : 'text-slate-400 hover:text-slate-300'
|
||||
}`}
|
||||
title={isStarred ? 'Unstar message' : 'Star message'}
|
||||
>
|
||||
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
|
||||
</button>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{formatDate(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<h4
|
||||
className={`text-xs truncate ${
|
||||
isUnread ? 'font-bold text-white' : 'font-medium text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{item.subject || 'Statutory Platform Notice'}
|
||||
</h4>
|
||||
|
||||
{/* Snippet preview stripped of markdown syntax */}
|
||||
<p className="text-[11px] text-slate-400 line-clamp-2 leading-relaxed">
|
||||
{stripMarkdown(item.message || 'No additional details provided.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ======================================================== */}
|
||||
{/* PANE 3: Full Message Detail & Interactive Actions */}
|
||||
{/* ======================================================== */}
|
||||
<main className="flex-1 bg-[#090d16] flex flex-col overflow-y-auto">
|
||||
{activeFolder === 'drafts' ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center text-slate-400">
|
||||
<FileText className="w-12 h-12 text-slate-600 mb-3" />
|
||||
<h3 className="text-sm font-bold text-white">Drafts Folder</h3>
|
||||
<p className="text-xs text-slate-400 max-w-md mt-1 mb-4">
|
||||
Click any draft on the left pane to resume composing in Markdown and dispatch your notification.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCompose()}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-lg shadow-blue-600/30 transition cursor-pointer"
|
||||
>
|
||||
<PenSquare className="w-4 h-4" />
|
||||
<span>Create New Message</span>
|
||||
</button>
|
||||
</div>
|
||||
) : !selectedMessage ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center text-slate-400">
|
||||
<div className="w-16 h-16 rounded-3xl bg-slate-900 border border-slate-800 flex items-center justify-center text-slate-600 mb-4 shadow-xl">
|
||||
<Mail className="w-8 h-8" />
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-white">No Message Selected</h3>
|
||||
<p className="text-xs text-slate-400 max-w-sm mt-1 mb-4">
|
||||
Select an in-app conversation or email notice from the list to view its rendered markdown and attachments.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenCompose()}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-200 border border-slate-700 text-xs font-semibold transition cursor-pointer"
|
||||
>
|
||||
<PenSquare className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Compose New Message</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Message Header Toolbar */}
|
||||
<div className="p-5 border-b border-slate-800/80 bg-[#0d121f]/60 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReplyMessage(selectedMessage)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/20 transition cursor-pointer"
|
||||
title="Reply to message"
|
||||
>
|
||||
<Reply className="w-3.5 h-3.5" />
|
||||
<span>Reply</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStar(selectedMessage.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl border text-xs font-medium transition cursor-pointer ${
|
||||
starredIds.includes(String(selectedMessage.id))
|
||||
? 'bg-amber-500/15 border-amber-500/30 text-amber-300'
|
||||
: 'bg-slate-900/80 border-slate-700/80 text-slate-300 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Star
|
||||
className={`w-3.5 h-3.5 ${
|
||||
starredIds.includes(String(selectedMessage.id)) ? 'fill-amber-400' : ''
|
||||
}`}
|
||||
/>
|
||||
<span>{starredIds.includes(String(selectedMessage.id)) ? 'Starred' : 'Star'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{activeFolder === 'trash' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => restoreMessage(selectedMessage.id)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-900/80 hover:bg-slate-800 border border-slate-700 text-xs text-slate-200 transition cursor-pointer"
|
||||
title="Restore message to Inbox"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Restore</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => permanentDelete(selectedMessage.id)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-rose-950/40 hover:bg-rose-900/40 border border-rose-800/60 text-xs text-rose-300 transition cursor-pointer"
|
||||
title="Permanently erase message"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-rose-400" />
|
||||
<span>Delete Forever</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
deleteMessage(selectedMessage.id);
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-rose-400 hover:bg-rose-950/20 border border-transparent hover:border-rose-900/40 transition cursor-pointer"
|
||||
title="Move message to Trash"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Details & Body */}
|
||||
<div className="p-6 space-y-6 flex-1 overflow-y-auto max-w-4xl">
|
||||
{/* Subject Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white tracking-tight leading-tight">
|
||||
{selectedMessage.subject || 'In-App Notification'}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
{Number(selectedMessage.platform) === NotificationPlatform.Email ? (
|
||||
<span className="text-[10px] font-semibold text-emerald-300 bg-emerald-500/15 border border-emerald-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
|
||||
<Mail className="w-3 h-3 text-emerald-400" />
|
||||
Channel: Email (Branded HTML)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/15 border border-blue-500/30 px-2 py-0.5 rounded-md flex items-center gap-1">
|
||||
<Smartphone className="w-3 h-3 text-blue-400" />
|
||||
Channel: InApp (Markdown)
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-[10px] font-mono text-slate-400 bg-slate-900 px-2 py-0.5 rounded-md border border-slate-800">
|
||||
ID: #{selectedMessage.id}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Received: {formatDate(selectedMessage.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sender & Recipient Card */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-slate-800 flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center font-bold text-xs text-white shadow-md shadow-blue-500/20 shrink-0">
|
||||
{selectedMessage.destination?.includes('@')
|
||||
? selectedMessage.destination.split('@')[0].slice(0, 2).toUpperCase()
|
||||
: 'PF'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-bold text-white">
|
||||
{selectedMessage.destination?.includes('From:')
|
||||
? selectedMessage.destination.split('From:')[1]?.trim().replace(/[<>]/g, '')
|
||||
: selectedMessage.destination || 'PFM Automated Dispatcher'}
|
||||
</p>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
({Number(selectedMessage.platform) === NotificationPlatform.Email ? 'SMTP Protocol' : 'InApp Protocol'})
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
To:{' '}
|
||||
<strong className="text-slate-300">
|
||||
{selectedMessage.destination?.includes('To:')
|
||||
? selectedMessage.destination.split('To:')[1]?.split('|')[0]?.trim()
|
||||
: currentUserEmail || 'You'}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDate(selectedMessage.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fully-Rendered Markdown & HTML Message Body with Branding */}
|
||||
<MarkdownViewer
|
||||
content={selectedMessage.message || ''}
|
||||
subject={selectedMessage.subject || ''}
|
||||
showBrandingHeader={true}
|
||||
/>
|
||||
|
||||
{/* Inline Quick Reply Section */}
|
||||
<div className="pt-6 border-t border-slate-800 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-bold text-white flex items-center gap-1.5">
|
||||
<Reply className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Quick Markdown Reply</span>
|
||||
</h4>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Supports Markdown formatting (e.g. **bold**, - lists, `code`)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{inlineReplySuccess && (
|
||||
<div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<span>Reply dispatched successfully!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
rows={4}
|
||||
value={inlineReplyText}
|
||||
onChange={e => setInlineReplyText(e.target.value)}
|
||||
placeholder="Type your markdown reply... (e.g. **Approved** milestone deliverable proof)"
|
||||
className="w-full p-3.5 rounded-2xl 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 resize-y font-mono leading-relaxed"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReplyMessage(selectedMessage)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 font-medium transition cursor-pointer"
|
||||
>
|
||||
Open rich compose editor with live preview
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSending || !inlineReplyText.trim()}
|
||||
onClick={handleSendInlineReply}
|
||||
className="flex items-center gap-2 px-5 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold shadow-md shadow-blue-600/30 transition disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
{isSending ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>Send Reply</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Compose Message Modal */}
|
||||
<ComposeMessageModal
|
||||
isOpen={isComposeOpen}
|
||||
onClose={() => setIsComposeOpen(false)}
|
||||
initialRecipient={composeInitialRecipient}
|
||||
initialSubject={composeInitialSubject}
|
||||
initialMessage={composeInitialMessage}
|
||||
initialPlatform={composeInitialPlatform}
|
||||
draftToEdit={selectedDraft}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -98,6 +98,8 @@ import {
|
||||
Claim,
|
||||
CreateUserRequest,
|
||||
AddUserClaimRequest,
|
||||
CreateNotificationRequest,
|
||||
UpdateNotificationRequest,
|
||||
} from '../types';
|
||||
|
||||
import {
|
||||
@@ -1614,6 +1616,53 @@ export const getNotifications = async (organisationId?: string | number): Promis
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getUserNotifications = async (userId?: string): Promise<Notification[]> => {
|
||||
try {
|
||||
const url = userId ? `/v1/api/communications/notifications/user/${encodeURIComponent(userId)}` : `/v1/api/communications/notifications/inbox`;
|
||||
const res = await apiClient.get<Notification[]>(url);
|
||||
if (res.data && Array.isArray(res.data)) {
|
||||
return res.data;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[getUserNotifications] API error:', err);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const createNotification = async (payload: CreateNotificationRequest): Promise<void> => {
|
||||
const tenantId = getCurrentUserTenantId();
|
||||
const orgId = payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null
|
||||
? toIntegerId(payload.organisationId)
|
||||
: (tenantId !== null ? tenantId : 1);
|
||||
|
||||
await apiClient.post('/v1/api/communications/notifications', {
|
||||
organisationId: orgId,
|
||||
recipient: payload.recipient,
|
||||
platform: payload.platform,
|
||||
subject: payload.subject || null,
|
||||
destination: payload.destination || 'In-App User Inbox',
|
||||
message: payload.message,
|
||||
status: payload.status !== undefined ? payload.status : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export const updateNotification = async (payload: UpdateNotificationRequest): Promise<void> => {
|
||||
await apiClient.put('/v1/api/communications/notifications', {
|
||||
notificationId: toIntegerId(payload.notificationId),
|
||||
destination: payload.destination || undefined,
|
||||
status: payload.status,
|
||||
sentAt: payload.sentAt || new Date().toISOString(),
|
||||
error: payload.error || null,
|
||||
});
|
||||
};
|
||||
|
||||
export const markNotificationAsRead = async (notificationId: number | string): Promise<void> => {
|
||||
await updateNotification({
|
||||
notificationId: toIntegerId(notificationId),
|
||||
status: NotificationStatus.Read,
|
||||
});
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// 9. Governance: Tenants APIs
|
||||
// ==========================================
|
||||
|
||||
@@ -28,6 +28,7 @@ export enum NotificationStatus {
|
||||
Failed = 7,
|
||||
Cancelled = 8,
|
||||
Bounced = 9,
|
||||
Draft = 10,
|
||||
}
|
||||
|
||||
export enum NotificationPlatform {
|
||||
@@ -856,6 +857,24 @@ export interface InviteRequest {
|
||||
platform: NotificationPlatform;
|
||||
}
|
||||
|
||||
export interface CreateNotificationRequest {
|
||||
organisationId: number | string;
|
||||
recipient: string;
|
||||
platform: NotificationPlatform;
|
||||
subject?: string | null;
|
||||
destination?: string | null;
|
||||
message: string;
|
||||
status?: NotificationStatus;
|
||||
}
|
||||
|
||||
export interface UpdateNotificationRequest {
|
||||
notificationId: number;
|
||||
destination?: string | null;
|
||||
status: NotificationStatus;
|
||||
sentAt?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// New OpenAPI 3.1.1 Entities & Request Models
|
||||
// ==========================================
|
||||
|
||||
@@ -23,6 +23,7 @@ public enum NotificationStatus : int
|
||||
Failed = 7,
|
||||
Cancelled = 8,
|
||||
Bounced = 9,
|
||||
Draft = 10,
|
||||
}
|
||||
|
||||
public enum NotificationPlatform : int
|
||||
|
||||
Reference in New Issue
Block a user