Implemented mailbox in ConsoleUI #155
@@ -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;
|
||||||
using PostFundManagement.Domain.Attributes;
|
using PostFundManagement.Domain.Attributes;
|
||||||
using PostFundManagement.Domain.Models;
|
using PostFundManagement.Domain.Models;
|
||||||
@@ -31,6 +31,9 @@ public interface ICommunicationsService
|
|||||||
[Log]
|
[Log]
|
||||||
Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default);
|
Task<Result<Notification[]>> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
[Log]
|
||||||
|
Task<Result<Notification[]>> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
[Log]
|
[Log]
|
||||||
Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default);
|
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.Extensions;
|
||||||
using PostFundManagement.Application.Services.Shared;
|
using PostFundManagement.Application.Services.Shared;
|
||||||
using PostFundManagement.Domain;
|
using PostFundManagement.Domain;
|
||||||
@@ -152,6 +152,9 @@ public sealed class CommunicationsCacheService(CommunicationsService service, ID
|
|||||||
return Result.Ok(cached.FromJson<Notification[]>());
|
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) =>
|
public async Task<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default) =>
|
||||||
await service.SendInviteAsync(request, userId, cancellationToken);
|
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.Application.Services.Shared;
|
||||||
using PostFundManagement.Domain;
|
using PostFundManagement.Domain;
|
||||||
using PostFundManagement.Domain.Abstractions;
|
using PostFundManagement.Domain.Abstractions;
|
||||||
@@ -94,6 +94,8 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
|||||||
{
|
{
|
||||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
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
|
context.Notifications.Add(new Domain.Entities.Notification
|
||||||
{
|
{
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
@@ -103,7 +105,7 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
|||||||
Subject = request.Subject,
|
Subject = request.Subject,
|
||||||
Message = request.Message,
|
Message = request.Message,
|
||||||
Platform = request.Platform,
|
Platform = request.Platform,
|
||||||
Status = NotificationStatus.Queued,
|
Status = initialStatus,
|
||||||
TenantId = tenantContext.TenantId
|
TenantId = tenantContext.TenantId
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,16 +118,17 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
|
|||||||
{
|
{
|
||||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
var changedTotal = await context.Notifications
|
var entity = await context.Notifications.FirstOrDefaultAsync(n => n.Id == request.NotificationId, cancellationToken);
|
||||||
.Where(n => n.Id == request.NotificationId)
|
if (entity is null)
|
||||||
.ExecuteUpdateAsync(f => f
|
return Result.Fail(NotFoundError.Create($"Notification #{request.NotificationId} not found"));
|
||||||
.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);
|
|
||||||
|
|
||||||
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) =>
|
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() ?? []);
|
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) =>
|
public async Task<Result<Notification>> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default) =>
|
||||||
await Result.Try((Func<Task<Result<Notification>>>)(async () =>
|
await Result.Try((Func<Task<Result<Notification>>>)(async () =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
using PostFundManagement.Domain;
|
using PostFundManagement.Domain;
|
||||||
|
|
||||||
namespace PostFundManagement.Application.Services.Communications;
|
namespace PostFundManagement.Application.Services.Communications;
|
||||||
|
|
||||||
public sealed record UpdateNotificationRequest([Required] long NotificationId, string? Destination,
|
public sealed record UpdateNotificationRequest([Required] long NotificationId,
|
||||||
[Required] NotificationStatus Status,
|
string? Destination = null,
|
||||||
[Required] DateTime SentAt,
|
[Required] NotificationStatus Status = NotificationStatus.Read,
|
||||||
[MaxLength(TextLength)] string? Error);
|
DateTime? SentAt = null,
|
||||||
|
[MaxLength(TextLength)] string? Error = null);
|
||||||
|
|
||||||
public sealed record CreateNotificationRequest([Required] long OrganisationId,
|
public sealed record CreateNotificationRequest([Required] long OrganisationId,
|
||||||
[Required] Guid Recipient,
|
[Required] Guid Recipient,
|
||||||
[Required] NotificationPlatform Platform,
|
[Required] NotificationPlatform Platform,
|
||||||
[Required][MaxLength(LabelLength)] string? Subject,
|
[Required][MaxLength(LabelLength)] string? Subject,
|
||||||
[Required][MaxLength(LabelLength)] string? Destination,
|
[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,
|
public sealed record InviteRequest([Required] long OrganisationId,
|
||||||
[Required] long InvitedOrganisationId,
|
[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 MonitoringPage = lazy(() => import('./pages/MonitoringPage').then(m => ({ default: m.MonitoringPage })));
|
||||||
const TenantManagementPage = lazy(() => import('./pages/TenantManagementPage').then(m => ({ default: m.TenantManagementPage })));
|
const TenantManagementPage = lazy(() => import('./pages/TenantManagementPage').then(m => ({ default: m.TenantManagementPage })));
|
||||||
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
|
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
|
||||||
|
const InboxPage = lazy(() => import('./pages/InboxPage').then(m => ({ default: m.InboxPage })));
|
||||||
|
|
||||||
const RouteLoading = () => (
|
const RouteLoading = () => (
|
||||||
<div className="flex items-center justify-center min-h-[400px] w-full text-slate-400">
|
<div className="flex items-center justify-center min-h-[400px] w-full text-slate-400">
|
||||||
@@ -181,6 +182,8 @@ export default function App() {
|
|||||||
</TenantManagementProtectedRoute>
|
</TenantManagementProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/inbox" element={<InboxPage />} />
|
||||||
|
<Route path="/mailbox" element={<InboxPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { NotificationBell } from './NotificationBell';
|
||||||
import {
|
import {
|
||||||
|
Inbox,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Building2,
|
Building2,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
@@ -121,6 +123,12 @@ const navItems: NavItem[] = [
|
|||||||
help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.',
|
help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.',
|
||||||
claim: 'tenant_management',
|
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',
|
name: 'Settings & Communications',
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
@@ -525,6 +533,8 @@ export const AppLayout: React.FC = () => {
|
|||||||
|
|
||||||
{/* User Profile and Controls */}
|
{/* User Profile and Controls */}
|
||||||
<div className="relative flex items-center gap-3">
|
<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="text-right hidden sm:block">
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
<p className="text-xs font-semibold text-white truncate max-w-[150px]" title={tokenUserName}>
|
<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>
|
<p className="text-[10px] text-slate-400 mt-1">Role: <strong className="text-blue-300">{userRole}</strong></p>
|
||||||
</div>
|
</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
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
onClick={() => setUserMenuOpen(false)}
|
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.Failed, label: 'Failed' },
|
||||||
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
|
||||||
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
{ value: NotificationStatus.Bounced, label: 'Bounced' },
|
||||||
|
{ value: NotificationStatus.Draft, label: 'Draft' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
export const NOTIFICATION_PLATFORM_OPTIONS = [
|
||||||
@@ -509,6 +510,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
|
|||||||
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
|
||||||
const s = Number(status);
|
const s = Number(status);
|
||||||
switch (s) {
|
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.Delivered:
|
||||||
case NotificationStatus.Read:
|
case NotificationStatus.Read:
|
||||||
return { label: getNotificationStatusLabel(s), className: 'bg-emerald-50 text-emerald-800 border-emerald-300 font-semibold' };
|
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,
|
Claim,
|
||||||
CreateUserRequest,
|
CreateUserRequest,
|
||||||
AddUserClaimRequest,
|
AddUserClaimRequest,
|
||||||
|
CreateNotificationRequest,
|
||||||
|
UpdateNotificationRequest,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -1614,6 +1616,53 @@ export const getNotifications = async (organisationId?: string | number): Promis
|
|||||||
return [];
|
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
|
// 9. Governance: Tenants APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export enum NotificationStatus {
|
|||||||
Failed = 7,
|
Failed = 7,
|
||||||
Cancelled = 8,
|
Cancelled = 8,
|
||||||
Bounced = 9,
|
Bounced = 9,
|
||||||
|
Draft = 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum NotificationPlatform {
|
export enum NotificationPlatform {
|
||||||
@@ -856,6 +857,24 @@ export interface InviteRequest {
|
|||||||
platform: NotificationPlatform;
|
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
|
// New OpenAPI 3.1.1 Entities & Request Models
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public enum NotificationStatus : int
|
|||||||
Failed = 7,
|
Failed = 7,
|
||||||
Cancelled = 8,
|
Cancelled = 8,
|
||||||
Bounced = 9,
|
Bounced = 9,
|
||||||
|
Draft = 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum NotificationPlatform : int
|
public enum NotificationPlatform : int
|
||||||
|
|||||||
Reference in New Issue
Block a user